У темі 25 ми зібрали клас
Продаж руками, у темі 26 навчилися
не дублювати спільне, а в темі 27 зʼясували, що __repr__, __eq__
й порівняння — це звичайні методи з дивними іменами, які ти пишеш сам. Усе разом працює.
Проблема в іншому: варто картці продажу дорости до пʼяти полів — і ці «звичайні методи»
перетворюються на три десятки рядків, у яких саме лише слово кількість
набране сім разів. Ця тема про один декоратор, який пише їх замість тебе, і про те,
де саме проходить межа його корисності.
Наскрізний приклад лишається той самий, що в темі 25, — рядок журналу продажів. Тільки тепер у ньому не три поля, а пʼять: товар, кількість, ціна, продавець і список тегів. Саме на пʼятому полі ручний код перестає бути «просто трохи довшим» і стає джерелом помилок.
01 / БільТридцять рядків заради пʼяти полів
Ось що доводиться написати, щоб картка продажу поводилась пристойно: створювалась, друкувалась по-людськи й порівнювалась за значенням, а не за адресою.
Двадцять два рядки коду, і в жодному з них немає ані краплі змісту. Це не алгоритм,
не правило предметної області, не перевірка — це переписування списку полів
чотири рази поспіль. Порахуй, скільки разів у цьому коді написано слово
ціна: у сигнатурі, у присвоєнні (двічі), у __repr__ (двічі),
у __eq__ (двічі). Сім разів. Помножити на пʼять полів — і виходить
тридцять пʼять місць, кожне з яких можна написати з одруківкою.
Найгірше, що одруківка тут не падає. Якщо в __eq__
випадково написати self.ціна == other.ціна двічі, а продавець
загубити — код працюватиме. Просто два різні продажі часом виявлятимуться рівними, і
ти дізнаєшся про це з дивної поведінки звіту через півроку. Мовчазні помилки —
найдорожчі, ми це вже бачили і в
темі 15, і в темі 26 із забутим
super().__init__().
Додай сюди типову вимогу «продажі треба вміти сортувати» — і зʼявляться ще чотири
методи: __lt__, __le__, __gt__,
__ge__. Разом із ними клас на пʼять полів переваливає за тридцять рядків,
з яких змістовних — нуль.
02 / ДекораторТри рядки замість тридцяти
Стандартний модуль dataclasses дає інструмент, який пише всі ці методи
за тебе. Виглядає він так:
Це повний еквівалент коду згори — з тим самим __init__, тим самим
__repr__ і тим самим __eq__. Перевіримо:
Рядок @dataclass над класом — це декоратор (decorator).
Повну механіку декораторів ми розберемо в темі 31; поки що достатньо однієї фрази:
декоратор — це функція, яка отримує щойно створений клас, щось із ним робить і повертає
назад. @dataclass робить конкретну річ: читає перелік полів і дописує в клас
методи, яких там не було. Жодної магії, жодного нового синтаксису класів — просто код,
який пише код.
Порівняй два способи пліч-о-пліч і подивись, як росте розрив:
Інтерактив 1 · Вручну проти @dataclass
Той самий клас двома способами. Додавай поля повзунком і вмикай потрібні методи галочками.
03 / АнотаціяЩо тут робить двокрапка
Рядок товар: str у тілі класу — це анотація
(annotation). Річ для нас нова, тому одразу знімемо два непорозуміння.
Python нічого не перевіряє. Написати кількість: int і
передати туди рядок — можна, і жодної помилки не буде. Анотація не звужує тип і не
впливає на виконання; це примітка, яку інтерпретатор запамʼятовує й нікому не
навʼязує.
Але декоратору вона потрібна. Саме за анотаціями
@dataclass дізнається, які поля має клас і в якому вони порядку. Рядок без
двокрапки полем не стане — і мовчки випаде з __init__. Тобто тут анотація
працює як розмітка для декоратора, а не як обіцянка про тип.
Що писати після двокрапки, якщо типу ще не знаєш? Пиши найочевидніше: str,
int, float, bool, list,
dict. Повна історія анотацій — чим вони корисні, як їх перевіряє окремий
інструмент і що робити зі складними випадками на кшталт «список рядків» — це наступна
тема, 29. Тут вони потрібні рівно як розділовий знак.
Продаж.__annotations__ — саме його читає декоратор.
З Python 3.10 їх можна отримати «лінивими» через
from __future__ import annotations, і тоді значеннями словника будуть рядки,
а не обʼєкти типів. Для @dataclass це майже нічого не міняє, з одним
винятком: він розпізнає ClassVar і InitVar за текстом
анотації, тож перейменування цих імен при імпорті ламає розпізнавання.04 / МеханікаЩо саме згенерував декоратор
Декоратор не робить нічого таємного, і це легко довести. По-перше, він лишає в класі перелік полів у службовому атрибуті:
По-друге, згенеровані методи — звичайні методи, які видно в
Продаж.__dict__ поруч із тими, що ти написав руками. Що саме туди потрапить,
залежить від параметрів декоратора, і їх варто знати всі чотири головні:
| параметр | типово | що додає |
|---|---|---|
| init | True | __init__ із полями в порядку оголошення |
| repr | True | __repr__ виду Продаж(товар='кава', …) |
| eq | True | __eq__ — порівняння кортежів усіх полів |
| order | False | __lt__, __le__, __gt__, __ge__ |
| frozen | False | __setattr__ і __delattr__, що забороняють зміни |
| slots | False | __slots__ замість словника атрибутів |
Перемикай параметри й дивись, що зʼявляється в класі:
Інтерактив 2 · Що зʼявилось у класі
Кожна галочка — параметр декоратора. Внизу видно, чи можна покласти обʼєкт у множину.
order без eq — і клас не створиться
взагалі: впорядкування без рівності не має сенсу, тож декоратор відмовляється
працювати. А сама лише пара eq без frozen робить
обʼєкт нехешованим: змінюваний обʼєкт із рівністю за значенням не можна
класти в множину, бо його хеш змінювався б разом із полями.Останній рядок підпису вартий окремого абзацу, бо це найнесподіваніша поведінка
декоратора. Коли ти пишеш свій __eq__ руками, Python теж прибирає
__hash__ — це той самий контракт із теми 27, а не витівка dataclass.
Логіка проста: два рівні обʼєкти зобовʼязані мати однаковий хеш, а якщо поля можна
міняти, то й хеш «поїде» після першої ж зміни, і обʼєкт загубиться у власній множині.
Тому мова радше забирає хеш, ніж дає зіпсований.
05 / ЗамовчуванняЗначення за замовчуванням
Поля можуть мати значення за замовчуванням — так само, як параметри функції в темі 15. Пишеться воно після анотації:
І звідси одразу випливає обмеження, яке ти вже знаєш із функцій:
обовʼязкові поля мають стояти перед необовʼязковими. Причина буквально
та сама, що й у темі 15: декоратор складає з полів сигнатуру __init__, а
в сигнатурі параметр без замовчування після параметра із замовчуванням неможливий —
Python не знатиме, куди подіти позиційний аргумент.
Зверни увагу на дві приємні дрібниці. По-перше, помилка виникає у момент читання файла, а не при створенні обʼєкта: декоратор виконується одразу, тож поламаний клас не доживе до першого виклику. По-друге, текст помилки називає конкретне поле — шукати нічого не треба.
06 / ПасткаПастка, що нарешті кричить
Тепер найважливіший розділ теми. Спробуй дати полю теги порожній список
за замовчуванням — так, як це просить рука:
Клас не створився. Помилка — у момент читання файла, з прямою вказівкою, що робити далі. І це чудова новина, бо ти вже бачив цю саму пастку у зовсім іншому настрої.
Згадай тему 15. Там функція def додати(товар, кошик=[]) була написана
абсолютно легально: Python не заперечив ані слова, обчислив [] один-єдиний
раз у момент читання def і поклав його в __defaults__. Далі
кожен виклик без аргументу дописував у той самий список, і функція починала
памʼятати всі попередні виклики. Ніякої помилки — просто дивна поведінка, яку помічають
на третьому виклику, а розуміють значно пізніше.
Причина в обох випадках одна: значення за замовчуванням обчислюється один раз,
у момент читання означення. У класі це так само: [] став би одним
спільним обʼєктом для всіх продажів — рідною сестрою пастки атрибута класу зі
теми 25. Різниця лише в тому, що
@dataclass бачить оголошення й може відмовитись його виконувати.
Функція такої перевірки не має й ніколи не матиме — надто пізно міняти правила мови.
Правильна форма — фабрика, тобто функція, яку викличуть на кожен новий обʼєкт:
field(...) — це спосіб сказати про поле більше, ніж уміщається в одну
анотацію. Параметр default_factory приймає функцію без
аргументів; згенерований __init__ викликає її щоразу, коли поле не
передали. Сам list — теж функція: list() повертає новий
порожній список. Так само сюди годяться dict, set і будь-яка
твоя власна функція, наприклад datetime.now.
Інтерактив 3 · Один список чи по одному на кожного
Три способи написати «порожній список за замовчуванням». Дивись, на якому етапі спливає різниця.
list, dict і set — типи, які він знає в обличчя.
Якщо покласти в замовчування свій змінюваний обʼєкт (скажімо, примірник іншого
dataclass без frozen), заперечень не буде, і ти отримаєш тиху пастку зразка
теми 15. Правило просте й без винятків: у замовчуванні поля може стояти лише
незмінне значення; усе інше — через default_factory.07 / fieldТонке налаштування поля
field() уміє не лише фабрику. Три його параметри трапляються постійно,
і всі троє розвʼязують ту саму задачу: поле є, але в цьому конкретному методі його
бути не повинно.
Продаж має ще й час запису — момент, коли рядок потрапив у журнал. Для рівності він зайвий: два однакові продажі лишаються однаковими, навіть якщо записані з різницею в секунду. Поле лишається, з порівняння зникає.
Якщо поле велике (список на тисячу елементів) або таємне (токен, пароль), його не
варто друкувати. repr=False прибирає поле з рядка
Продаж(...), не чіпаючи більше нічого.
Поле, яке обчислюється з інших, не має сенсу приймати ззовні. Сума продажу — це кількість на ціну; передавати її руками означає дозволити комусь передати неправильну.
Останній випадок піднімає очевидне питання: якщо сума не приймається
ззовні, то хто ж її обчислить? Для цього є окремий гак.
08 / Після__post_init__
Метод __post_init__ — це місце, куди ти дописуєш те, чого декоратор
згенерувати не може: перевірки й обчислення. Згенерований __init__ кличе
його останнім рядком, коли всі поля вже записані в обʼєкт.
Порядок тут принциповий, і його варто побачити покроково: спершу
__init__ присвоює всі поля згори вниз, потім кличе фабрики для тих, кому
значення не передали, і аж наприкінці — __post_init__. Тому в перевірках
уже можна звертатися до будь-якого поля.
Інтерактив 4 · Коли саме спрацьовує __post_init__
Ліворуч — рядок згенерованого __init__, який зараз виконується. Праворуч — що вже записано в обʼєкт.
Продаж(...) нічого не поверне:
виняток із __post_init__ вилітає з конструктора, і напівживий обʼєкт
просто нікому не дістанеться. Поля сума в ньому так і не зʼявилось.InitVar. Іноді потрібне значення, яке
приймається в конструкторі, використовується в __post_init__, але полем
не стає — курс валюти, ключ бази, прапорець режиму. Для цього є анотація
InitVar[тип]: таке імʼя потрапляє в сигнатуру __init__, а звідти
— окремим аргументом у __post_init__, і в самому обʼєкті не лишається
жодного сліду. У __repr__ і __eq__ воно теж не бере участі.09 / Заморозкаfrozen=True і його межа
Параметр frozen=True перетворює обʼєкт на незмінний: після створення
жодне поле не присвоїти.
Механіка знайома з теми 27: декоратор
дописує в клас власні __setattr__
і __delattr__, які просто кидають виняток. Ніякого спеціального «режиму
незмінності» в мові немає — це звичайні методи, які відмовляються працювати.
Навіщо це потрібно — та сама причина, з якої в мові є
кортежі. Незмінний обʼєкт можна безпечно
передати в чужу функцію, покласти в кеш і використати як ключ словника, бо він гарантовано
не зміниться за твоєю спиною. Разом із незмінністю обʼєкт отримує назад
__hash__, який пара eq+змінюваність у нього забрала, — і
відтоді працює set(продажі) й {продаж: коментар}.
А тепер про межу, через яку спотикаються всі. frozen=True
захищає поля обʼєкта, а не обʼєкти, на які вони показують.
Інтерактив 5 · Що саме заморожено
Чотири дії над одним продажем. Перемикай frozen і тип поля теги.
p.теги.append("акція") спокійно спрацьовує: замок стоїть на полі
теги, і він забороняє перепризначити поле на інший список.
Сам список за стрілкою лишається звичайним змінюваним списком із
теми 06. Зніми другу галочку — і кортеж
усередині зробить обʼєкт незмінним по-справжньому.Цю різницю називають поверхневою незмінністю: заморожене саме звʼязування «імʼя поля →
обʼєкт», а не вміст того обʼєкта. Тут корисно згадати
тему 03: імʼя — це ярлик,
приліплений до обʼєкта. frozen забороняє переклеїти ярлик; що робиться
всередині коробки, його не обходить.
Практичний висновок: щоб дістати справжню незмінність, усі поля мають бути
незмінними. Список замінюй кортежем, словник — теж кортежем пар або
незмінною мапою. Побічний доказ того, що це не дрібниця, ти бачив в інтерактиві: у
замороженого продажу зі списком усередині hash(p) падає з
TypeError: unhashable type: 'list'. Хеш обчислюється по кортежу всіх полів,
а список хешувати не можна — і половина користі від frozen пропадає.
10 / Памʼятьslots=True і памʼять
Параметр slots=True зʼявився в Python 3.10 і розвʼязує зовсім іншу
задачу — не правильність, а вартість. За замовчуванням атрибути кожного обʼєкта живуть
у словнику p.__dict__, і ми на нього дивилися ще в темі 25. Словник —
структура зручна, але не безкоштовна.
__slots__ — це домовленість навпаки: клас наперед оголошує повний перелік
полів, і Python розкладає їх у фіксовані комірки, без словника взагалі. Виміряймо на
нашому продажі з пʼятьма полями (CPython 3.12):
| варіант | байтів на обʼєкт | нове поле «на льоту» |
|---|---|---|
| @dataclass | 104 | можна: p.знижка = 5 |
| @dataclass(slots=True) | 72 | AttributeError |
Тридцять два байти на обʼєкт, тобто близько 31 %. На сотні обʼєктів це буквально ніщо; на десятках мільйонів — гігабайти. Подивись, де саме починається різниця:
Інтерактив 6 · Скільки коштує словник атрибутів
Повзунок — скільки обʼєктів Продаж живе в памʼяті одночасно.
slots=True — це
інструмент для мільйонів однотипних записів, а не звичка на кожен клас.Платня за це є, і вона не лише в неможливості дописати поле на льоту. Клас зі слотами
не має __dict__, тож ламається все, що на нього розраховує: частина
бібліотек серіалізації, кешування через functools.cached_property, деякі
відлагоджувальні інструменти. І ще одна дрібниця, що інколи дивує:
@dataclass(slots=True) не змінює клас, а створює новий —
слоти неможливо додати до вже готового класу. Тому super() без аргументів
усередині такого класу поводиться нормально лише завдяки окремому обхідному прийому в
самому декораторі.
11 / МежаКоли dataclass не потрібен
У темі 25 був розділ «коли клас не потрібен». Тут потрібен його наступний поверх:
клас потрібен, але саме dataclass — не завжди.
Три ознаки, за якими варто взяти щось інше:
- Поведінки багато, даних майже немає. Клас із одним полем і
вісьмома методами — це не «дані з поведінкою», а служба. Згенеровані
__repr__і__eq__їй ні до чого; бери звичайний клас. - Дані прості й незмінні. Точка, діапазон, пара «код валюти —
курс» — для них є
NamedTuple: коротший запис, менше памʼяті, розпакуванняx, y = точкапрацює з коробки. - Значень два-три й вони живуть один вираз. Звичайний кортеж. Заводити тип заради того, що існує три рядки, — робота без вигоди.
Задай свої вимоги й подивись, що з пʼяти варіантів під них підходить:
Інтерактив 7 · Що обрати під свої вимоги
Познач те, що тобі справді потрібно. Варіанти, які цього не вміють, гаснуть.
| що брати | імена полів | незмінність | методи й перевірки | ціна |
|---|---|---|---|---|
| словник | ключі довільні | ні | ні | 0 рядків, 0 гарантій |
| кортеж | немає | так | ні | найдешевший |
| NamedTuple | так | завжди | методи можна, перевірки — важко | 2 рядки + поля |
| dataclass | так | за бажанням | так | 3 рядки + поля |
| звичайний клас | так | ні | так, будь-які | усе пишеш сам |
dataclasses, з якого той і виріс: те саме, але з валідаторами,
конвертерами полів і купою дрібних зручностей, яких у стандартній бібліотеці свідомо не
стали робити. pydantic вирішує іншу задачу: він перевіряє й приводить
типи під час виконання, тому його беруть на межі системи — там, де в програму
входить чужий JSON. Обидві — сторонні залежності; поки задача вміщається в
@dataclass із __post_init__, платити за них не варто.12 / ПідсумокЩо забрати з теми
Уся тема тримається на одній думці: одноманітний код має писати машина. Не тому, що лінь, а тому, що людина в тридцяти однакових рядках обовʼязково зробить одруківку, якої ніхто не помітить.
@dataclass— звичайний декоратор, який читає анотації полів і дописує в клас__init__,__repr__,__eq__, а на замовлення — ще й чотири методи порівняння. Перелік полів видно в__dataclass_fields__, згенеровані методи — у__dict__класу.- Анотація тут — розмітка для декоратора, а не перевірка типу. Рядок без двокрапки полем не стає. Повна історія анотацій — тема 29.
- Обовʼязкові поля стоять перед необовʼязковими, бо з них
складається сигнатура
__init__. Порушення видно одразу, з іменем поля в тексті помилки. - Змінюване значення за замовчуванням заборонене: замість
теги: list = []пишиfield(default_factory=list). Це та сама пастка, що в темі 15, — але тут вона кричить у момент читання файла, а не мовчить роками. __post_init__— місце для перевірок і обчислюваних полів. Він виконується останнім рядком__init__, коли всі поля вже на місці; виняток звідти не дає обʼєкту народитися взагалі.frozen=Trueдає незмінність і повертає хеш — але тільки поверхневу: список усередині лишається змінюваним, а обʼєкт із ним усередині взагалі не хешується. Справжня незмінність — це коли незмінні усі поля.slots=Trueприбирає словник атрибутів і економить близько третини памʼяті на обʼєкт. Має сенс на мільйонах записів, а не за звичкою.- dataclass — не універсальна відповідь. Багато поведінки й мало
даних — звичайний клас. Прості незмінні дані —
NamedTuple. Пара значень на один вираз — кортеж. Довільні ключі — словник.
На цьому блок «Обʼєкти» закінчено. Чотири теми тому клас був способом покласти дані поруч із правилами; тепер ти вмієш будувати ієрархії, вбудовувати власні типи в синтаксис мови через магічні методи й описувати картку даних трьома рядками замість тридцяти.
А блок 7 починається з питання, яке ця тема залишила відкритим. Ми весь час писали
товар: str і чесно казали, що Python цього не перевіряє. Тоді навіщо воно?
Виявляється, анотації — це повноцінна мова опису типів, яку читають редактор коду,
окремий перевіряльник і людина, що прийде після тебе; вона ловить цілий клас помилок
ще до запуску програми. Це тема 29, анотації типів. Далі в блоці —
ітератори й генератори, декоратори (де нарешті зʼясується, як влаштований сам
@dataclass) і базова асинхронність.
practice.ipynb ти напишеш клас
Продаж двічі — руками й декоратором — і доведеш assert-ом, що
вони поводяться однаково; спіймаєш ValueError на
теги: list = [] і полагодиш його через default_factory;
переконаєшся, що два продажі мають різні списки тегів; додаси
__post_init__ із перевіркою й обчислюваною сумою; заморозиш клас і
доведеш, що frozen не рятує вкладений список; і
порівняєш dataclass із кортежем, NamedTuple і звичайним класом за
розміром у памʼяті.Далі в темі
Теорію прочитано. Тепер закріпи її на практиці.