Python

Dataclasses, NamedTuple та сучасні контейнери Python

Вичерпний розбір сучасних способів опису структур даних у Python — від ручного написання __init__ до @dataclass, NamedTuple, TypedDict і Enum. Порівняння продуктивності, використання пам'яті та зручності синтаксису.

Dataclasses, NamedTuple та сучасні контейнери Python

Проблема: бойлерплейт, що вбиває продуктивність

Уявіть, що ви пишете систему обробки замовлень для інтернет-магазину. Вам потрібен клас Order, що зберігає кілька полів і вміє порівнюватись, виводитись у консоль та серіалізуватись. Здавалось би, нічого складного:

# Без жодних допоміжних інструментів — «вручну»
class Order:
    def __init__(
        self,
        order_id: int,
        customer: str,
        product: str,
        quantity: int,
        price: float,
        status: str = "pending",
    ):
        self.order_id = order_id
        self.customer = customer
        self.product = product
        self.quantity = quantity
        self.price = price
        self.status = status

    def __repr__(self) -> str:
        return (
            f"Order(order_id={self.order_id!r}, customer={self.customer!r}, "
            f"product={self.product!r}, quantity={self.quantity!r}, "
            f"price={self.price!r}, status={self.status!r})"
        )

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Order):
            return NotImplemented
        return (
            self.order_id == other.order_id
            and self.customer == other.customer
            and self.product == other.product
            and self.quantity == other.quantity
            and self.price == other.price
            and self.status == other.status
        )

    def __hash__(self) -> int:
        return hash((self.order_id, self.customer, self.product,
                     self.quantity, self.price, self.status))

І це лише для шести полів! Уже 48 рядків коду — і жодної бізнес-логіки. А тепер уявіть, що потрібно додати ще три поля, або що ви хочете зробити об'єкти незмінними. Вам доведеться вручну оновити __init__, __repr__, __eq__ і __hash__. Пропустити — і з'являться баги, що роками живуть у продакшені.

Багато коду, мало сенсу

Сотні рядків __init__, __repr__, __eq__ — це шаблонний код, який не описує логіку програми, а лише структуру даних.

Легко помилитись

Забути поле у __repr__, переплутати порядок у __eq__, не оновити __hash__ після додавання поля — усе це реальні баги, що виникають у будь-якій великій команді.

Важко рефакторити

Додавання нового поля вимагає оновлення щонайменше трьох методів. У великих кодових базах — це джерело постійних помилок при злитті гілок.

Рішення: сучасні контейнери

@dataclass, NamedTuple, TypedDict — Python надає декілька інструментів, що генерують шаблонний код автоматично або повністю усувають необхідність у ньому.

Саме для вирішення цієї проблеми Python поступово отримав кілька потужних інструментів: collections.namedtuple (Python 2.6), typing.NamedTuple (Python 3.5), @dataclass (Python 3.7, PEP 557), TypedDict (Python 3.8). Сьогодні ми розберемо кожен з них детально — від механіки до практичного застосування.


Частина I: @dataclass — клас даних без бойлерплейту

Що таке @dataclass і як він працює

Декоратор @dataclass з модуля dataclasses (PEP 557, Python 3.7+) аналізує анотації типів у тілі класу і автоматично генерує методи __init__, __repr__ і __eq__. Підкреслимо: Python не змінює семантику класу — він просто дописує методи, які ви написали б самі, але не хочете.

Перепишемо Order з використанням @dataclass:

# dataclass_intro.py
from dataclasses import dataclass, field
from typing import ClassVar


@dataclass
class Order:
    order_id: int
    customer: str
    product: str
    quantity: int
    price: float
    status: str = "pending"          # поле зі значенням за замовчуванням


# Використання — абсолютно ідентичне до ручного класу
o1 = Order(1, "Іван Петренко", "Ноутбук", 1, 45_000.0)
o2 = Order(1, "Іван Петренко", "Ноутбук", 1, 45_000.0)
o3 = Order(2, "Марія Коваль",  "Клавіатура", 2, 1_500.0)

print(o1)               # __repr__ згенеровано автоматично
print(o1 == o2)         # True  ← __eq__ згенеровано автоматично
print(o1 == o3)         # False
python dataclass_intro.py
$ python dataclass_intro.py
Order(order_id=1, customer='Іван Петренко', product='Ноутбук', quantity=1, price=45000.0, status='pending')
True
False

Замість 48 рядків — 8. Логіка — та сама, поведінка — ідентична.

Параметри декоратора @dataclass

Декоратор приймає кілька булевих параметрів, що визначають, які методи генерувати і яку поведінку надати класу:

init
bool = True
Якщо True — генерує метод __init__. Встановіть False, якщо хочете написати власний __init__ і при цьому залишити інші переваги dataclass.
repr
bool = True
Якщо True — генерує __repr__, що включає всі поля. Результат має вигляд ClassName(field1=val1, field2=val2, ...).
eq
bool = True
Якщо True — генерує __eq__, що порівнює всі поля попарно. Якщо eq=False — Python успадковує порівняння за ідентичністю від object.
order
bool = False
Якщо True — генерує __lt__, __le__, __gt__, __ge__ на основі порівняння кортежів з усіх полів. Дозволяє сортувати об'єкти через sorted().
frozen
bool = False
Якщо True — робить екземпляри незмінними: будь-яка спроба присвоїти нове значення атрибуту призведе до FrozenInstanceError. Також генерує __hash__, що дозволяє використовувати об'єкти як ключі словника чи елементи множини.
slots
bool = False (Python 3.10+)
Якщо True — автоматично встановлює __slots__ для класу, що прискорює доступ до атрибутів і зменшує витрати пам'яті (замість __dict__ на кожен екземпляр).
kw_only
bool = False (Python 3.10+)
Якщо True — всі поля стають keyword-only аргументами у згенерованому __init__. Запобігає помилкам при передачі аргументів за позицією.

