Классы данных появились в Python 3.7 и быстро стали стандартом: меньше бойлерплейта, чем в обычных классах, проще, чем attrs, и не требуют зависимостей. Выглядят настолько просто, что кажется, что сломаться там нечему. Но у них есть три ловушки, которые не имеют значения при написании.
from dataclasses import dataclass
@dataclass
класс UserConfig:
имя: str
разрешения: список[str] = []
...не компилируется. Python выдает ValueError: изменяемое значение по умолчанию и отказывается создавать класс. Это хорошо: ошибка ловится сразу, разработчик идёт в документации и узнаёт про поле(default_factory=...).
Проблема в другом. Часто разработчик «исправляет» ошибку вот так:
@dataclass
класс UserConfig:
имя: str
разрешения: список[str] = Нет
def __post_init__(self):
если self.permissions равно None:
self.permissions = []
Формально работает, но разрешения теперь необязательные[list[str]], хотя по смыслу это всегда список. Mypy будет ругаться (или молчать с типом: игнорировать), и каждое место использования будет проверяться либо на None, либо на типе двери. Через полгода кто-то передаёт Ничего явно, думая, что это допустимо, и получит баг.
Правильный способ:
from dataclasses import dataclass, field
@dataclass
класс UserConfig:
имя: str
разрешения: список[str] = поле(default_factory=list)
default_factory возникает при каждом создании экземпляра. Каждый UserConfig получает свой пустой список. Тип всегда list[str], никакой Необязательно, mypy доволен.
Для более сложных результатов по умолчанию — лямбда:
@dataclass
класс PipelineConfig:
имя: str
шаги: список[str] = поле(default_factory=lambda: ["validate", "transform", "load"])
метаданные: dict[str, str] = field(default_factory=dict)
Каждый экземпляр получает свои шаги по настройке и свой пустой документ. Мутация одного экземпляра не затрагивает другие.
Думаю бы, мелочь. Но часто случается так, что один экземпляр UserConfig мутировал разрешения общего доступа, а все остальные экземпляры (созданные до исправлений, когда по умолчанию был списком, а не заводским) внезапно резко изменили чужие разрешения. Р
@dataclass(frozen=True)
Отчет по классу:
заголовок: строка
теги: список[str] = поле(default_factory=list)
отчет = Отчет(title="Q1", tags=["финансы"])
замороженный=True запрещает получение разрешения в полях. report.title = "Q2" вызывает FrozenInstanceError. Пока всё логично. Но вот это работает без ошибок:
report.tags.append("urgent")
print(report.tags) # ['finance', 'срочно'] — объект изменился!
Frosted=True запрещает setattr (переназначение полей), но не запрещает изменение значения поля. report.tags = new_list упадёт. report.tags.append(item) пройдёт. Python не может запретить мутацию причинного объекта, потому что не знает, какие его методы меняют состояние, а какое нет.
Frozen-dataclassы часто используют ключевые слова и элементы множества, потому что Frozen=True хэш цепи . И вот что получается:
кэш = {}
key = Report(title="Q1", tags=["finance"])
cache[key] = "некоторый результат"
key.tags.append("urgent") # мутируемый карантин
print(cache[key]) # KeyError! Хеш изменился, ключ потерялся.
Хеш-объект зависит от первого результата и зависит от полей отбора. Когда вы мутируете список внутри замороженного объекта, хеш меняется, и словарь не может найти ключ, потому что он ищет новый хешу в бакете, куда объект попал по старому.
Если нужна настоящая иммутабельность, воспользуйтесь иммутабельными типами для всех полей:
@dataclass(frozen=True)
Отчет по классу:
заголовок: строка
теги: tuple[str, ...] # кортеж вместо списка
отчет = Отчет(title="Q1", tags=("финансы",))
# report.tags.append("urgent") # AttributeError: кортеж не содержит элемента append
Tuple, FrosedSet, Str — иммутабельные. Если все поля замороженного-датакласса используют такие типы, объект действительно неизменяемый. Хотя бы одно поле изменчивое, замороженная власть только от переназначения, но не от изменения поправки.
Простое правило: замороженный класс данных, который используется в качестве ключа или элемента распространения, должен сохранять только хешируемые и неизменяемые поля. Если нужен список, обернитесь в кортеж. Если нужен набор, воспользуйтесь замороженным набором. Если нужен dict... ну, тут сложнее, ноtypes.MappingProxyType или просто отказаться от dict в замороженном классе.
@dataclass
класс Животное:
имя: str
вес: поплавок
@dataclass
класс Собака (Животное):
порода: стр
животное = Животное(имя="Рекс", вес=30.0)
собака = Dog(name="Rex", weight=30.0, breed="Labrador")
print(animal == dog) # True
print(dog == animal) # False
животное == собака выдает Animal.__eq__, который сравнивает только поля Animal: имя и вес. Они произошли. О породе животных ничего не известно, поэтому не вчера. Результат: Верно.
собака == животное вызывает Dog.__eq__, который сравнивает все три поля: имя, вес, породу. У животного нет породы, Собака. eq возвращает NotImplemented, Python переворачивает и пробует Animal. eq , который, опять же, сравнивает только имя и вес. Результат: Ложь.
a == b и b == a дают разные результаты. Это соглашение по контракту (симметричность), и последствия проявляются в самых неожиданных местах:
животные = {животное, собака}
print(len(животные)) # 1 или 2? Зависит от порядка вставок.
Set хеширует объекты и впоследствии приводит к коллизии. Если животное добавлено первым, собака при сравнении сравнивается с ним через животное == собака (True), set решает, что это дубликат, и не добавить. Если собака первая, животное сравнивается через собаку == животное (Ложь), оба называются.
Исправить можно разными способами. Первый — проверьте тип в уравнении :
@dataclass
класс Животное:
имя: str
вес: поплавок
def __eq__(self, other):
если тип(other) не равен типу(self):
return NotImplemented
return (self.name, self.weight) == (other.name, other.weight)
Теперь Animal("Rex", 30) == Dog("Rex", 30, "Lab") вернёт NotImplemented с другой стороны, а Python вернёт False. Симметрично.
Второй — используйте eq=False на родителе и ограничивайте eq только на дочернем классе. Третий — не используйте датклассы друг от друга и используйте композицию:
@dataclass
класс AnimalInfo:
имя: str
вес: поплавок
@dataclass
класс Собака:
информация: AnimalInfo
порода: стр
Третий самый безопасный вариант, потому что не создаётся проблем с полями eq , hash и порядком. Проведение датклассов работает, но требует внимания к деталям, которые очень легко пропустить.
@dataclass
Базовый класс:
имя: str
значение: int = 0 # поле с дефолтом
@dataclass
класс Дочерний(Базовый):
метка: str # поле без дефолта — TypeError!
Python выбирает поля в порядке MRO: имя, значение (с дефолтом), метку (без дефолта). Поля без дефолта после полей с дефолтом запрещено (как и в обычных функциях). Решение для Python 3.10+:
@dataclass
класс Дочерний(Базовый):
метка: str = field(kw_only=True)
child = Child(name="test", label="important") # value=0 по умолчанию
kw_only=True делает поле аргументом только для ключевых слов, и оно не преобразуется в позиционном порядке.
Если вы видели другие проблемы с классами данных, пишите в комментариях. Спасибо, что дочитали.
Ошибка 1: изменяемое значение по умолчанию в полях
Вот этот код:from dataclasses import dataclass
@dataclass
класс UserConfig:
имя: str
разрешения: список[str] = []
...не компилируется. Python выдает ValueError: изменяемое значение по умолчанию и отказывается создавать класс. Это хорошо: ошибка ловится сразу, разработчик идёт в документации и узнаёт про поле(default_factory=...).
Проблема в другом. Часто разработчик «исправляет» ошибку вот так:
@dataclass
класс UserConfig:
имя: str
разрешения: список[str] = Нет
def __post_init__(self):
если self.permissions равно None:
self.permissions = []
Формально работает, но разрешения теперь необязательные[list[str]], хотя по смыслу это всегда список. Mypy будет ругаться (или молчать с типом: игнорировать), и каждое место использования будет проверяться либо на None, либо на типе двери. Через полгода кто-то передаёт Ничего явно, думая, что это допустимо, и получит баг.
Правильный способ:
from dataclasses import dataclass, field
@dataclass
класс UserConfig:
имя: str
разрешения: список[str] = поле(default_factory=list)
default_factory возникает при каждом создании экземпляра. Каждый UserConfig получает свой пустой список. Тип всегда list[str], никакой Необязательно, mypy доволен.
Для более сложных результатов по умолчанию — лямбда:
@dataclass
класс PipelineConfig:
имя: str
шаги: список[str] = поле(default_factory=lambda: ["validate", "transform", "load"])
метаданные: dict[str, str] = field(default_factory=dict)
Каждый экземпляр получает свои шаги по настройке и свой пустой документ. Мутация одного экземпляра не затрагивает другие.
Думаю бы, мелочь. Но часто случается так, что один экземпляр UserConfig мутировал разрешения общего доступа, а все остальные экземпляры (созданные до исправлений, когда по умолчанию был списком, а не заводским) внезапно резко изменили чужие разрешения. Р
Ошибка 2: замороженное=True не делает объект неизменяемым
Многие разработчики думают, что замороженный=True делает класс данных иммутабельным. Это не так.@dataclass(frozen=True)
Отчет по классу:
заголовок: строка
теги: список[str] = поле(default_factory=list)
отчет = Отчет(title="Q1", tags=["финансы"])
замороженный=True запрещает получение разрешения в полях. report.title = "Q2" вызывает FrozenInstanceError. Пока всё логично. Но вот это работает без ошибок:
report.tags.append("urgent")
print(report.tags) # ['finance', 'срочно'] — объект изменился!
Frosted=True запрещает setattr (переназначение полей), но не запрещает изменение значения поля. report.tags = new_list упадёт. report.tags.append(item) пройдёт. Python не может запретить мутацию причинного объекта, потому что не знает, какие его методы меняют состояние, а какое нет.
Frozen-dataclassы часто используют ключевые слова и элементы множества, потому что Frozen=True хэш цепи . И вот что получается:
кэш = {}
key = Report(title="Q1", tags=["finance"])
cache[key] = "некоторый результат"
key.tags.append("urgent") # мутируемый карантин
print(cache[key]) # KeyError! Хеш изменился, ключ потерялся.
Хеш-объект зависит от первого результата и зависит от полей отбора. Когда вы мутируете список внутри замороженного объекта, хеш меняется, и словарь не может найти ключ, потому что он ищет новый хешу в бакете, куда объект попал по старому.
Если нужна настоящая иммутабельность, воспользуйтесь иммутабельными типами для всех полей:
@dataclass(frozen=True)
Отчет по классу:
заголовок: строка
теги: tuple[str, ...] # кортеж вместо списка
отчет = Отчет(title="Q1", tags=("финансы",))
# report.tags.append("urgent") # AttributeError: кортеж не содержит элемента append
Tuple, FrosedSet, Str — иммутабельные. Если все поля замороженного-датакласса используют такие типы, объект действительно неизменяемый. Хотя бы одно поле изменчивое, замороженная власть только от переназначения, но не от изменения поправки.
Простое правило: замороженный класс данных, который используется в качестве ключа или элемента распространения, должен сохранять только хешируемые и неизменяемые поля. Если нужен список, обернитесь в кортеж. Если нужен набор, воспользуйтесь замороженным набором. Если нужен dict... ну, тут сложнее, ноtypes.MappingProxyType или просто отказаться от dict в замороженном классе.
Ошибка 3: исследование ломает сравнение
Это самая неприятная из трех ошибок, потому что результат зависит от порядка операндов.@dataclass
класс Животное:
имя: str
вес: поплавок
@dataclass
класс Собака (Животное):
порода: стр
животное = Животное(имя="Рекс", вес=30.0)
собака = Dog(name="Rex", weight=30.0, breed="Labrador")
print(animal == dog) # True
print(dog == animal) # False
животное == собака выдает Animal.__eq__, который сравнивает только поля Animal: имя и вес. Они произошли. О породе животных ничего не известно, поэтому не вчера. Результат: Верно.
собака == животное вызывает Dog.__eq__, который сравнивает все три поля: имя, вес, породу. У животного нет породы, Собака. eq возвращает NotImplemented, Python переворачивает и пробует Animal. eq , который, опять же, сравнивает только имя и вес. Результат: Ложь.
a == b и b == a дают разные результаты. Это соглашение по контракту (симметричность), и последствия проявляются в самых неожиданных местах:
животные = {животное, собака}
print(len(животные)) # 1 или 2? Зависит от порядка вставок.
Set хеширует объекты и впоследствии приводит к коллизии. Если животное добавлено первым, собака при сравнении сравнивается с ним через животное == собака (True), set решает, что это дубликат, и не добавить. Если собака первая, животное сравнивается через собаку == животное (Ложь), оба называются.
Исправить можно разными способами. Первый — проверьте тип в уравнении :
@dataclass
класс Животное:
имя: str
вес: поплавок
def __eq__(self, other):
если тип(other) не равен типу(self):
return NotImplemented
return (self.name, self.weight) == (other.name, other.weight)
Теперь Animal("Rex", 30) == Dog("Rex", 30, "Lab") вернёт NotImplemented с другой стороны, а Python вернёт False. Симметрично.
Второй — используйте eq=False на родителе и ограничивайте eq только на дочернем классе. Третий — не используйте датклассы друг от друга и используйте композицию:
@dataclass
класс AnimalInfo:
имя: str
вес: поплавок
@dataclass
класс Собака:
информация: AnimalInfo
порода: стр
Третий самый безопасный вариант, потому что не создаётся проблем с полями eq , hash и порядком. Проведение датклассов работает, но требует внимания к деталям, которые очень легко пропустить.
Ловушка с порядком полей при наследовании
Ещё одна проблема, которая связана с наследованием, но не с равенством:@dataclass
Базовый класс:
имя: str
значение: int = 0 # поле с дефолтом
@dataclass
класс Дочерний(Базовый):
метка: str # поле без дефолта — TypeError!
Python выбирает поля в порядке MRO: имя, значение (с дефолтом), метку (без дефолта). Поля без дефолта после полей с дефолтом запрещено (как и в обычных функциях). Решение для Python 3.10+:
@dataclass
класс Дочерний(Базовый):
метка: str = field(kw_only=True)
child = Child(name="test", label="important") # value=0 по умолчанию
kw_only=True делает поле аргументом только для ключевых слов, и оно не преобразуется в позиционном порядке.
Итой
Три пункта, которые стоит проверить перед тем, как dataclass попадает в прод. Все мутабельные дефолты через поле(default_factory=...), никаких = [] и = {} и тем более = None с __post_init__. Если заморожено = True, все поля иммутабельного типа, иначе замороженная власть только из переназначения. Если вы наследуете классы дат, проверьте симметричность эквалайзера и подумайте, не лучше ли состав .Если вы видели другие проблемы с классами данных, пишите в комментариях. Спасибо, что дочитали.