dataclass в Python: что это и как использовать

Python Автор: Среда и версия: CPython 3.14.5
содержание

dataclass — декоратор, который дописывает в класс методы, обычно набираемые руками: конструктор, читаемое представление и сравнение по значению. Пишешь только имена полей с типами, остальное появляется само. Код выполнен на CPython 3.14.5.

Python / 01

Поля описываешь ты. Служебный код — dataclass

Поля описываешь ты. Служебный код — dataclass01 Описание id · amount status = "new" Поля и значения по умолчанию остаются явными. 02 Генерация __init__ · __repr__ __eq__ Декоратор создаёт обычные методы класса. 03 Экземпляр Order(10, 900, "new") Можно создать, напечатать и сравнить заказ.01Описаниеid · amountstatus = "new"Поля и значения по умолчанию остаются явными.02Генерация__init__ · __repr____eq__Декоратор создаёт обычные методы класса.03ЭкземплярOrder(10, 900, "new")Можно создать, напечатать и сравнить заказ.
Достаточно описать поля: @dataclass сам создаст конструктор, понятную печать и сравнение объектов по значениям.

Что делает декоратор

Класс, хранящий три значения, без него выглядит так:

class OrderPlain:
    def __init__(self, id, amount, status="new"):
        self.id = id
        self.amount = amount
        self.status = status

o = OrderPlain(10, 900)
print(repr(o).startswith("<__main__.OrderPlain object at"))
print(o == OrderPlain(10, 900))
True
False

Две проблемы сразу. Печать объекта даёт <__main__.OrderPlain object at 0x7f...> с адресом в памяти, который меняется от запуска к запуску и ничего не говорит о содержимом. Сравнение двух одинаковых заказов даёт False, потому что по умолчанию Python сравнивает объекты по тождеству, а не по значениям.

Тот же класс с декоратором:

from dataclasses import dataclass

@dataclass
class Order:
    id: int
    amount: int
    status: str = "new"

o = Order(10, 900)
print(o)
print(o == Order(10, 900))
Order(id=10, amount=900, status='new')
True

Конструктор не написан, а работает. Печать показывает поля. Сравнение идёт по значениям. Проверить, что именно сгенерировалось, можно прямо у класса:

from dataclasses import dataclass, fields

@dataclass
class Order:
    id: int
    amount: int
    status: str = "new"

print([m for m in ("__init__", "__repr__", "__eq__") if m in Order.__dict__])
print([f.name for f in fields(Order)])
['__init__', '__repr__', '__eq__']
['id', 'amount', 'status']

Механика та же, что у любого декоратора: он получает класс, дописывает в него методы и возвращает обратно.

Аннотация обязательна, но не проверяется

Поле появляется только у имени с аннотацией типа. Без неё это обычный атрибут класса:

from dataclasses import dataclass, fields

@dataclass
class Half:
    a: int
    b = 5

print([f.name for f in fields(Half)], Half(1).b)
['a'] 5

b не попал в поля, значит его нет ни в конструкторе, ни в сравнении. Пропущенное двоеточие с типом — тихая ошибка: класс соберётся, а поле исчезнет.

При этом сама аннотация ни на что не влияет во время работы. Python её не проверяет:

from dataclasses import dataclass

@dataclass
class Order:
    id: int
    amount: int
    status: str = "new"

print(Order("десять", "много"))
Order(id='десять', amount='много', status='new')

Строка вместо числа прошла без единого возражения. Аннотации нужны читателю и статическим анализаторам; проверку типов во время работы делают отдельные библиотеки.

Порядок полей и значения по умолчанию

Поле без значения не может стоять после поля со значением, иначе конструктор нельзя было бы вызвать позиционно:

from dataclasses import dataclass

try:
    @dataclass
    class Bad:
        status: str = "new"
        id: int
except TypeError as exc:
    print(f"TypeError: {exc}")
TypeError: non-default argument 'id' follows default argument 'status'

Обойти ограничение, не переставляя поля, помогает kw_only: тогда все аргументы становятся именованными и порядок перестаёт иметь значение.

from dataclasses import dataclass

@dataclass(kw_only=True)
class Conf:
    host: str
    port: int = 5432

print(Conf(host="localhost"))
try:
    Conf("localhost")
except TypeError as exc:
    print(f"TypeError: {exc}")
Conf(host='localhost', port=5432)
TypeError: Conf.__init__() takes 1 positional argument but 2 were given

Почему список нельзя записать по умолчанию

Попытка дать полю изменяемое значение по умолчанию отвергается сразу:

from dataclasses import dataclass

try:
    @dataclass
    class BadList:
        items: list = []
except ValueError as exc:
    print(f"ValueError: {exc}")
ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory

Это редкий случай, когда язык ловит ошибку за тебя. У обычной функции def f(items=[]) тот же дефект проходит молча и приводит к общему списку на все вызовы, а dataclass отказывается собираться и прямо называет решение.

default_factory вызывает фабрику при создании каждого объекта:

from dataclasses import dataclass, field

@dataclass
class Cart:
    items: list = field(default_factory=list)

a, b = Cart(), Cart()
a.items.append("мышь")
print(a, b)
Cart(items=['мышь']) Cart(items=[])

Списки разные, добавление в один не задело другой. Почему это вообще проблема и откуда она растёт, разобрано в статье про изменяемые и неизменяемые типы.

frozen: неизменяемый объект и ключ словаря

Аргумент frozen=True запрещает присваивание полям после создания:

from dataclasses import dataclass, FrozenInstanceError

@dataclass(frozen=True)
class Point:
    x: int
    y: int

p = Point(1, 2)
try:
    p.x = 5
