Dataclasses появились в Python 3.7 и быстро стали стандартом: меньше бойлерплейта, чем у обычных классов, проще, чем attrs, и не требуют зависимостей. Выглядят настолько просто, что кажется, что ломаться там нечему. Но у них есть три ловушки, которые не видны при написании.

Ошибка 1: mutable default в полях

Вот этот код:

from dataclasses import dataclass

@dataclass
class UserConfig:
    name: str
    permissions: list[str] = []

...не компилируется. Python выдаёт ValueError: mutable default value и отказывается создавать класс. Это хорошо: ошибка ловится сразу, разработчик идёт в документацию и узнаёт про field(default_factory=...).

Проблема в другом. Часто разработчик «исправляет» ошибку вот так:

@dataclass
class UserConfig:
    name: str
    permissions: list[str] = None

    def __post_init__(self):
        if self.permissions is None:
            self.permissions = []

Формально работает, но permissions теперь Optional[list[str]], хотя по смыслу это всегда список. Mypy будет ругаться (или молчать с type: ignore), и каждое место использования будет либо проверять на None, либо игнорировать тип. Через полгода кто-то передаст None явно, думая, что это допустимо, и получит баг.

Правильный способ:

from dataclasses import dataclass, field

@dataclass
class UserConfig:
    name: str
    permissions: list[str] = field(default_factory=list)

default_factory вызывается при каждом создании экземпляра. Каждый UserConfig получает свой пустой список. Тип всегда list[str], никакого Optional, mypy доволен.

Для более сложных значений по умолчанию — lambda:

@dataclass
class PipelineConfig:
    name: str
    steps: list[str] = field(default_factory=lambda: ["validate", "transform", "load"])
    metadata: dict[str, str] = field(default_factory=dict)

Каждый экземпляр получает свою копию steps и свой пустой dict. Мутация одного экземпляра не затрагивает другие.

Казалось бы, мелочь. Но часто бывает так, что один экземпляр UserConfig мутировал shared-список permissions, и все остальные экземпляры (созданные до исправления, когда default был списком, а не factory) внезапно получали чужие permissions. Р

Ошибка 2: frozen=True не делает объект неизменяемым

Многие разработчики думают, что frozen=True делает dataclass иммутабельным. Это не так.

@dataclass(frozen=True)
class Report:
    title: str
    tags: list[str] = field(default_factory=list)

report = Report(title="Q1", tags=["finance"])

frozen=True запрещает присваивание в поля. report.title = "Q2" вызовет FrozenInstanceError. Пока всё логично. Но вот это работает без ошибок:

report.tags.append("urgent")
print(report.tags)  # ['finance', 'urgent'] — объект изменился!

frozen=True запрещает setattr (переназначение поля), но не запрещает мутацию содержимого поля. report.tags = new_list упадёт. report.tags.append(item) пройдёт. Python не может запретить мутацию произвольного объекта, потому что не знает, какие его методы меняют состояние, а какие нет.

Frozen-датаклассы часто используют как ключи словарей и элементы множеств, потому что frozen=True генерирует hash. И вот что получается:

cache = {}
key = Report(title="Q1", tags=["finance"])
cache[key] = "some result"

key.tags.append("urgent")  # мутируем содержимое
print(cache[key])           # KeyError! Хеш изменился, ключ потерян.

Хеш объекта вычисляется при первом обращении и зависит от содержимого полей. Когда вы мутируете список внутри frozen-объекта, хеш меняется, и словарь не может найти ключ, потому что ищет по новому хешу в бакете, куда объект попал по старому.

Если нужна настоящая иммутабельность, используйте иммутабельные типы для всех полей:

@dataclass(frozen=True)
class Report:
    title: str
    tags: tuple[str, ...]  # tuple вместо list

report = Report(title="Q1", tags=("finance",))
# report.tags.append("urgent")  # AttributeError: tuple has no append

