Dataclasses, NamedTuple та сучасні контейнери Python
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
Замість 48 рядків — 8. Логіка — та сама, поведінка — ідентична.
Параметри декоратора @dataclass
Декоратор приймає кілька булевих параметрів, що визначають, які методи генерувати і яку поведінку надати класу:
True — генерує метод __init__. Встановіть False, якщо хочете написати власний __init__ і при цьому залишити інші переваги dataclass.True — генерує __repr__, що включає всі поля. Результат має вигляд ClassName(field1=val1, field2=val2, ...).True — генерує __eq__, що порівнює всі поля попарно. Якщо eq=False — Python успадковує порівняння за ідентичністю від object.True — генерує __lt__, __le__, __gt__, __ge__ на основі порівняння кортежів з усіх полів. Дозволяє сортувати об'єкти через sorted().True — робить екземпляри незмінними: будь-яка спроба присвоїти нове значення атрибуту призведе до FrozenInstanceError. Також генерує __hash__, що дозволяє використовувати об'єкти як ключі словника чи елементи множини.True — автоматично встановлює __slots__ для класу, що прискорює доступ до атрибутів і зменшує витрати пам'яті (замість __dict__ на кожен екземпляр).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() дозволяє налаштовувати поведінку кожного поля:
None. Ніколи не передавайте сюди списки чи словники!list, dict, set. Наприклад: field(default_factory=list).False — поле виключається з автоматично згенерованого __repr__. Корисно для паролів, токенів та інших чутливих даних.False — поле виключається з порівняння (__eq__, __lt__ тощо). Наприклад, created_at може не брати участь у порівнянні двох записів.__hash__. None означає: використовувати те саме значення, що і compare. False — виключити з хешу навіть якщо compare=True.False — поле не включається в __init__ як параметр. Значення встановлюється через default або default_factory, або в __post_init__.Ось реальний приклад: клас 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: це різні об'єкти
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}")
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__')}")
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}")
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})")
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)} унікальних векторів")
Вбудовані методи 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!)")
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
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
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}")
Підсумок: порівняльна таблиця сучасних контейнерів
Основні характеристики
| Характеристика | Звичайний клас | @dataclass | NamedTuple | TypedDict |
|---|---|---|---|---|
Автогенерація __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)
NamedTuple
csv.reader).TypedDict
**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: intrecipient_id: intamount: floatcurrency: strtimestamp: NotRequired[str]
- Створіть валідований клас даних
Transactionза допомогою@dataclass(frozen=True, slots=True)з відповідними полями. Тип поляtimestampмає бутиdatetime. - Реалізуйте метод класу
from_payload(cls, payload: TransactionPayload) -> "Transaction", який:- Перевіряє, що сума переказу (
amount) більша за нуль. Якщо ні — піднімаєValueError. - Перевіряє, що
sender_idтаrecipient_idне збігаються. Якщо збігаються — піднімаєValueError. - Парсить
timestampз рядка (наприклад,"2026-06-25T10:00:00") у об'єктdatetime. Якщо ключ відсутній у словнику, використовуйте поточний час (datetime.now()). - Повертає створений незмінний об'єкт
Transaction.
- Перевіряє, що сума переказу (
- Продемонструйте роботу валідатора: обробіть один валідний словник та спробуйте обробити словники з помилками (невалідна сума, однаковий відправник/отримувач), перехопивши винятки.
Метакласи — Динамічне створення класів під капотом CPython
Глибокий розбір динамічної природи класів у Python. Використання type() для створення класів на льоту, створення та налаштування власних метакласів, життєвий цикл __new__ та __init__, а також сучасна альтернатива у вигляді __init_subclass__ (PEP 487).
GIL та модель конкурентності CPython — фундамент перед потоками і процесами
Глибокий розбір Global Interpreter Lock у CPython, різниці між concurrency та parallelism, I/O-bound та CPU-bound задачами. Benchmarks та шпаргалка вибору між threading, multiprocessing та asyncio.