Порядок полів і значення за замовчуванням: важливе правило

Поля без значень за замовчуванням повинні передувати полям зі значеннями. Це обмеження Python: у будь-якому def f(a, b=1, c)c без дефолту після b з дефолтом є синтаксичною помилкою. Dataclass дотримується тих самих правил.

from dataclasses import dataclass

# ✅ Правильно: обов'язкові поля першими
@dataclass
class GoodClass:
    required_field: str
    another_required: int
    optional_field: str = "default"
    another_optional: int = 0

# ❌ Неправильно: поле без дефолту після поля з дефолтом
# @dataclass
# class BadClass:
#     optional_field: str = "default"
#     required_field: str          # TypeError: non-default argument follows default argument

Функція field(): тонке налаштування кожного поля

Проста форма field_name: type = default_value не покриває всі випадки. Функція field() дозволяє налаштовувати поведінку кожного поля:

default
Any
Значення за замовчуванням. Використовуйте для незмінних (immutable) значень: рядків, чисел, None. Ніколи не передавайте сюди списки чи словники!
default_factory
Callable[[], T]
Фабрика за замовчуванням — функція без аргументів, що повертає нове значення для кожного екземпляра. Використовуйте для змінних (mutable) значень: list, dict, set. Наприклад: field(default_factory=list).
repr
bool = True
Якщо False — поле виключається з автоматично згенерованого __repr__. Корисно для паролів, токенів та інших чутливих даних.
compare
bool = True
Якщо False — поле виключається з порівняння (__eq__, __lt__ тощо). Наприклад, created_at може не брати участь у порівнянні двох записів.
hash
bool | None = None
Управляє включенням поля у __hash__. None означає: використовувати те саме значення, що і compare. False — виключити з хешу навіть якщо compare=True.
init
bool = True
Якщо False — поле не включається в __init__ як параметр. Значення встановлюється через default або default_factory, або в __post_init__.
metadata
Mapping | None = None
Довільний незмінний словник метаданих, що асоційований з полем. Використовується сторонніми бібліотеками (наприклад, Marshmallow або FastAPI) для додаткового опису поля.

Ось реальний приклад: клас UserProfile з кількома спеціальними полями:

# field_demo.py
from dataclasses import dataclass, field
from datetime import datetime


@dataclass
class UserProfile:
    # Обов'язкові поля
    username: str
    email: str

    # Змінне поле — обов'язково через default_factory, а не []!
    # Якби написали tags: list[str] = [] — всі екземпляри ділили б ОДИН список!
    tags: list[str] = field(default_factory=list)

    # Чутливе поле — не виводиться у repr
    _password_hash: str = field(default="", repr=False)

    # Поле, що не бере участі у порівнянні (технічна метадата)
    created_at: datetime = field(
        default_factory=datetime.now,
        compare=False,    # не порівнюємо дати при перевірці рівності
        repr=True,
    )

    # Поле, що не є аргументом __init__ — заповнюється у __post_init__
    display_name: str = field(init=False, default="")

    def __post_init__(self) -> None:
        """Викликається відразу після __init__. Ідеально для обчислень і валідації."""
        # Обчислюємо display_name на основі username
        self.display_name = self.username.replace("_", " ").title()

        # Валідація email
        if "@" not in self.email:
            raise ValueError(f"Невалідний email: {self.email!r}")


# Небезпечна пастка зі списком за замовчуванням (навмисно показуємо проблему):
@dataclass
class BrokenClass:
    # Це спричинить ValueError: mutable default <class 'list'> is not allowed
    # items: list = []  # ← Python не дозволить це з dataclass!
    items: list = field(default_factory=list)  # ← правильно


# ─── Демонстрація ────────────────────────────────────────────────────────────
u1 = UserProfile("arakviel", "arakviel@example.com", tags=["python", "backend"])
u2 = UserProfile("arakviel", "arakviel@example.com", tags=["js", "frontend"])

print(u1)                     # created_at і _password_hash у repr є/немає
print(f"display_name: {u1.display_name}")  # Обчислено у __post_init__
print(f"u1 == u2: {u1 == u2}")  # True: created_at не бере участі у порівнянні!
print(f"u1 is u2: {u1 is u2}")  # False: це різні об'єкти
python field_demo.py
$ python field_demo.py
UserProfile(username='arakviel', email='arakviel@example.com', tags=['python', 'backend'], created_at=datetime.datetime(2024, 6, 22, ...), display_name='Arakviel')
display_name: Arakviel
u1 == u2: True
u1 is u2: False
u1 == u2 повертає True навіть попри те, що у них різні tags і різний created_at. Чому? Бо created_at має compare=False. Але почекайте — tags таки різні! Це ілюструє важливість: порядок полів у рівності визначається тим, у яких полів compare=True. У нашому прикладі tags має compare=True (за замовчуванням), і тому об'єкти u1 і u2 насправді НЕ рівні — завдяки різним тегам. Якщо хочете перевірити цей нюанс — спробуйте зробити tags однаковими.

