Зачем нужен ещё один способ описать данные
В прошлом уроке мы научились типизировать словари через TypedDict. Это работает, но у словарей есть две слабости:
- Доступ к полю — через
[]и магическую строку.user["email"]вместоuser.email. IDE подсказывает ключи, но это не то же самое, что атрибут объекта. Опечаткаuser["emial"]поймается только в runtime. - Нет вычисляемых полей и методов. TypedDict — это просто словарь, нельзя завести в нём
def display_name(self)или поле, которое вычисляется из других.
Когда нужны эти возможности, в Python пишут классы. Но писать класс руками — это писать __init__, потом __repr__, потом __eq__, потом, возможно, __hash__. Это десятки строк boilerplate для пятиполевой структуры:
class DataRecord:
def __init__(self, id: int, name: str, amount: float, created_at: str) -> None:
self.id = id
self.name = name
self.amount = amount
self.created_at = created_at
def __repr__(self) -> str:
return (
f"DataRecord(id={self.id!r}, name={self.name!r}, "
f"amount={self.amount!r}, created_at={self.created_at!r})"
)
def __eq__(self, other: object) -> bool:
if not isinstance(other, DataRecord):
return NotImplemented
return (
self.id == other.id
and self.name == other.name
and self.amount == other.amount
and self.created_at == other.created_at
)
Двадцать строк, чтобы сказать «вот класс с четырьмя полями». На пяти таких классах рука устаёт, на пятидесяти — теряется концентрация. И каждое поле повторяется четыре раза (init, init body, repr, eq), что само по себе источник багов.
Эту проблему решает
dataclasses@dataclass за минуту
from dataclasses import dataclass
@dataclass
class DataRecord:
id: int
name: str
amount: float
created_at: str
Всё. Этот код эквивалентен 20-строчному примеру выше:
r = DataRecord(id=1, name="Анна", amount=99.9, created_at="2026-01-15")
print(r)
# DataRecord(id=1, name='Анна', amount=99.9, created_at='2026-01-15')
r2 = DataRecord(id=1, name="Анна", amount=99.9, created_at="2026-01-15")
print(r == r2) # True — сравнение по значениям, не по id
Декоратор @dataclass читает аннотации класса и из них генерирует __init__ (с этими параметрами), __repr__ (форматирует все поля) и __eq__ (сравнивает все поля). Полей нет — нечего генерировать.
Поэтому правило: для
@dataclass. Двадцати строк никто не пишет — это атавизм из учебников 2015 года.
Декоратор читает аннотации полей и генерирует методы. Поля и сгенерированные методы — две разные сущности.
Параметры декоратора
@dataclass принимает несколько флагов. Шесть из них Junior DE должен знать в лицо.
frozen=True — неизменяемость и хэшируемость
@dataclass(frozen=True)
class Coordinate:
lat: float
lon: float
c = Coordinate(55.75, 37.62)
c.lat = 60.0
# FrozenInstanceError: cannot assign to field 'lat'
frozen=True запрещает менять поля после создания. Это
set или ключом в dict.
Для DE это важно: записи, которые мы прокидываем через ETL, лучше делать immutable. Случайная мутация в одной функции — и состояние ломается во всех других. Frozen-dataclass даёт гарантию: «эту запись никто не поменяет, можно дальше доверять».
slots=True — экономия памяти
@dataclass(slots=True)
class Event:
type: str
payload: dict
По умолчанию у каждого Python-объекта есть __dict__ — словарь для атрибутов. Это даёт гибкость («можно добавить атрибут на лету»), но стоит памяти: словарь весит сотни байт. На миллионе записей это сотни мегабайт.
slots=True__slots__ в класс автоматически. Объект становится на 40-60% меньше в памяти. Цена — нельзя добавить произвольный атрибут (event.foo = 1 упадёт).
Для DE-DTO, которых будут миллионы в pipeline, — однозначно slots=True.
kw_only=True — только именованные аргументы
@dataclass(kw_only=True)
class HttpRequest:
method: str
url: str
timeout: int = 30
retries: int = 3
Все поля должны передаваться по имени:
# OK
HttpRequest(method="GET", url="https://api.com")
# Ошибка — позиционные больше нельзя:
HttpRequest("GET", "https://api.com")
Это спасает от тысячи багов вида «перепутал порядок аргументов в конструкторе». Если у DTO больше трёх полей — обычно делают kw_only=True.
order=True — операторы сравнения
@dataclass(order=True)
class Version:
major: int
minor: int
patch: int
v1 = Version(1, 2, 3)
v2 = Version(1, 3, 0)
print(v1 < v2) # True — сравнение по кортежу (major, minor, patch)
Добавляет __lt__, __le__, __gt__, __ge__. Сравнение — лексикографическое, поле за полем в порядке объявления. Полезно для версий, временных меток, координат.
eq=False — отключить eq
Иногда __eq__ не нужен — например, объект уникален по id, как в ORM. Тогда @dataclass(eq=False) оставляет дефолтное сравнение «по адресу в памяти».
Комбинации, которые встречаются на практике
| Параметры | Когда |
|---|---|
@dataclass | Обычный mutable DTO. Базовый случай. |
@dataclass(frozen=True, slots=True) | Immutable-запись для production pipeline. |
@dataclass(kw_only=True) | Конфиг с >3 параметрами. |
@dataclass(frozen=True, slots=True, kw_only=True) | Совершенный production DTO. |
@dataclass(order=True, frozen=True) | Версии, ключи сортировки. |
Запомните последнюю комбинацию (frozen=True, slots=True, kw_only=True) — это production-default для DE-проектов.
field() — настройка отдельных полей
Иногда поле нужно настроить отдельно: default-фабрика, исключить из repr или eq, добавить метаданные. Для этого есть dataclasses.field.
from dataclasses import dataclass, field
@dataclass
class EtlJob:
name: str
args: list[str] = field(default_factory=list) # default_factory!
metrics: dict[str, int] = field(default_factory=dict)
_internal_id: int = field(default=0, repr=False) # не показывать в repr
api_key: str = field(default="", compare=False) # не учитывать в eq
Ключевые параметры field():
default_factory=list— для mutable default. Никогда не пишитеargs: list[str] = []в dataclass — это запрещено, dataclass на этом упадёт. Только черезdefault_factory.default=...— обычный default (для immutable значений:0,"",None).init=False— поле не попадает в__init__, инициализируется в__post_init__или дефолтом.repr=False— поле не показывается в__repr__(для секретов вроде токенов).compare=False— поле не учитывается в__eq__/__lt__.metadata={...}— произвольный dict для внешних инструментов (валидаторов, сериализаторов).
default_factory=list — самая частая запись. Запомните: никогда = [], всегда field(default_factory=list).
post_init — валидация и вычисляемые поля
@dataclass генерирует __init__, который просто присваивает поля. А если нужна валидация или вычисление производных полей? Есть хук __post_init__:
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Money:
amount: float
currency: str
amount_cents: int = field(init=False)
def __post_init__(self) -> None:
if self.amount < 0:
raise ValueError(f"amount must be >= 0, got {self.amount}")
if self.currency not in ("USD", "EUR", "RUB"):
raise ValueError(f"unknown currency: {self.currency}")
# обходной приём для frozen: object.__setattr__
object.__setattr__(self, "amount_cents", int(self.amount * 100))
Два важных момента:
- Валидация —
__post_init__запускается после установки полей. Если что-то не так, можно бросить ValueError. - Производные поля — поля с
init=Falseинициализируются здесь. Для frozen-dataclass нельзяself.x = ..., нужноobject.__setattr__(self, "x", ...).
Это паттерн для DataRecord с derivations: храним сырое поле, а в __post_init__ досчитываем зависимые.
Утилиты модуля dataclasses
Три функции, которые junior должен знать.
from dataclasses import dataclass, asdict, astuple, replace
@dataclass
class User:
id: int
name: str
email: str
u = User(id=1, name="Анна", email="[email protected]")
# 1. asdict — превратить в обычный dict (рекурсивно, по вложенным dataclass тоже)
print(asdict(u))
# {'id': 1, 'name': 'Анна', 'email': '[email protected]'}
# 2. astuple — превратить в кортеж
print(astuple(u))
# (1, 'Анна', '[email protected]')
# 3. replace — получить копию с изменёнными полями
u2 = replace(u, email="[email protected]")
print(u2)
# User(id=1, name='Анна', email='[email protected]')
asdict особенно полезен для сериализации в JSON или для записи в БД. replace — для immutable-объектов: вместо u.email = ... (что запрещено в frozen) пишем u2 = replace(u, email=...) — новый объект с обновлённым полем.
DE-кейс: frozen-запись с derivations
Соберём типовой DataRecord для ETL: immutable, с валидацией и вычисляемыми полями.
from dataclasses import dataclass, field
from datetime import datetime, UTC
@dataclass(frozen=True, slots=True, kw_only=True)
class Order:
"""Запись заказа после нормализации.
Frozen — потому что в pipeline нельзя случайно мутировать.
Slots — потому что таких объектов в pipeline миллионы.
Kw-only — потому что 6 полей и легко перепутать.
"""
order_id: int
user_id: int
amount_cents: int
currency: str
status: str
created_at: datetime
# вычисляемые поля
amount_dollars: float = field(init=False)
is_paid: bool = field(init=False)
age_seconds: float = field(init=False)
def __post_init__(self) -> None:
# валидация
if self.amount_cents < 0:
raise ValueError(f"amount_cents must be >= 0, got {self.amount_cents}")
if self.currency not in ("USD", "EUR", "RUB"):
raise ValueError(f"unknown currency: {self.currency}")
if self.status not in ("created", "paid", "cancelled"):
raise ValueError(f"unknown status: {self.status}")
if self.created_at.tzinfo is None:
raise ValueError("created_at must be timezone-aware")
# производные поля (через object.__setattr__ из-за frozen)
object.__setattr__(self, "amount_dollars", self.amount_cents / 100)
object.__setattr__(self, "is_paid", self.status == "paid")
age = (datetime.now(UTC) - self.created_at).total_seconds()
object.__setattr__(self, "age_seconds", age)
order = Order(
order_id=1,
user_id=42,
amount_cents=9990,
currency="USD",
status="paid",
created_at=datetime(2026, 1, 15, 12, 0, tzinfo=UTC),
)
print(order.amount_dollars) # 99.9
print(order.is_paid) # True
Этот паттерн — основа production-DTO. Запомните его — пригодится во всех следующих модулях.
dataclass vs NamedTuple vs обычный класс
Иногда видят typing.NamedTuple и думают: «может, лучше его?». Сравним.
from typing import NamedTuple
from dataclasses import dataclass
class CoordTuple(NamedTuple):
lat: float
lon: float
@dataclass(frozen=True)
class CoordDC:
lat: float
lon: float
Оба immutable, оба с типизированными полями, оба сравнимы по значениям. Разница:
| Свойство | NamedTuple | dataclass(frozen=True) |
|---|---|---|
Доступ по индексу: c[0] | да | нет |
Распаковка: lat, lon = c | да | нет |
| Сравнение с обычным кортежем | да ((1.0, 2.0) == c) | нет |
__post_init__ | нет | да |
slots=True | всегда есть | опционально |
default_factory для mutable | через __new__ костыли | прямой |
Правило: по умолчанию @dataclass. Используй NamedTuple только если нужна совместимость с обычными tuple (распаковка, сравнение с ()-литералом). В DE это редкий случай.
А ручной класс с __init__ — никогда для DTO. Этот эпизод в истории Python закончился.
Что про сериализацию в JSON
Голый dataclass не умеет в JSON напрямую — нужно сначала превратить его через asdict():
import json
print(json.dumps(asdict(order), default=str))
default=str нужен, потому что datetime не сериализуется в JSON стандартно. Для production-кода такой связки мало: нужно валидировать вход, обрабатывать множество типов, парсить обратно. Это уже работа Pydantic — в уроке 03.
Когда dataclass, когда Pydantic
Главное различие:
- dataclass — для внутренних структур: DTO между функциями, кэш, состояние. Доверяем входу.
- Pydantic — для границ системы: HTTP responses, env vars, парсинг конфигов. Не доверяем входу.
Подробное сравнение — в следующем уроке. Пока запомните: внутри своего модуля — dataclass. На границе (получаем JSON извне) — Pydantic.
Что должно остаться в голове
@dataclassавтогенерирует__init__,__repr__,__eq__из аннотаций полей. Ручной boilerplate ушёл в прошлое.- Production-default:
@dataclass(frozen=True, slots=True, kw_only=True). Immutable, экономный, без позиционных-сюрпризов. field(default_factory=list)— единственный правильный способ задать mutable default.__post_init__— валидация и derivations. Для frozen — черезobject.__setattr__.asdict/astuple/replace— три утилитные функции, которые junior использует постоянно.- dataclass vs NamedTuple — по умолчанию dataclass. NamedTuple только для tuple-совместимости.
В следующем уроке — Pydantic v2, который делает то же самое, плюс умеет валидировать данные из внешнего мира.