Tuple, frozenset, str — иммутабельные. Если все поля frozen-датакласса используют такие типы, объект действительно неизменяемый. Если хотя бы одно поле мутабельное, frozen защищает только от переназначения, но не от изменения содержимого.

Правило простое: frozen-датакласс, который используется как ключ или элемент множества, должен содержать только хешируемые и иммутабельные поля. Если нужен список, оберните в tuple. Если нужен set, используйте frozenset. Если нужен dict... ну, тут сложнее, но types.MappingProxyType или просто отказ от dict в frozen-классе.

Ошибка 3: наследование ломает сравнение

Это самая неприятная из трёх ошибок, потому что результат зависит от порядка операндов.

@dataclass
class Animal:
    name: str
    weight: float

@dataclass
class Dog(Animal):
    breed: str

animal = Animal(name="Rex", weight=30.0)
dog = Dog(name="Rex", weight=30.0, breed="Labrador")

print(animal == dog)  # True
print(dog == animal)  # False

animal == dog вызывает Animal.__eq__, который сравнивает только поля Animal: name и weight. Они совпадают. Про breed Animal ничего не знает, поэтому не проверяет. Результат: True.

dog == animal вызывает Dog.__eq__, который сравнивает все три поля: name, weight, breed. У animal нет breed, Dog.eq возвращает NotImplemented, Python переворачивает и пробует Animal.eq, который опять сравнивает только name и weight. Результат: False.

a == b и b == a дают разные результаты. Это нарушает контракт равенства (симметричность), и последствия проявляются в самых неожиданных местах:

animals = {animal, dog}
print(len(animals))  # 1 или 2? Зависит от порядка вставки.

Set хеширует объекты и проверяет равенство при коллизии. Если animal добавлен первым, а dog при вставке сравнивается с ним через animal == dog (True), set решит, что это дубликат, и не добавит. Если dog первый, а animal сравнивается через dog == animal (False), оба останутся.

Исправить можно тремя способами. Первый — проверять тип в eq:

@dataclass
class Animal:
    name: str
    weight: float

    def __eq__(self, other):
        if type(other) is not type(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
class AnimalInfo:
    name: str
    weight: float

@dataclass
class Dog:
    info: AnimalInfo
    breed: str

Третий вариант самый безопасный, потому что не создаёт проблем с eq, hash и порядком полей. Наследование датаклассов работает, но требует внимания к деталям, которые очень легко пропустить.

Ловушка с порядком полей при наследовании

Ещё одна проблема, которая связана с наследованием, но не с равенством:

@dataclass
class Base:
    name: str
    value: int = 0  # поле с дефолтом

@dataclass
class Child(Base):
    label: str  # поле без дефолта — TypeError!

Python объединяет поля в порядке MRO: name, value (с дефолтом), label (без дефолта). Поля без дефолта после полей с дефолтом запрещены (как в обычных функциях). Решение для Python 3.10+:

@dataclass
class Child(Base):
    label: str = field(kw_only=True)

child = Child(name="test", label="important")  # value=0 по умолчанию

kw_only=True делает поле keyword-only аргументом, и оно не участвует в позиционном порядке.

Итого

Три пункта, которые стоит проверить перед тем, как dataclass попадёт в прод. Все мутабельные дефолты через field(default_factory=...), никаких = [] и = {} и тем более = None с __post_init__. Если frozen=True, все поля иммутабельных типов, иначе frozen защищает только от переназначения. Если наследуете датаклассы, проверьте eq на симметричность и подумайте, не лучше ли композиция.

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


Такие ошибки редко ломают проект сразу, но хорошо показывают, насколько глубоко вы понимаете Python за пределами синтаксиса.

Если вы уже пишете на Python и хотите системно подтянуть уровень — архитектуру, асинхронность, производительность, типизацию и внутреннее устройство языка — можно начать с вступительного теста на курс «Python-разработчик». Он поможет понять, насколько текущего опыта достаточно для обучения на продвинутом уровне.

Практические разборы от экспертов в разработке ищите в календаре бесплатных демо-уроков.