frozen=True: незмінні dataclass як value objects

Встановивши frozen=True, ви отримуєте клас, екземпляри якого поводяться як незмінні значення (value objects). Python автоматично генерує __hash__, що дозволяє використовувати їх як ключі словника і елементи множини:

# frozen_dataclass.py
from dataclasses import dataclass


@dataclass(frozen=True, order=True)
class Point:
    """Незмінна двовимірна точка в просторі."""
    x: float
    y: float

    def distance_to(self, other: "Point") -> float:
        """Відстань між двома точками (формула Евкліда)."""
        return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5


p1 = Point(0.0, 0.0)
p2 = Point(3.0, 4.0)
p3 = Point(1.0, 2.0)

print(f"Відстань p1→p2: {p1.distance_to(p2)}")  # 5.0

# Можна використовувати як ключ словника (бо __hash__ згенеровано)
cache: dict[Point, float] = {p1: 0.0, p2: 5.0}
print(f"cache[p2] = {cache[p2]}")

# Можна сортувати (бо order=True генерує __lt__, __le__, ...)
points = [p2, p1, p3]
print(f"Sorted: {sorted(points)}")

# Спроба змінити поле → FrozenInstanceError
try:
    p1.x = 10.0
except Exception as e:
    print(f"{type(e).__name__}: {e}")
python frozen_dataclass.py
$ python frozen_dataclass.py
Відстань p1→p2: 5.0
cache[p2] = 5.0
Sorted: [Point(x=0.0, y=0.0), Point(x=1.0, y=2.0), Point(x=3.0, y=4.0)]
FrozenInstanceError: cannot assign to field 'x'

slots=True: оптимізація пам'яті (Python 3.10+)

Параметр slots=True автоматично встановлює __slots__ для класу. Це усуває __dict__ у кожному екземплярі, що суттєво зменшує витрати пам'яті при створенні мільйонів об'єктів:

# slots_comparison.py
from dataclasses import dataclass
import sys


@dataclass
class PointNormal:
    x: float
    y: float
    z: float


@dataclass(slots=True)
class PointSlots:
    x: float
    y: float
    z: float


normal = PointNormal(1.0, 2.0, 3.0)
slotted = PointSlots(1.0, 2.0, 3.0)

print(f"PointNormal: {sys.getsizeof(normal)} байт,  __dict__: {sys.getsizeof(normal.__dict__)} байт")
print(f"PointSlots:  {sys.getsizeof(slotted)} байт  (немає __dict__)")

# Перевіряємо: у PointSlots немає __dict__
print(f"hasattr(normal,  '__dict__'): {hasattr(normal, '__dict__')}")
print(f"hasattr(slotted, '__dict__'): {hasattr(slotted, '__dict__')}")
python slots_comparison.py
$ python slots_comparison.py
PointNormal: 48 байт, __dict__: 232 байт
PointSlots: 56 байт (немає __dict__)
hasattr(normal, '__dict__'): True
hasattr(slotted, '__dict__'): False
При мільйоні екземплярів PointNormal витрати на __dict__ складуть ~232 МБ. PointSlots зекономить ці 232 МБ повністю. Для мікросервісів, що обробляють потоки даних, або для ігрових рушіїв з тисячами ігрових об'єктів — це кардинальна різниця.

Спадкування dataclass: об'єднання полів батька і нащадка

Dataclass-и підтримують спадкування. Нащадок автоматично отримує всі поля батьківського класу перед своїми власними полями у __init__:

# dataclass_inheritance.py
from dataclasses import dataclass
from datetime import datetime


@dataclass
class BaseEntity:
    """Базова сутність із технічними полями."""
    id: int
    created_at: datetime = None

    def __post_init__(self) -> None:
        if self.created_at is None:
            object.__setattr__(self, "created_at", datetime.now())


@dataclass
class Product(BaseEntity):
    """Конкретна сутність: товар."""
    name: str = ""
    price: float = 0.0
    category: str = "uncategorized"


# __init__ нащадка: Product(id, created_at, name, price, category)
p = Product(id=42, name="Python книга", price=499.0)
print(p)
print(f"id: {p.id}, created_at: {p.created_at}")
При спадкуванні dataclass-ів існує відома пастка: якщо у батьківському класі є поля з дефолтами, а у нащадку — поля без дефолтів, Python підніме TypeError. Причина: у згенерованому __init__ поля з дефолтами передують полям без дефолтів (з нащадка), що порушує правило Python. Рішення: або всі поля нащадка теж мають дефолти, або використовуйте field(default=...) і kw_only=True (Python 3.10+).

Частина II: Корисні функції модуля dataclasses

Окрім декоратора @dataclass і функції field(), модуль dataclasses надає кілька корисних утиліт для роботи з dataclass-об'єктами:

fields(): інтроспекція полів

from dataclasses import dataclass, field, fields, Field
from typing import get_type_hints


@dataclass
class Config:
    host: str = "localhost"
    port: int = 8080
    debug: bool = False
    secret_key: str = field(default="changeme", repr=False)


cfg = Config()

# Отримуємо кортеж об'єктів Field
for f in fields(cfg):
    print(f"  {f.name}: {f.type} = {getattr(cfg, f.name)!r}  (repr={f.repr})")
fields() demo
$ python -c "..."
host: str = 'localhost' (repr=True)
port: int = 8080 (repr=True)
debug: bool = False (repr=True)
secret_key: str = 'changeme' (repr=False)

