Практика · Dataclass і сучасний стиль¶
Лекція: lecture.html · Тест: quiz.html · ДЗ: homework.md
Наскрізний приклад той самий, що в лекції, — картка продажу з пʼятьма полями. Тут ми руками зробимо все, про що йшлося:
- напишемо клас двічі — вручну й декоратором — і доведемо
assert-ами, що вони поводяться однаково; - подивимось у
__dataclass_fields__, щоб побачити, звідки декоратор бере поля; - навмисно зламаємо порядок полів і напишемо
теги: list = []— щоб побачити справжні тексти помилок; - полагодимо це через
field(default_factory=list)і переконаємось, що в кожного продажу свій список; - поруч відтворимо ту саму пастку у звичайній функції з теми 15 — і побачимо, що там вона мовчить;
- додамо
__post_init__із перевіркою й обчислюваним полем; - заморозимо клас і доведемо, що
frozen=Trueне рятує вкладений список; - зміряємо памʼять зі
slots=Trueі без нього; - порівняємо dataclass із кортежем,
NamedTupleі звичайним класом.
Зошит виконується наскрізь. Клітинки, які мають впасти, позначені окремо — їхній traceback і є результатом.
1 · Клас вручну: пʼять полів і три методи¶
Почнемо з того, від чого відштовхується вся тема. Щоб картка продажу створювалась, друкувалась по-людськи й порівнювалась за значенням, доводиться написати три методи, у кожному з яких перелічені всі пʼять полів.
class ПродажВручну:
"""Те саме, що робить @dataclass, але написане руками."""
def __init__(self, товар, кількість,
ціна, продавець, теги):
self.товар = товар
self.кількість = кількість
self.ціна = ціна
self.продавець = продавець
self.теги = теги
def __repr__(self):
return (f"ПродажВручну(товар={self.товар!r}, "
f"кількість={self.кількість!r}, "
f"ціна={self.ціна!r}, "
f"продавець={self.продавець!r}, "
f"теги={self.теги!r})")
def __eq__(self, other):
if not isinstance(other, ПродажВручну):
return NotImplemented
return (self.товар == other.товар
and self.кількість == other.кількість
and self.ціна == other.ціна
and self.продавець == other.продавець
and self.теги == other.теги)
перший = ПродажВручну("кава", 2, 85.0, "Олена", ["акція"])
другий = ПродажВручну("кава", 2, 85.0, "Олена", ["акція"])
print("repr: ", перший)
print("рівні:", перший == другий)
print("той самий обʼєкт у памʼяті:", перший is другий)
repr: ПродажВручну(товар='кава', кількість=2, ціна=85.0, продавець='Олена', теги=['акція']) рівні: True той самий обʼєкт у памʼяті: False
Порахуймо, скільки разів у цьому класі довелося набрати імена полів руками. Це не цікавинка, а міра ризику: кожне таке місце — шанс на одруківку, яка не впаде, а тихо зіпсує порівняння.
# Рахуємо не «на око», а за структурою коду.
# Кожне поле згадується: 1 раз у сигнатурі __init__, 2 рази в присвоєнні
# (self.поле = поле), 2 рази в __repr__ (імʼя + значення) і 2 рази в __eq__.
згадок_на_поле = 1 + 2 + 2 + 2
поля = ["товар", "кількість", "ціна", "продавець", "теги"]
рядків_вручну = 22 # непорожніх рядків у класі вище, без рядка-документації
згадок_вручну = len(поля) * згадок_на_поле
print("полів у класі: ", len(поля))
print("рядків коду (порожні не рахуємо):", рядків_вручну)
print("разів набрано імена полів: ", згадок_вручну)
полів у класі: 5 рядків коду (порожні не рахуємо): 22 разів набрано імена полів: 35
2 · Те саме через @dataclass¶
Тепер той самий клас декоратором. Двокрапка після імені поля — це анотація: Python нічого за нею не перевіряє, але саме за нею декоратор дізнається, які поля має клас і в якому вони порядку. Повна історія анотацій — у темі 29.
from dataclasses import dataclass, field, fields
@dataclass
class Продаж:
товар: str
кількість: int
ціна: float
продавець: str
теги: list
третій = Продаж("кава", 2, 85.0, "Олена", ["акція"])
четвертий = Продаж("кава", 2, 85.0, "Олена", ["акція"])
print("repr: ", третій)
print("рівні:", третій == четвертий)
рядків_dc = 8 # import, порожній рядок, @dataclass, class і пʼять полів
згадок_dc = len(поля) # імʼя поля пишеться рівно один раз — в анотації
print("рядків коду: ", рядків_dc)
print("разів набрано імена полів: ", згадок_dc)
repr: Продаж(товар='кава', кількість=2, ціна=85.0, продавець='Олена', теги=['акція']) рівні: True рядків коду: 8 разів набрано імена полів: 5
Головна перевірка цього розділу: обидві версії поводяться однаково. Порівняємо
не код, а поведінку — рівність за значенням і текст repr із точністю до імені класу.
# рівність за значенням працює в обох версіях однаково
assert (перший == другий) == (третій == четвертий) == True, "рівність розійшлася!"
# і в обох випадках різні дані дають нерівні обʼєкти
інший_вручну = ПродажВручну("чай", 2, 85.0, "Олена", ["акція"])
інший_dc = Продаж("чай", 2, 85.0, "Олена", ["акція"])
assert (перший == інший_вручну) == (третій == інший_dc) == False, "нерівність розійшлася!"
# repr відрізняється лише назвою класу — прибираємо її й порівнюємо решту
хвіст_вручну = repr(перший).split("(", 1)[1]
хвіст_dc = repr(третій).split("(", 1)[1]
assert хвіст_вручну == хвіст_dc, "repr виглядає по-різному!"
print("✅ поведінка збігається")
print("рядків зекономлено:", рядків_вручну - рядків_dc)
print("місць для одруківки менше на:", згадок_вручну - згадок_dc)
✅ поведінка збігається рядків зекономлено: 14 місць для одруківки менше на: 30
3 · Звідки декоратор бере поля¶
Ніякої магії: перелік полів лежить у службовому атрибуті __dataclass_fields__, і його
можна прочитати. Функція fields() показує те саме, але зручніше — з усіма
налаштуваннями кожного поля.
print("поля класу:", list(Продаж.__dataclass_fields__))
print()
print(f"{'імʼя':<12}{'тип':<10}{'у repr':<9}{'у ==':<7}{'у __init__'}")
for поле in fields(Продаж):
тип = поле.type.__name__ if hasattr(поле.type, "__name__") else str(поле.type)
print(f"{поле.name:<12}{тип:<10}{str(поле.repr):<9}{str(поле.compare):<7}{поле.init}")
# порядок полів — це порядок оголошення, і саме він стає порядком аргументів __init__
assert [п.name for п in fields(Продаж)] == поля, "порядок полів не той!"
print("\n✅ порядок полів збігається з порядком оголошення")
поля класу: ['товар', 'кількість', 'ціна', 'продавець', 'теги'] імʼя тип у repr у == у __init__ товар str True True True кількість int True True True ціна float True True True продавець str True True True теги list True True True ✅ порядок полів збігається з порядком оголошення
А ось згенеровані методи — просто методи в __dict__ класу, поруч із тими, що ти пишеш
руками. __hash__ тут дорівнює None: разом із __eq__ декоратор забирає хеш у
змінюваного обʼєкта, бо після зміни поля хеш «поїхав» би й обʼєкт загубився у власній множині.
цікаві = ["__init__", "__repr__", "__eq__", "__hash__", "__lt__", "__match_args__"]
for імя in цікаві:
значення = Продаж.__dict__.get(імя, "— немає —")
print(f"{імя:<16}{значення}")
assert Продаж.__hash__ is None, "у змінюваного dataclass хеша бути не повинно"
print("\n✅ __hash__ дорівнює None — обʼєкт нехешований")
__init__ <function Продаж.__init__ at 0x7f8b61c41b20>
__repr__ <function Продаж.__repr__ at 0x7f8b61c427a0>
__eq__ <function Продаж.__eq__ at 0x7f8b61c42840>
__hash__ None
__lt__ — немає —
__match_args__ ('товар', 'кількість', 'ціна', 'продавець', 'теги')
✅ __hash__ дорівнює None — обʼєкт нехешований
4 · Поля зі значеннями за замовчуванням і їхній порядок¶
Обовʼязкові поля мають стояти перед необовʼязковими — рівно з тієї самої причини,
що й параметри функції в темі 15: з полів складається сигнатура __init__.
Наступна клітинка навмисно падає. Зверни увагу: помилка виникає в момент читання класу, ще до створення хоч якогось обʼєкта, і в тексті названо конкретне поле.
@dataclass
class ЗламанийПорядок:
товар: str = "невідомий"
кількість: int # поле без замовчування після поля з ним
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[7], line 1 ----> 1 @dataclass 2 class ЗламанийПорядок: 3 товар: str = "невідомий" 4 кількість: int # поле без замовчування після поля з ним File /usr/lib64/python3.12/dataclasses.py:1266, in dataclass(cls, init, repr, eq, order, unsafe_hash, frozen, match_args, kw_only, slots, weakref_slot) 1263 return wrap 1265 # We're called as @dataclass without parens. -> 1266 return wrap(cls) File /usr/lib64/python3.12/dataclasses.py:1256, in dataclass.<locals>.wrap(cls) 1255 def wrap(cls): -> 1256 return _process_class(cls, init, repr, eq, order, unsafe_hash, 1257 frozen, match_args, kw_only, slots, 1258 weakref_slot) File /usr/lib64/python3.12/dataclasses.py:1063, in _process_class(cls, init, repr, eq, order, unsafe_hash, frozen, match_args, kw_only, slots, weakref_slot) 1058 if init: 1059 # Does this class have a post-init function? 1060 has_post_init = hasattr(cls, _POST_INIT_NAME) 1062 _set_new_attribute(cls, '__init__', -> 1063 _init_fn(all_init_fields, 1064 std_init_fields, 1065 kw_only_init_fields, 1066 frozen, 1067 has_post_init, 1068 # The name to use for the "self" 1069 # param in __init__. Use "self" 1070 # if possible. 1071 '__dataclass_self__' if 'self' in fields 1072 else 'self', 1073 globals, 1074 slots, 1075 )) 1077 # Get the fields as a list, and include only real fields. This is 1078 # used in all of the following methods. 1079 field_list = [f for f in fields.values() if f._field_type is _FIELD] File /usr/lib64/python3.12/dataclasses.py:585, in _init_fn(fields, std_fields, kw_only_fields, frozen, has_post_init, self_name, globals, slots) 583 seen_default = True 584 elif seen_default: --> 585 raise TypeError(f'non-default argument {f.name!r} ' 586 'follows default argument') 588 locals = {f'__dataclass_type_{f.name}__': f.type for f in fields} 589 locals.update({ 590 '__dataclass_HAS_DEFAULT_FACTORY__': _HAS_DEFAULT_FACTORY, 591 '__dataclass_builtins_object__': object, 592 }) TypeError: non-default argument 'кількість' follows default argument
Правильний порядок — і все працює: поле продавець тепер можна не передавати.
@dataclass
class ПродажІзЗамовчуванням:
товар: str
кількість: int
ціна: float
продавець: str = "невідомий"
без_продавця = ПродажІзЗамовчуванням("кава", 2, 85.0)
з_продавцем = ПродажІзЗамовчуванням("кава", 2, 85.0, "Олена")
print(без_продавця)
print(з_продавцем)
assert без_продавця.продавець == "невідомий", "замовчування не спрацювало"
print("✅ необовʼязкове поле взяло значення за замовчуванням")
ПродажІзЗамовчуванням(товар='кава', кількість=2, ціна=85.0, продавець='невідомий') ПродажІзЗамовчуванням(товар='кава', кількість=2, ціна=85.0, продавець='Олена') ✅ необовʼязкове поле взяло значення за замовчуванням
5 · Пастка змінюваного замовчування — і вона кричить¶
Тепер найважливіше місце теми. Спробуємо дати полю теги порожній список так, як
проситься рука. Клітинка навмисно падає — і це чудова новина.
@dataclass
class ПродажЗПасткою:
товар: str
теги: list = [] # так робити не можна
--------------------------------------------------------------------------- ValueError Traceback (most recent call last) Cell In[9], line 1 ----> 1 @dataclass 2 class ПродажЗПасткою: 3 товар: str 4 теги: list = [] # так робити не можна File /usr/lib64/python3.12/dataclasses.py:1266, in dataclass(cls, init, repr, eq, order, unsafe_hash, frozen, match_args, kw_only, slots, weakref_slot) 1263 return wrap 1265 # We're called as @dataclass without parens. -> 1266 return wrap(cls) File /usr/lib64/python3.12/dataclasses.py:1256, in dataclass.<locals>.wrap(cls) 1255 def wrap(cls): -> 1256 return _process_class(cls, init, repr, eq, order, unsafe_hash, 1257 frozen, match_args, kw_only, slots, 1258 weakref_slot) File /usr/lib64/python3.12/dataclasses.py:994, in _process_class(cls, init, repr, eq, order, unsafe_hash, frozen, match_args, kw_only, slots, weakref_slot) 991 kw_only = True 992 else: 993 # Otherwise it's a field of some type. --> 994 cls_fields.append(_get_field(cls, name, type, kw_only)) 996 for f in cls_fields: 997 fields[f.name] = f File /usr/lib64/python3.12/dataclasses.py:852, in _get_field(cls, a_name, a_type, default_kw_only) 848 # For real fields, disallow mutable defaults. Use unhashable as a proxy 849 # indicator for mutability. Read the __hash__ attribute from the class, 850 # not the instance. 851 if f._field_type is _FIELD and f.default.__class__.__hash__ is None: --> 852 raise ValueError(f'mutable default {type(f.default)} for field ' 853 f'{f.name} is not allowed: use default_factory') 855 return f ValueError: mutable default <class 'list'> for field теги is not allowed: use default_factory
Прочитай текст помилки: у ньому не лише названо поле, а й одразу написано, що робити —
use default_factory. Робимо як просять.
default_factory приймає функцію без аргументів, яку викличуть на кожне створення
обʼєкта. list — це саме така функція: list() повертає новий порожній список.
@dataclass
class ПродажЗТегами:
товар: str
теги: list = field(default_factory=list)
кава = ПродажЗТегами("кава")
чай = ПродажЗТегами("чай")
кава.теги.append("акція")
print("кава:", кава)
print("чай: ", чай)
print("це той самий список?", кава.теги is чай.теги)
# головна перевірка: у кожного обʼєкта свій список
assert кава.теги is not чай.теги, "списки виявились спільними!"
assert чай.теги == [], "у чаю зʼявився чужий тег!"
print("✅ фабрика створила окремий список для кожного продажу")
кава: ПродажЗТегами(товар='кава', теги=['акція']) чай: ПродажЗТегами(товар='чай', теги=[]) це той самий список? False ✅ фабрика створила окремий список для кожного продажу
def додати(товар, кошик=[]): # класична пастка: список у замовчуванні
кошик.append(товар)
return кошик
перший_виклик = додати("кава")
другий_виклик = додати("чай")
третій_виклик = додати("мед")
print("перший виклик: ", перший_виклик)
print("другий виклик: ", другий_виклик)
print("третій виклик: ", третій_виклик)
print("що лежить у __defaults__:", додати.__defaults__)
# доводимо, що всі три виклики працювали з одним і тим самим списком
assert перший_виклик is другий_виклик is третій_виклик, "а ось тут мав бути один список"
assert третій_виклик == ["кава", "чай", "мед"], "накопичення не відтворилось"
print("✅ помилки не було — просто функція запамʼятала всі виклики")
перший виклик: ['кава', 'чай', 'мед'] другий виклик: ['кава', 'чай', 'мед'] третій виклик: ['кава', 'чай', 'мед'] що лежить у __defaults__: (['кава', 'чай', 'мед'],) ✅ помилки не було — просто функція запамʼятала всі виклики
Різниця, заради якої цей розділ і потрібен: однакова за суттю помилка в класі падає в момент читання файла, а у функції не падає ніколи. Помилка, яка кричить одразу, завжди дешевша за помилку, яка мовчить.
from dataclasses import make_dataclass
# те саме оголошення, але зроблене під час роботи програми, щоб зловити виняток
try:
make_dataclass("Тест", [("теги", list, field(default=[]))])
результат_класу = "помилки немає"
except ValueError as помилка:
результат_класу = f"{type(помилка).__name__}: {str(помилка)[:46]}…"
результат_функції = "помилки немає, але список спільний"
print(f"{'у dataclass:':<16}{результат_класу}")
print(f"{'у функції:':<16}{результат_функції}")
assert результат_класу.startswith("ValueError"), "клас мав відмовитись створюватись"
print("\n✅ одна й та сама пастка: у класі гучна, у функції тиха")
у dataclass: ValueError: mutable default <class 'list'> for field теги … у функції: помилки немає, але список спільний ✅ одна й та сама пастка: у класі гучна, у функції тиха
7 · field(): поле є, але не скрізь¶
Крім фабрики, field() уміє прибирати поле з окремих згенерованих методів. Час запису
не має впливати на рівність (два однакові продажі лишаються однаковими, навіть якщо
записані з різницею в секунду), а внутрішній код рядка не варто друкувати.
@dataclass
class ПродажІзСлужбовим:
товар: str
ціна: float
записано: float = field(default=0.0, compare=False) # не бере участі в ==
код_рядка: str = field(default="", repr=False) # не потрапляє у вивід
о_дев_ятій = ПродажІзСлужбовим("кава", 85.0, записано=9.0, код_рядка="A-17")
о_десятій = ПродажІзСлужбовим("кава", 85.0, записано=10.0, код_рядка="A-17")
print("перший: ", о_дев_ятій)
print("другий: ", о_десятій)
print("рівні, хоч записані в різний час:", о_дев_ятій == о_десятій)
assert о_дев_ятій == о_десятій, "compare=False не спрацював"
assert "код_рядка" not in repr(о_дев_ятій), "repr=False не спрацював"
assert о_дев_ятій.код_рядка == "A-17", "поле мало лишитись у обʼєкті"
print("✅ поле є в обʼєкті, але не в repr і не в порівнянні")
перший: ПродажІзСлужбовим(товар='кава', ціна=85.0, записано=9.0) другий: ПродажІзСлужбовим(товар='кава', ціна=85.0, записано=10.0) рівні, хоч записані в різний час: True ✅ поле є в обʼєкті, але не в repr і не в порівнянні
8 · __post_init__: перевірки й обчислювані поля¶
Згенерований __init__ кличе __post_init__ останнім рядком, коли всі поля вже
записані. Тому в перевірках можна звертатися до будь-якого поля, а обчислювані поля
(field(init=False)) заповнюються саме тут.
@dataclass
class ПродажІзПеревіркою:
товар: str
кількість: int
ціна: float
продавець: str = "невідомий"
теги: list = field(default_factory=list)
сума: float = field(init=False) # ззовні не приймається — обчислюється
def __post_init__(self):
if self.кількість <= 0:
raise ValueError("кількість має бути додатною")
if self.ціна < 0:
raise ValueError("ціна не може бути відʼємною")
self.сума = round(self.кількість * self.ціна, 2)
import inspect # щоб зазирнути в сигнатуру згенерованого __init__
нормальний = ПродажІзПеревіркою("кава", 2, 85.0)
print(нормальний)
print("сума:", нормальний.сума)
assert нормальний.сума == 170.0, "сума порахована неправильно"
# поле сума не приймається конструктором — його немає в сигнатурі __init__
assert "сума" not in inspect.signature(ПродажІзПеревіркою).parameters
print("✅ сума обчислена, а передати її ззовні неможливо")
ПродажІзПеревіркою(товар='кава', кількість=2, ціна=85.0, продавець='невідомий', теги=[], сума=170.0) сума: 170.0 ✅ сума обчислена, а передати її ззовні неможливо
І перевірка, заради якої __post_init__ і пишуть. Наступна клітинка навмисно падає:
обʼєкт на цей момент уже наповнений пʼятьма полями, і все одно нікуди не потрапить —
виняток вилітає з конструктора.
ПродажІзПеревіркою("кава", 0, 85.0)
--------------------------------------------------------------------------- ValueError Traceback (most recent call last) Cell In[15], line 1 ----> 1 ПродажІзПеревіркою("кава", 0, 85.0) File <string>:8, in __init__(self, товар, кількість, ціна, продавець, теги) Cell In[14], line 12, in ПродажІзПеревіркою.__post_init__(self) 10 def __post_init__(self): 11 if self.кількість <= 0: ---> 12 raise ValueError("кількість має бути додатною") 13 if self.ціна < 0: 14 raise ValueError("ціна не може бути відʼємною") ValueError: кількість має бути додатною
Спіймаємо цей самий виняток акуратно й переконаємось, що жодного обʼєкта не лишилось.
спіймано = None
try:
поганий = ПродажІзПеревіркою("кава", 0, 85.0)
except ValueError as помилка:
спіймано = str(помилка)
print("виняток:", спіймано)
print("імʼя «поганий» узагалі не звʼязалось:", "поганий" not in dir())
assert спіймано == "кількість має бути додатною", "текст помилки не той"
print("✅ перевірка спрацювала до того, як обʼєкт кудись потрапив")
виняток: кількість має бути додатною імʼя «поганий» узагалі не звʼязалось: True ✅ перевірка спрацювала до того, як обʼєкт кудись потрапив
9 · frozen=True і його межа¶
frozen=True дописує в клас власні __setattr__ і __delattr__, які просто кидають
виняток. Наступна клітинка навмисно падає.
@dataclass(frozen=True)
class ЗамороженийПродаж:
товар: str
ціна: float
теги: list = field(default_factory=list)
заморожений = ЗамороженийПродаж("кава", 85.0)
print(заморожений)
заморожений.ціна = 90.0
ЗамороженийПродаж(товар='кава', ціна=85.0, теги=[])
--------------------------------------------------------------------------- FrozenInstanceError Traceback (most recent call last) Cell In[17], line 10 8 заморожений = ЗамороженийПродаж("кава", 85.0) 9 print(заморожений) ---> 10 заморожений.ціна = 90.0 File <string>:4, in __setattr__(self, name, value) FrozenInstanceError: cannot assign to field 'ціна'
from dataclasses import FrozenInstanceError
до_зміни = repr(заморожений)
# поле перепризначити не можна
не_дало_змінити_поле = False
try:
заморожений.теги = ["інший список"]
except FrozenInstanceError:
не_дало_змінити_поле = True
# а от дописати в сам список — будь ласка
заморожений.теги.append("акція")
print("до зміни: ", до_зміни)
print("після: ", заморожений)
assert не_дало_змінити_поле, "поле мало бути захищене"
assert заморожений.теги == ["акція"], "список мав змінитись — у цьому й річ"
assert repr(заморожений) != до_зміни, "заморожений обʼєкт таки змінився"
print("\n✅ доведено: frozen=True захищає поле, але НЕ вміст обʼєкта за полем")
до зміни: ЗамороженийПродаж(товар='кава', ціна=85.0, теги=[]) після: ЗамороженийПродаж(товар='кава', ціна=85.0, теги=['акція']) ✅ доведено: frozen=True захищає поле, але НЕ вміст обʼєкта за полем
Побічний наслідок цієї дірки: заморожений обʼєкт зі списком усередині не хешується.
Хеш рахується по кортежу всіх полів, а список хешувати не можна — і половина користі
від frozen зникає.
Справжня незмінність — це коли незмінні всі поля. Замінюємо список кортежем.
@dataclass(frozen=True)
class СправдіНезмінний:
товар: str
ціна: float
теги: tuple = ()
зі_списком = ЗамороженийПродаж("чай", 45.0)
з_кортежем = СправдіНезмінний("чай", 45.0)
# зі списком усередині хеш не рахується
хеш_зі_списком = None
try:
хеш_зі_списком = hash(зі_списком)
except TypeError as помилка:
хеш_зі_списком = f"TypeError: {помилка}"
print("hash(зі_списком):", хеш_зі_списком)
print("hash(з_кортежем):", hash(з_кортежем))
print("однакові за значенням потрапляють у множину як один елемент:",
len({СправдіНезмінний("чай", 45.0), СправдіНезмінний("чай", 45.0)}))
assert isinstance(хеш_зі_списком, str) and хеш_зі_списком.startswith("TypeError")
assert isinstance(hash(з_кортежем), int), "кортеж усередині мав дати хеш"
assert len({СправдіНезмінний("чай", 45.0), СправдіНезмінний("чай", 45.0)}) == 1
print("✅ незмінність працює лише тоді, коли незмінні всі поля")
hash(зі_списком): TypeError: unhashable type: 'list' hash(з_кортежем): 3477173012319697247 однакові за значенням потрапляють у множину як один елемент: 1 ✅ незмінність працює лише тоді, коли незмінні всі поля
10 · slots=True: скільки коштує словник атрибутів¶
За замовчуванням атрибути обʼєкта живуть у словнику __dict__. slots=True (з Python
3.10) розкладає їх у фіксовані комірки без словника. Зміряймо це не «на око», а
tracemalloc — на двохстах тисячах однакових продажів.
import sys
import tracemalloc
@dataclass
class ПродажЗвичайний:
товар: str
кількість: int
ціна: float
продавець: str
теги: tuple
@dataclass(slots=True)
class ПродажЗіСлотами:
товар: str
кількість: int
ціна: float
продавець: str
теги: tuple
СКІЛЬКИ = 200_000
def памʼять_на_обʼєкт(клас, скільки):
"""Скільки байтів займає один обʼєкт — разом зі своїм словником атрибутів."""
tracemalloc.start()
склад = [клас("кава", 2, 85.0, "Олена", ()) for _ in range(скільки)]
зайнято, _ = tracemalloc.get_traced_memory()
tracemalloc.stop()
# віднімаємо сам список посилань — він однаковий для обох варіантів
на_обʼєкт = (зайнято - sys.getsizeof(склад)) / скільки
del склад
return на_обʼєкт
звичайний_байт = памʼять_на_обʼєкт(ПродажЗвичайний, СКІЛЬКИ)
слоти_байт = памʼять_на_обʼєкт(ПродажЗіСлотами, СКІЛЬКИ)
print(f"звичайний dataclass: {звичайний_байт:.0f} Б на обʼєкт")
print(f"slots=True: {слоти_байт:.0f} Б на обʼєкт")
print(f"економія: {100 * (1 - слоти_байт / звичайний_байт):.0f} %")
print(f"на {СКІЛЬКИ:_} обʼєктах: "
f"{СКІЛЬКИ * (звичайний_байт - слоти_байт) / 1048576:.1f} МБ")
assert слоти_байт < звичайний_байт, "слоти мали заощадити памʼять"
print("✅ версія зі слотами справді легша")
звичайний dataclass: 104 Б на обʼєкт slots=True: 72 Б на обʼєкт економія: 31 % на 200_000 обʼєктах: 6.1 МБ ✅ версія зі слотами справді легша
Платня за це — у класі зі слотами немає __dict__, і нове поле «на льоту» не додати.
Наступна клітинка навмисно падає.
зі_слотами = ПродажЗіСлотами("кава", 2, 85.0, "Олена", ())
зі_слотами.знижка = 5 # такого поля в переліку немає
--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) Cell In[21], line 2 1 зі_слотами = ПродажЗіСлотами("кава", 2, 85.0, "Олена", ()) ----> 2 зі_слотами.знижка = 5 # такого поля в переліку немає AttributeError: 'ПродажЗіСлотами' object has no attribute 'знижка'
звичайний = ПродажЗвичайний("кава", 2, 85.0, "Олена", ())
звичайний.знижка = 5 # а тут поле просто зʼявляється у словнику — і мовчки
print("у звичайного зʼявилось нове поле:", звичайний.знижка)
print("його словник атрибутів:", list(звичайний.__dict__))
print("у версії зі слотами словника немає:", not hasattr(зі_слотами, "__dict__"))
assert not hasattr(зі_слотами, "__dict__"), "слоти мали прибрати словник"
print("✅ те, що економить памʼять, заразом ловить одруківки в іменах полів")
у звичайного зʼявилось нове поле: 5 його словник атрибутів: ['товар', 'кількість', 'ціна', 'продавець', 'теги', 'знижка'] у версії зі слотами словника немає: True ✅ те, що економить памʼять, заразом ловить одруківки в іменах полів
11 · Коли dataclass не потрібен¶
Останній розділ — про межу. Порівняємо пʼять способів зберегти ті самі дані: словник,
кортеж, NamedTuple, dataclass і звичайний клас.
from typing import NamedTuple
class ПродажNT(NamedTuple):
товар: str
ціна: float
class ПродажКлас:
def __init__(self, товар, ціна):
self.товар = товар
self.ціна = ціна
@dataclass(frozen=True, slots=True)
class ПродажDC:
товар: str
ціна: float
варіанти = {
"словник": {"товар": "кава", "ціна": 85.0},
"кортеж": ("кава", 85.0),
"NamedTuple": ПродажNT("кава", 85.0),
"dataclass": ПродажDC("кава", 85.0),
"звичайний клас": ПродажКлас("кава", 85.0),
}
print(f"{'варіант':<16}{'байтів':<9}{'імена полів':<14}{'незмінний':<11}{'хешований'}")
for назва, зразок in варіанти.items():
розмір = sys.getsizeof(зразок)
if hasattr(зразок, "__dict__"):
розмір += sys.getsizeof(зразок.__dict__)
має_імена = hasattr(зразок, "товар") or isinstance(зразок, dict)
try:
hash(зразок)
хешується = True
except TypeError:
хешується = False
незмінний = назва in ("кортеж", "NamedTuple", "dataclass")
print(f"{назва:<16}{розмір:<9}{str(має_імена):<14}{str(незмінний):<11}{хешується}")
варіант байтів імена полів незмінний хешований словник 184 True False False кортеж 56 False True True NamedTuple 56 True True True dataclass 48 True True True звичайний клас 344 True False True
Найважливіша практична різниця між NamedTuple і dataclass — це те, що
NamedTuple лишається кортежем: він розпаковується, індексується й випадково
дорівнює звичайному кортежу з тими самими значеннями. Іноді це саме те, що треба,
іноді — джерело дивних збігів.
нт = ПродажNT("кава", 85.0)
дк = ПродажDC("кава", 85.0)
товар, ціна = нт # NamedTuple розпаковується
print("розпакували NamedTuple:", товар, ціна)
print("нт[0]:", нт[0])
print('нт == ("кава", 85.0):', нт == ("кава", 85.0))
print('дк == ("кава", 85.0):', дк == ("кава", 85.0))
assert нт == ("кава", 85.0), "NamedTuple мав дорівнювати кортежу"
assert дк != ("кава", 85.0), "dataclass кортежем не є і рівним йому бути не може"
print("\n✅ NamedTuple — це кортеж з іменами; dataclass — окремий тип")
розпакували NamedTuple: кава 85.0
нт[0]: кава
нт == ("кава", 85.0): True
дк == ("кава", 85.0): False
✅ NamedTuple — це кортеж з іменами; dataclass — окремий тип
І остання перевірка розділу: dataclass уміє те, чого NamedTuple не вміє без
церемоній, — перевіряти дані при створенні. Саме це найчастіше й вирішує вибір.
@dataclass(frozen=True)
class ПродажІзКонтрактом:
товар: str
ціна: float
def __post_init__(self):
if not self.товар:
raise ValueError("товар не може бути порожнім")
if self.ціна <= 0:
raise ValueError("ціна має бути додатною")
спіймані = []
for погані_дані in [("", 85.0), ("кава", 0.0), ("кава", -5.0)]:
try:
ПродажІзКонтрактом(*погані_дані)
except ValueError as помилка:
спіймані.append(f"{погані_дані} → {помилка}")
for рядок in спіймані:
print(рядок)
# а NamedTuple такі дані пропускає без жодного слова
мовчазний = ПродажNT("", -5.0)
print("\nNamedTuple прийняв те саме:", мовчазний)
assert len(спіймані) == 3, "мали спрацювати всі три перевірки"
assert мовчазний.ціна == -5.0, "NamedTuple мав пропустити відʼємну ціну"
print("\n✅ перевірка при створенні — саме та причина брати dataclass, а не NamedTuple")
('', 85.0) → товар не може бути порожнім
('кава', 0.0) → ціна має бути додатною
('кава', -5.0) → ціна має бути додатною
NamedTuple прийняв те саме: ПродажNT(товар='', ціна=-5.0)
✅ перевірка при створенні — саме та причина брати dataclass, а не NamedTuple
Що далі — три завдання¶
🟢 Рівень 1¶
Опиши через @dataclass картку читача бібліотеки: імʼя, рік_народження,
квиток (рядок) і взяті_книги (список, порожній за замовчуванням). Створи двох
читачів, додай книгу одному з них і доведи assert-ом, що список другого читача
лишився порожнім.
🟡 Рівень 2¶
Додай до цієї картки __post_init__, який забороняє порожнє імʼя й рік народження
поза межами 1900–2026, і поле вік, оголошене як field(init=False). Перевір
assert-ами, що вік рахується правильно й що обидві перевірки справді кидають
ValueError.
🔴 Рівень 3¶
Зроби frozen=True версію картки й доведи двома assert-ами: (1) поле не
перепризначити, (2) список взяті_книги все одно змінюється зсередини.
Потім полагодь це, замінивши список кортежем, і покажи, що після заміни
hash(картка) працює, а до заміни падав із TypeError.
Розгорнуті умови з критеріями «зроблено» — у homework.md.