Learning Platform
Урок 06.02 · 22 мин
Начальный
dataclassDTOfrozenslotsNamedTuple
dataclass и __slots__: память и устройство на уровне CPython Schema evolution: read vs write, registry

Зачем нужен ещё один способ описать данные

В прошлом уроке мы научились типизировать словари через TypedDict. Это работает, но у словарей есть две слабости:

  1. Доступ к полю — через [] и магическую строку. user["email"] вместо user.email. IDE подсказывает ключи, но это не то же самое, что атрибут объекта. Опечатка user["emial"] поймается только в runtime.
  2. Нет вычисляемых полей и методов. 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__ (сравнивает все поля). Полей нет — нечего генерировать.

Поэтому правило: для

DTO
, конфигов, записей в Python почти всегда выбирают @dataclass. Двадцати строк никто не пишет — это атавизм из учебников 2015 года.

Анатомия dataclass

Декоратор читает аннотации полей и генерирует методы. Поля и сгенерированные методы — две разные сущности.

@dataclassдекоратор
id: intполе-аннотация
name: strполе-аннотация
amount: floatполе-аннотация
created_at: strполе-аннотация
генерацияна этапе class body
__init__из полей в порядке
__repr__format всех полей
__eq__кортежное сравнение
(__hash__)если frozen=True

Параметры декоратора

@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 запрещает менять поля после создания. Это

immutable
-объект. Бонус: frozen-dataclass автоматически становится hashable — можно класть в set или ключом в dict.

Для DE это важно: записи, которые мы прокидываем через ETL, лучше делать immutable. Случайная мутация в одной функции — и состояние ломается во всех других. Frozen-dataclass даёт гарантию: «эту запись никто не поменяет, можно дальше доверять».

slots=True — экономия памяти

@dataclass(slots=True)
class Event:
    type: str
    payload: dict

По умолчанию у каждого Python-объекта есть __dict__ — словарь для атрибутов. Это даёт гибкость («можно добавить атрибут на лету»), но стоит памяти: словарь весит сотни байт. На миллионе записей это сотни мегабайт.

slots=True
(доступен с Python 3.10) добавляет __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))

Два важных момента:

  1. Валидация__post_init__ запускается после установки полей. Если что-то не так, можно бросить ValueError.
  2. Производные поля — поля с 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, оба с типизированными полями, оба сравнимы по значениям. Разница:

СвойствоNamedTupledataclass(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.

Что должно остаться в голове

  1. @dataclass автогенерирует __init__, __repr__, __eq__ из аннотаций полей. Ручной boilerplate ушёл в прошлое.
  2. Production-default: @dataclass(frozen=True, slots=True, kw_only=True). Immutable, экономный, без позиционных-сюрпризов.
  3. field(default_factory=list) — единственный правильный способ задать mutable default.
  4. __post_init__ — валидация и derivations. Для frozen — через object.__setattr__.
  5. asdict / astuple / replace — три утилитные функции, которые junior использует постоянно.
  6. dataclass vs NamedTuple — по умолчанию dataclass. NamedTuple только для tuple-совместимости.

В следующем уроке — Pydantic v2, который делает то же самое, плюс умеет валидировать данные из внешнего мира.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4