3 Mistakes to Avoid When Working with DataClasses in Python

WILD

Administrator
Staff member
ADMIN
SELLER
SUPREME
MEMBER
Joined
Jan 21, 2025
Messages
220
Reaction score
631
Deposit
0$
Классы данных появились в Python 3.7 и быстро стали стандартом: меньше бойлерплейта, чем в обычных классах, проще, чем attrs, и не требуют зависимостей. Выглядят настолько просто, что кажется, что сломаться там нечему. Но у них есть три ловушки, которые не имеют значения при написании.

Ошибка 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, все поля иммутабельного типа, иначе замороженная власть только из переназначения. Если вы наследуете классы дат, проверьте симметричность эквалайзера и подумайте, не лучше ли состав .

Если вы видели другие проблемы с классами данных, пишите в комментариях. Спасибо, что дочитали.
 
Top Bottom