asdict() і astuple(): серіалізація

from dataclasses import dataclass, asdict, astuple


@dataclass
class Address:
    city: str
    country: str


@dataclass
class Person:
    name: str
    age: int
    address: Address


p = Person("Іван", 30, Address("Київ", "Україна"))

# asdict() рекурсивно перетворює вкладені dataclass-и
d = asdict(p)
print(d)
# {'name': 'Іван', 'age': 30, 'address': {'city': 'Київ', 'country': 'Україна'}}

# Ідеально для серіалізації у JSON:
import json
print(json.dumps(d, ensure_ascii=False))

# astuple() — рекурсивно у вкладений кортеж
t = astuple(p)
print(t)  # ('Іван', 30, ('Київ', 'Україна'))

replace(): копія з зміненими полями (незамінно для frozen)

from dataclasses import dataclass, replace


@dataclass(frozen=True)
class Config:
    host: str
    port: int
    debug: bool = False


prod_cfg = Config("prod.example.com", 443)

# Не можемо змінити prod_cfg (frozen), але можемо отримати нову копію:
dev_cfg = replace(prod_cfg, host="localhost", port=8080, debug=True)

print(prod_cfg)  # Config(host='prod.example.com', port=443, debug=False)
print(dev_cfg)   # Config(host='localhost', port=8080, debug=True)
print(prod_cfg is dev_cfg)  # False — це різні об'єкти
replace() — це аналог методу .evolve() у бібліотеці attrs і основний патерн роботи з незмінними структурами даних. Замість мутації — створюємо нову версію об'єкта зі зміненими полями. Це знижує кількість багів і спрощує налагодження.

Частина III: typing.NamedTuple — іменований кортеж із типами

Чим NamedTuple відрізняється від dataclass

NamedTuple — це не просто «dataclass для незмінних даних». Це принципово інша абстракція: іменований кортеж з анотаціями типів. Його екземпляри є справжніми кортежами (tuples), а не звичайними об'єктами:

  • Вони підтримують розпакування: x, y, z = point
  • Вони підтримують індексацію: point[0]
  • Вони є незмінними за природою (не через frozen, а через природу кортежу)
  • Вони мають значно менший розмір у пам'яті — структура кортежу, а не dict-based об'єкт
  • Вони автоматично hashable

collections.namedtuple vs typing.NamedTuple

Python має два способи створити іменований кортеж. Старий (collections.namedtuple) і сучасний (typing.NamedTuple):

# namedtuple_comparison.py
from collections import namedtuple
from typing import NamedTuple


# === Старий стиль (Python 2.6+) ===
# Визначення через рядок або список рядків
OldPoint = namedtuple("OldPoint", ["x", "y", "z"])
OldPoint2 = namedtuple("OldPoint2", "x y z")  # альтернатива

p_old = OldPoint(1.0, 2.0, 3.0)
print(p_old)                          # OldPoint(x=1.0, y=2.0, z=3.0)
print(p_old.x, p_old[0])             # 1.0  1.0  — ім'я і індекс
x, y, z = p_old                      # розпакування як кортеж
print(isinstance(p_old, tuple))      # True ← це справжній tuple!


# === Сучасний стиль (Python 3.5+) ===
# Визначення через синтаксис класу з анотаціями типів
class Vector3(NamedTuple):
    """Тривимірний вектор — незмінний, типізований іменований кортеж."""
    x: float
    y: float
    z: float = 0.0  # поле зі значенням за замовчуванням

    def magnitude(self) -> float:
        """Довжина вектора."""
        return (self.x ** 2 + self.y ** 2 + self.z ** 2) ** 0.5

    def dot(self, other: "Vector3") -> float:
        """Скалярний добуток."""
        return self.x * other.x + self.y * other.y + self.z * other.z


v1 = Vector3(1.0, 0.0, 0.0)
v2 = Vector3(0.0, 1.0, 0.0)
v3 = Vector3(3.0, 4.0)          # z = 0.0 за замовчуванням

print(v1)                        # Vector3(x=1.0, y=0.0, z=0.0)
print(f"|v3| = {v3.magnitude()}") # 5.0
print(f"v1·v2 = {v1.dot(v2)}")   # 0.0

# Повна сумісність з tuple API
print(v1[0], v1[1])              # 1.0  0.0  (індексація)
a, b, c = v3                     # розпакування
print(f"a={a}, b={b}, c={c}")    # a=3.0, b=4.0, c=0.0

# Можна використовувати як ключ словника (hashable)
seen: set[Vector3] = {v1, v2, v3}
print(f"Множина: {len(seen)} унікальних векторів")
python namedtuple_comparison.py
$ python namedtuple_comparison.py
OldPoint(x=1.0, y=2.0, z=3.0)
1.0 1.0
True
Vector3(x=1.0, y=0.0, z=0.0)
|v3| = 5.0
v1·v2 = 0.0
1.0 0.0
a=3.0, b=4.0, c=0.0
Множина: 3 унікальних векторів

Вбудовані методи NamedTuple

Кожен іменований кортеж автоматично отримує набір корисних методів:

from typing import NamedTuple


class HTTPResponse(NamedTuple):
    status_code: int
    body: str
    headers: dict = {}


resp = HTTPResponse(200, '{"ok": true}', {"Content-Type": "application/json"})