except FrozenInstanceError as exc:
    print(f"FrozenInstanceError: {exc}")
print(Point(1, 2) in {p})
FrozenInstanceError: cannot assign to field 'x'
True

Вторая строка важнее первой. Этот замороженный объект попал в множество и нашёлся там по значению: его поля хешируемы, поэтому он годится и в ключи словаря. Одного frozen=True недостаточно: поле-список, участвующее в хеше, вызовет TypeError. Заморозка запрещает переприсваивать поля, но не менять содержимое вложенного списка. Обычный dataclass с настройками по умолчанию не хешируется:

from dataclasses import dataclass

@dataclass
class Order:
    id: int
    amount: int

try:
    {Order(10, 900)}
except TypeError as exc:
    print(f"TypeError: {exc}")
TypeError: cannot use 'Order' as a set element (unhashable type: 'Order')

При eq=True и frozen=False декоратор отключает __hash__, поскольку поля, от которых зависит равенство, могут измениться. Это не общий запрет на хеширование изменяемых объектов: обычные пользовательские классы по умолчанию сравниваются и хешируются по идентичности. Чтобы получить объект с изменённым полем, не меняя исходный, используют replace:

from dataclasses import dataclass, replace

@dataclass
class Order:
    id: int
    amount: int

o = Order(10, 900)
print(replace(o, amount=1200), o)
Order(id=10, amount=1200) Order(id=10, amount=900)

order: сортировка объектов

С order=True появляются операторы сравнения, и объекты можно сортировать без всякого key:

from dataclasses import dataclass

@dataclass(order=True)
class Score:
    points: int
    name: str

print(sorted([Score(3, "Аня"), Score(1, "Борис"), Score(3, "Вера")]))
[Score(points=1, name='Борис'), Score(points=3, name='Аня'), Score(points=3, name='Вера')]

Сравнение идёт по всем полям слева направо, как у кортежа: сначала очки, при равенстве имя. Отсюда важное следствие: порядок объявления полей задаёт порядок сортировки, и поле, попавшее в класс случайно, будет участвовать в сравнении.

Исключить поле из сравнения можно точечно:

from dataclasses import dataclass, field

@dataclass(order=True)
class Row:
    key: int
    note: str = field(compare=False, default="")

print(Row(1, "первый") == Row(1, "второй"))
True

Тот же приём у repr: field(repr=False) убирает поле из печати, что удобно для паролей и токенов.

from dataclasses import dataclass, field

@dataclass
class User:
    login: str
    password: str = field(repr=False, default="")

print(User("anya", "secret"))
User(login='anya')

Вычисляемое поле и превращение в словарь

Значение, зависящее от других полей, считают в __post_init__, а само поле помечают init=False, чтобы оно не просилось в конструктор:

from dataclasses import dataclass, field

@dataclass
class Rect:
    w: int
    h: int
    area: int = field(init=False)

    def __post_init__(self):
        self.area = self.w * self.h

print(Rect(3, 4))
Rect(w=3, h=4, area=12)

Для отдачи наружу объект разворачивают в словарь или кортеж. Вложенные объекты разворачиваются тоже:

from dataclasses import dataclass, asdict

@dataclass
class Order:
    id: int
    amount: int

@dataclass
class Client:
    name: str
    last: Order

print(asdict(Client("Аня", Order(10, 900))))
{'name': 'Аня', 'last': {'id': 10, 'amount': 900}}

Это готовый мост к JSON и к любому коду, который ждёт обычные словари.

Если self, __init__ и __repr__ пока выглядят загадочно, начни с разбора обычных классов: dataclass пишет их за тебя, но понимать, что именно он пишет, всё равно нужно.

Когда dataclass не нужен

Данные пришли извне и уходят наружу без обработки. Разобранный JSON — это уже словарь, и оборачивать его в класс ради одного прохода незачем.

Нужна проверка типов на входе. Аннотации в dataclass декоративные. Если данные приходят от пользователя и их надо валидировать, берут библиотеку с проверкой, а не пишут её сами в __post_init__.

Нужен только неизменяемый набор значений без методов. NamedTuple короче, распаковывается как кортеж и хешируется, если хешируемы все его элементы.

Класс на самом деле про поведение, а не про данные. Если полей два, а методов десять, это обычный класс, и декоратор ничего не улучшит.

Частые вопросы

Чем dataclass отличается от обычного класса

Ничем по устройству: это тот же класс, которому декоратор дописал __init__, __repr__ и __eq__. Всё остальное, включая методы и наследование, работает как обычно.

Как сделать поле со списком по умолчанию

field(default_factory=list). Просто написать = [] язык запрещает, чтобы список не оказался общим для всех объектов.

Как запретить менять объект после создания

@dataclass(frozen=True) запрещает присваивание полям, но не изменение вложенных объектов. При стандартном eq=True декоратор также создаёт __hash__; объект сможет быть ключом словаря, если все участвующие в хеше поля хешируемы.

Проверяет ли dataclass типы

Нет. Order("десять", "много") соберётся без ошибки. Аннотации нужны читателю и статическим анализаторам.

Чем dataclass отличается от NamedTuple

NamedTuple ведёт себя как кортеж: его поля нельзя переприсвоить, он распаковывается и индексируется. Хеширование возможно, если все элементы хешируемы. dataclass по умолчанию изменяем, допускает методы и настраивается аргументами декоратора.

Где потренироваться

В пути «Python: ООП» на Koddo классы данных идут вместе с моделями предметной области: заказ, клиент, позиция корзины. Рядом стоят декораторы, на которых dataclass и построен, и паттерны проектирования, где он служит готовым строителем.

Источники