# _asdict() → OrderedDict (сумісно зі звичайним dict у Python 3.7+)
d = resp._asdict()
print(d)  # {'status_code': 200, 'body': '{"ok": true}', 'headers': {...}}

# _replace() → копія з зміненими полями (аналог dataclasses.replace)
redirected = resp._replace(status_code=301, body="")
print(redirected)

# _fields → кортеж імен полів
print(HTTPResponse._fields)  # ('status_code', 'body', 'headers')

# _field_defaults → словник значень за замовчуванням
print(HTTPResponse._field_defaults)  # {'headers': {}}

Пам'ять: NamedTuple vs dataclass

Ось де NamedTuple виграє у dataclass за рахунок базової структури кортежу:

# memory_comparison.py
import sys
from dataclasses import dataclass
from typing import NamedTuple


@dataclass
class PointDC:
    x: float
    y: float
    z: float


class PointNT(NamedTuple):
    x: float
    y: float
    z: float


dc = PointDC(1.0, 2.0, 3.0)
nt = PointNT(1.0, 2.0, 3.0)

print(f"dataclass:   {sys.getsizeof(dc)} + {sys.getsizeof(dc.__dict__)} (dict) = ~{sys.getsizeof(dc) + sys.getsizeof(dc.__dict__)} байт")
print(f"NamedTuple:  {sys.getsizeof(nt)} байт  (без dict!)")
python memory_comparison.py
$ python memory_comparison.py
dataclass: 48 + 232 (dict) = ~280 байт
NamedTuple: 88 байт (без dict!)
NamedTuple з трьома полями займає лише 88 байт проти ~280 байт для звичайного dataclass. Якщо ваш код оперує мільйонами невеликих незмінних записів (парсинг CSV, координати, події) — NamedTuple може зменшити використання пам'яті у 3 рази.

Коли вибирати NamedTuple замість @dataclass

ПитанняNamedTuple@dataclass
Потрібна незмінність?✅ Вбудованаfrozen=True
Потрібне розпакування a, b, c = obj?✅ Так❌ Ні
Потрібна індексація obj[0]?✅ Так❌ Ні
Потрібна сумісність з tuple-API?✅ Так❌ Ні
Потрібна мутабельність?❌ Ні✅ Так
Потрібні методи зі складною логікою?Обмежено✅ Так
Мінімальні витрати пам'яті?✅ Такslots=True
Потрібні значення за замовчуванням (складні)?Обмеженоfield()

Частина IV: TypedDict — типізований словник

Що таке TypedDict і навіщо він потрібен

TypedDict (модуль typing, Python 3.8+) — це спосіб описати структуру словника зі статичними типами. Він не є класом у звичайному розумінні — він не генерує жодних методів і не додає перевірок у runtime. Його єдина мета — надати статичному аналізатору (mypy, Pyright) інформацію про те, які ключі і якого типу мають бути у даному словнику.

TypedDict корисний, коли ви:

  • Працюєте з JSON-даними, що приходять з API
  • Не хочете конвертувати їх у клас, але хочете типову безпеку
  • Взаємодієте з бібліотеками, що очікують dict (наприклад, Flask, FastAPI, SQLAlchemy)
# typed_dict_demo.py
from typing import TypedDict, Required, NotRequired


# Базовий спосіб оголошення TypedDict
class UserData(TypedDict):
    id: int
    username: str
    email: str
    age: int


# TypedDict з необов'язковими полями (Python 3.11+ синтаксис)
class UserCreateRequest(TypedDict, total=False):
    """total=False робить всі поля необов'язковими за замовчуванням."""
    username: str
    email: str
    age: int
    bio: str


# Змішаний підхід через Required/NotRequired (Python 3.11+)
class ArticleData(TypedDict):
    title: str                         # обов'язкове
    content: str                       # обов'язкове
    author_id: int                     # обов'язкове
    tags: NotRequired[list[str]]       # необов'язкове
    published: NotRequired[bool]       # необов'язкове


def display_user(user: UserData) -> str:
    """Функція, що приймає типізований словник."""
    return f"[{user['id']}] {user['username']} <{user['email']}>"


# Використання — це звичайний dict під капотом!
user: UserData = {
    "id": 1,
    "username": "arakviel",
    "email": "arakviel@example.com",
    "age": 30,
}

print(display_user(user))
print(type(user))  # <class 'dict'> ← не новий тип, а просто dict з аннотацією

# TypedDict можна використовувати для розпакування (**kwargs)
def create_user(**kwargs: UserData) -> None:
    ...
TypedDict не виконує перевірок у runtime. Якщо ви передасте словник з неправильними ключами або типами — Python не підніме жодного винятку. Перевірки виконує лише статичний аналізатор (mypy, Pyright). Для runtime-валідації використовуйте Pydantic або @dataclass з __post_init__.

TypedDict для моделювання JSON-відповідей API

Типовий use case — моделювання відповідей зовнішнього REST API:

# api_types.py
from typing import TypedDict, NotRequired


class GitHubUser(TypedDict):
    """Структура відповіді GitHub API для /users/{username}"""
    login: str
    id: int
    avatar_url: str
    name: NotRequired[str | None]
    company: NotRequired[str | None]
    blog: NotRequired[str]
    location: NotRequired[str | None]
    email: NotRequired[str | None]
    public_repos: int
    followers: int
    following: int


class GitHubRepo(TypedDict):
    id: int
    name: str
    full_name: str
    private: bool
    description: NotRequired[str | None]
    language: NotRequired[str | None]
    stargazers_count: int
    forks_count: int


def process_user(data: GitHubUser) -> str:
    name = data.get("name") or data["login"]
    repos = data["public_repos"]
    followers = data["followers"]
    return f"{name}: {repos} repos, {followers} followers"


# Парсимо JSON напряму у TypedDict — без конвертації!
import json
raw_json = '{"login": "arakviel", "id": 12345, "avatar_url": "...", "public_repos": 42, "followers": 150, "following": 30}'
user_data: GitHubUser = json.loads(raw_json)
print(process_user(user_data))

Частина V: enum.Enum — перелічувані константи

Проблема магічних рядків і чисел

У більшості коду можна знайти конструкції на кшталт:

# Антипатерн: магічні рядки
order.status = "pending"
if order.status == "pendingg":  # Друкарська помилка — Python мовчить!
    send_notification()

# Антипатерн: магічні числа
ROLE_ADMIN = 1
ROLE_EDITOR = 2
ROLE_VIEWER = 3

if user.role == 4:  # 4 не існує — Python мовчить!
    ...

Обидва підходи мають одну й ту саму проблему: Python не може перевірити валідність значення. Enum вирішує це, надаючи закриту множину іменованих констант з типовою перевіркою.

Базовий Enum

# enum_basics.py
from enum import Enum, auto, IntEnum, StrEnum


class OrderStatus(Enum):
    """Статус замовлення — перелічений тип."""
    PENDING   = "pending"
    CONFIRMED = "confirmed"
    SHIPPED   = "shipped"
    DELIVERED = "delivered"
    CANCELLED = "cancelled"


# Доступ до члена переліку
status = OrderStatus.PENDING

print(status)           # OrderStatus.PENDING
print(status.name)      # "PENDING"   ← ім'я у коді
print(status.value)     # "pending"   ← зберігається значення
print(repr(status))     # <OrderStatus.PENDING: 'pending'>

# Перетворення з рядка (lookup за значенням)
loaded = OrderStatus("shipped")
print(loaded)           # OrderStatus.SHIPPED
print(loaded is OrderStatus.SHIPPED)  # True ← синглтон!

# Перебір усіх членів
for s in OrderStatus:
    print(f"  {s.name}: {s.value!r}")

# Порівняння — суворе (за ідентичністю, не за значенням)
print(OrderStatus.PENDING == "pending")  # False ← не рядок!
print(OrderStatus.PENDING == OrderStatus.PENDING)  # True
python enum_basics.py
$ python enum_basics.py
OrderStatus.PENDING
PENDING
pending
<OrderStatus.PENDING: 'pending'>
OrderStatus.SHIPPED
True
PENDING: 'pending'
CONFIRMED: 'confirmed'
SHIPPED: 'shipped'
DELIVERED: 'delivered'
CANCELLED: 'cancelled'
False
True

auto(): автоматичні значення

auto() генерує значення автоматично — зазвичай цілі числа починаючи від 1. Ідеально, коли конкретне значення не важливе, а важлива лише ідентичність:

from enum import Enum, auto


class Direction(Enum):
    NORTH = auto()   # 1
    SOUTH = auto()   # 2
    EAST  = auto()   # 3
    WEST  = auto()   # 4


class Permission(Enum):
    READ    = auto()
    WRITE   = auto()
    EXECUTE = auto()
    DELETE  = auto()


print(Direction.NORTH.value)    # 1
print(Permission.DELETE.value)  # 4

IntEnum і StrEnum: сумісність з вбудованими типами

Звичайний Enum є суворим — він не рівний своєму числовому чи рядковому значенню. Але іноді потрібна сумісність, наприклад, коли enum-значення передається в SQL-запит або в JSON як ціле число чи рядок:

# int_str_enum.py
from enum import IntEnum, StrEnum, auto


class HTTPStatus(IntEnum):
    """HTTP-коди — сумісні з int, можна порівнювати з числами."""
    OK                    = 200
    CREATED               = 201
    NO_CONTENT            = 204
    BAD_REQUEST           = 400
    UNAUTHORIZED          = 401
    NOT_FOUND             = 404
    INTERNAL_SERVER_ERROR = 500


class Color(StrEnum):
    """Кольори у CSS-нотації — сумісні з str."""
    RED   = "red"
    GREEN = "green"
    BLUE  = "blue"


# IntEnum — сумісний з int
status = HTTPStatus.OK
print(status == 200)         # True  ← IntEnum дозволяє порівняння з int
print(status + 1)            # 201   ← арифметика працює!
print(isinstance(status, int))  # True

# Корисно при перевірці HTTP-відповідей:
def is_success(code: int) -> bool:
    return 200 <= code < 300

print(is_success(HTTPStatus.CREATED))  # True ← без .value!

# StrEnum — сумісний з str
color = Color.RED
print(color == "red")         # True
print(color.upper())          # "RED"  ← str-методи працюють
print(f"color: {color}")      # "color: red"  ← форматування як рядок

Розширений Enum: методи, властивості та __new__

Enum може мати методи і властивості. Це перетворює його на повноцінну декларацію бізнес-об'єктів:

# advanced_enum.py
from enum import Enum
from decimal import Decimal


class Currency(Enum):
    """Валюта з символом і кодом ISO 4217."""
    USD = ("$",   "USD", Decimal("1.0"))
    EUR = ("€",   "EUR", Decimal("0.93"))
    UAH = ("₴",   "UAH", Decimal("41.5"))
    GBP = ("£",   "GBP", Decimal("0.79"))

    def __new__(cls, symbol: str, code: str, rate: Decimal):
        obj = object.__new__(cls)
        obj._value_ = code       # value — це ISO-код
        obj.symbol = symbol
        obj.code = code
        obj.rate_to_usd = rate
        return obj

    def convert_to(self, amount: Decimal, target: "Currency") -> Decimal:
        """Конвертація суми в іншу валюту через USD."""
        in_usd = amount / self.rate_to_usd
        return (in_usd * target.rate_to_usd).quantize(Decimal("0.01"))

    def format_amount(self, amount: Decimal) -> str:
        return f"{self.symbol}{amount:,.2f}"


# Використання
price = Decimal("1000")
print(Currency.USD.format_amount(price))        # $1,000.00
print(Currency.UAH.format_amount(price))        # ₴1,000.00

converted = Currency.USD.convert_to(price, Currency.UAH)
print(f"$1000 = {Currency.UAH.format_amount(converted)}")  # ₴41,500.00

# Lookup за ISO-кодом
eur = Currency("EUR")
print(eur)                  # Currency.EUR
print(eur.symbol)           # €
print(eur.rate_to_usd)      # 0.93
python advanced_enum.py
$ python advanced_enum.py
$1,000.00
₴1,000.00
$1000 = ₴41,500.00
Currency.EUR
0.93

Enum у поєднанні з @dataclass

Найпотужніший патерн: Enum для стану, @dataclass для даних:

# enum_with_dataclass.py
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum, auto


class OrderStatus(Enum):
    PENDING   = auto()
    CONFIRMED = auto()
    SHIPPED   = auto()
    DELIVERED = auto()
    CANCELLED = auto()

    def can_transition_to(self, new_status: "OrderStatus") -> bool:
        """Матриця дозволених переходів між станами."""
        transitions = {
            OrderStatus.PENDING:   {OrderStatus.CONFIRMED, OrderStatus.CANCELLED},
            OrderStatus.CONFIRMED: {OrderStatus.SHIPPED, OrderStatus.CANCELLED},
            OrderStatus.SHIPPED:   {OrderStatus.DELIVERED},
            OrderStatus.DELIVERED: set(),  # фінальний стан
            OrderStatus.CANCELLED: set(),  # фінальний стан
        }
        return new_status in transitions[self]


class PaymentMethod(Enum):
    CARD        = "card"
    CASH        = "cash"
    CRYPTO      = "crypto"
    BANK_TRANSFER = "bank_transfer"


@dataclass
class OrderItem:
    product_id: int
    name: str
    quantity: int
    unit_price: float

    @property
    def subtotal(self) -> float:
        return self.quantity * self.unit_price


@dataclass
class Order:
    order_id: int
    customer_name: str
    payment_method: PaymentMethod
    items: list[OrderItem] = field(default_factory=list)
    status: OrderStatus = OrderStatus.PENDING
    created_at: datetime = field(default_factory=datetime.now)
    history: list[tuple[OrderStatus, datetime]] = field(
        default_factory=list, repr=False
    )

    def total(self) -> float:
        return sum(item.subtotal for item in self.items)

    def transition_to(self, new_status: OrderStatus) -> None:
        """Перехід до нового стану з перевіркою дозволеності."""
        if not self.status.can_transition_to(new_status):
            raise ValueError(
                f"Неможливий перехід: {self.status.name}{new_status.name}"
            )
        self.history.append((self.status, datetime.now()))
        self.status = new_status
        print(f"  ✓ Статус змінено: {new_status.name}")


# ─── Демонстрація ────────────────────────────────────────────────────────────
order = Order(
    order_id=1001,
    customer_name="Іван Петренко",
    payment_method=PaymentMethod.CARD,
    items=[
        OrderItem(1, "Python книга",  1, 499.0),
        OrderItem(2, "Клавіатура",    1, 1_800.0),
        OrderItem(3, "USB-хаб",       2, 350.0),
    ]
)

print(f"Замовлення #{order.order_id}: {order.customer_name}")
print(f"Сума: {order.total():.2f} грн")
print(f"Оплата: {order.payment_method.value}")
print(f"Статус: {order.status.name}\n")

order.transition_to(OrderStatus.CONFIRMED)
order.transition_to(OrderStatus.SHIPPED)

try:
    order.transition_to(OrderStatus.PENDING)  # ← неможливий перехід
except ValueError as e:
    print(f"\nValueError: {e}")

order.transition_to(OrderStatus.DELIVERED)
print(f"\nФінальний статус: {order.status.name}")
python enum_with_dataclass.py
$ python enum_with_dataclass.py
Замовлення #1001: Іван Петренко
Сума: 2,999.00 грн
Оплата: card
Статус: PENDING
✓ Статус змінено: CONFIRMED
✓ Статус змінено: SHIPPED
ValueError: Неможливий перехід: SHIPPED → PENDING
✓ Статус змінено: DELIVERED
Фінальний статус: DELIVERED

Підсумок: порівняльна таблиця сучасних контейнерів

Основні характеристики

ХарактеристикаЗвичайний клас@dataclassNamedTupleTypedDict
Автогенерація __init__
Автогенерація __repr__
Автогенерація __eq__✅ (через tuple)
Мутабельність✅ (за замовч.)✅ (dict)
НезмінністьВручнуfrozen=True✅ Вбудована
HashableВручнуfrozen=True✅ Вбудована
Розпакування a, b = obj
Індексація obj[0]✅ (obj["key"])
Runtime-перевірки типівЗ __post_init__
СпадкуванняОбмежено
Методи бізнес-логіки✅ (обмежено)

Продуктивність і пам'ять

ПоказникЗвичайний клас@dataclass@dataclass(slots=True)NamedTuple
Розмір екземпляра~280 байт~280 байт~56 байт~88 байт
Швидкість доступу до атр.БазоваБазоваШвидше (~20%)Аналог кортежу
Швидкість створенняБазоваНезначно повільнішеАналог звичайномуШвидше за dict-based

Рекомендації: що і коли обирати

@dataclass

Вибирайте за замовчуванням для більшості структур даних з бізнес-логікою. Мутабельний, підтримує __post_init__, спадкування, field(). Ідеально для сутностей (Entity), DTO, конфігурацій.

@dataclass(frozen=True)

Коли потрібна незмінність і hashability: value objects (координати, гроші), ключі словників, конфігурація, що не повинна мінятись після ініціалізації.

NamedTuple

Коли потрібна сумісність з tuple API (розпакування, індексація), максимальна економія пам'яті при незмінних даних, або робота з API, що очікує кортежі (наприклад, повернення з функцій, csv.reader).

TypedDict

Коли ви не хочете конвертувати JSON/dict в об'єкт, але хочете статичну перевірку типів. Ідеально для моделювання відповідей API, конфігурацій у форматі словника, аргументів **kwargs.

Enum

Будь-яка закрита множина констант: статуси, ролі, коди, напрямки, категорії. Замінює магічні рядки і числа, забезпечує безпечне порівняння і самодокументованість коду.

Ключові принципи

ПринципДеталь
default_factoryДля змінних дефолтів (list, dict) — завжди field(default_factory=...), ніколи = []
__post_init__Для обчислень залежних полів і runtime-валідації після __init__
frozen=True + order=TrueКомбінація для value objects, що потребують сортування
slots=TrueПри масовому створенні об'єктів — суттєво зменшує витрати пам'яті
Enum vs рядкиЗавжди використовуйте Enum замість «магічних» рядкових або числових констант
NamedTuple для tuple-сумісностіПри роботі з функціями, що повертають або приймають кортежі
TypedDict без runtimeПам'ятайте: TypedDict не перевіряє типи у runtime — тільки статичний аналізатор

Практичні завдання

Рівень 1 — Базовий

Опишіть книгу у бібліотеці за допомогою @dataclass:

  • Назва класу: Book.
  • Поля: title: str, author: str, pages: int, price: float.
  • Додайте поле genres: list[str], яке за замовчуванням ініціалізується порожнім списком (використовуйте default_factory).
  • Додайте поле isbn: str, яке за замовчуванням має порожній рядок і не повинно виводитися у консоль при друці об'єкта (використовуйте repr=False).
  • Реалізуйте property price_per_page, яке повертає вартість однієї сторінки книги.
  • Створіть екземпляр класу та перевірте автоматичну генерацію методів __repr__ і роботу property.

Рівень 2 — Середній

Реалізуйте систему перевірки прав доступу користувачів у вебзастосунку:

  • Створіть UserRole(Enum) зі значеннями: ADMIN, EDITOR, VIEWER.
  • Створіть Permission(Enum) зі значеннями: CREATE, READ, UPDATE, DELETE.
  • Додайте до UserRole властивість permissions або метод, який повертає множину (set) дозволених для цієї ролі прав:
    • ADMIN → має всі права.
    • EDITOR → має CREATE, READ, UPDATE.
    • VIEWER → має лише READ.
  • Реалізуйте User як NamedTuple (або @dataclass(frozen=True)) з полями: id: int, username: str, role: UserRole.
  • Реалізуйте функцію has_permission(user: User, permission: Permission) -> bool, яка перевіряє, чи має користувач зазначене право відповідно до своєї ролі.
  • Протестуйте рішення: створіть користувача з роллю VIEWER та переконайтеся, що він не має права DELETE, а користувач з роллю ADMIN має всі права.

Рівень 3 — Advanced

Реалізуйте парсер та валідатор вхідних JSON-транзакцій для платіжного шлюзу:

  • Оголосіть TransactionPayload(TypedDict) для опису вхідних сирих даних:
    • sender_id: int
    • recipient_id: int
    • amount: float
    • currency: str
    • timestamp: NotRequired[str]
  • Створіть валідований клас даних Transaction за допомогою @dataclass(frozen=True, slots=True) з відповідними полями. Тип поля timestamp має бути datetime.
  • Реалізуйте метод класу from_payload(cls, payload: TransactionPayload) -> "Transaction", який:
    1. Перевіряє, що сума переказу (amount) більша за нуль. Якщо ні — піднімає ValueError.
    2. Перевіряє, що sender_id та recipient_id не збігаються. Якщо збігаються — піднімає ValueError.
    3. Парсить timestamp з рядка (наприклад, "2026-06-25T10:00:00") у об'єкт datetime. Якщо ключ відсутній у словнику, використовуйте поточний час (datetime.now()).
    4. Повертає створений незмінний об'єкт Transaction.
  • Продемонструйте роботу валідатора: обробіть один валідний словник та спробуйте обробити словники з помилками (невалідна сума, однаковий відправник/отримувач), перехопивши винятки.
Copyright © 2026