Клас, який пише себе сам

Пʼять полів — і три десятки рядків одноманітного коду, у якому одруківку ніхто не помітить. @dataclass пише ці рядки за тебе. А заразом ловить пастку з теми 16 — і цього разу вголос.

У темі 30 ми зібрали клас Продаж руками, у темі 31 навчилися не дублювати спільне, а в темі 32 зʼясували, що __repr__, __eq__ й порівняння — це звичайні методи з дивними іменами, які ти пишеш сам. Усе разом працює. Проблема в іншому: варто картці продажу дорости до пʼяти полів — і ці «звичайні методи» перетворюються на три десятки рядків, у яких саме лише слово кількість набране сім разів. Ця тема про один декоратор, який пише їх замість тебе, і про те, де саме проходить межа його корисності.

Наскрізний приклад лишається той самий, що в темі 30, — рядок журналу продажів. Тільки тепер у ньому не три поля, а пʼять: товар, кількість, ціна, продавець і список тегів. Саме на пʼятому полі ручний код перестає бути «просто трохи довшим» і стає джерелом помилок.

БільТридцять рядків заради пʼяти полів

Ось що доводиться написати, щоб картка продажу поводилась пристойно: створювалась, друкувалась по-людськи й порівнювалась за значенням, а не за адресою.

class Продаж: 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.теги)

Двадцять два рядки коду, і в жодному з них немає ані краплі змісту. Це не алгоритм, не правило предметної області, не перевірка — це переписування списку полів чотири рази поспіль. Порахуй, скільки разів у цьому коді написано слово ціна: у сигнатурі, у присвоєнні (двічі), у __repr__ (двічі), у __eq__ (двічі). Сім разів. Помножити на пʼять полів — і виходить тридцять пʼять місць, кожне з яких можна написати з одруківкою.

Найгірше, що одруківка тут не падає. Якщо в __eq__ випадково написати self.ціна == other.ціна двічі, а продавець загубити — код працюватиме. Просто два різні продажі часом виявлятимуться рівними, і ти дізнаєшся про це з дивної поведінки звіту через півроку. Мовчазні помилки — найдорожчі, ми це вже бачили і в темі 16, і в темі 31 із забутим super().__init__().

Додай сюди типову вимогу «продажі треба вміти сортувати» — і зʼявляться ще чотири методи: __lt__, __le__, __gt__, __ge__. Разом із ними клас на пʼять полів переваливає за тридцять рядків, з яких змістовних — нуль.

ДекораторТри рядки замість тридцяти

Стандартний модуль dataclasses дає інструмент, який пише всі ці методи за тебе. Виглядає він так:

from dataclasses import dataclass @dataclass class Продаж: товар: str кількість: int ціна: float продавець: str теги: list

Це повний еквівалент коду згори — з тим самим __init__, тим самим __repr__ і тим самим __eq__. Перевіримо:

>>> p = Продаж("кава", 2, 85.0, "Олена", ["акція"]) >>> p Продаж(товар='кава', кількість=2, ціна=85.0, продавець='Олена', теги=['акція']) >>> p == Продаж("кава", 2, 85.0, "Олена", ["акція"]) True

Рядок @dataclass над класом — це декоратор (decorator), і він потребує пояснення тут же, бо без нього вся тема стоїть на незнайомому слові.

Декоратор — це функція, яка приймає функцію або клас, щось із ними робить і повертає назад. Запис із «равликом» — не новий синтаксис класів, а коротка форма звичайного виклику: ці два фрагменти означають буквально одне й те саме.

@dataclass Продаж = dataclass(Продаж) class Продаж: # ← те саме, тільки довше товар: str

Тобто Python спершу читає тіло класу й створює клас, як завжди, потім передає його функції dataclass, а те, що функція поверне, лишає під іменем Продаж. @dataclass робить конкретну річ: читає перелік полів і дописує в клас методи, яких там не було, — після чого повертає той самий клас. Жодної магії — просто код, який пише код. Як писати такі функції самому й що ще з ними роблять, розбирає тема 36.

Порівняй два способи пліч-о-пліч і подивись, як росте розрив:

Вручну проти @dataclass

Той самий клас двома способами. Додавай поля повзунком і вмикай потрібні методи галочками.

рядків вручну0
рядків із dataclass0
імʼя поля треба вписати—
Стеж за нижнім числом — це кількість місць, де імʼя поля доводиться набрати руками. Саме воно, а не довжина файла, вимірює ризик: кожне таке місце — шанс на одруківку, яку не спіймає жоден інтерпретатор. У версії з декоратором імена полів написані рівно по одному разу, і збігтися їм просто ніде.

АнотаціяЩо тут робить двокрапка

Рядок товар: str у тілі класу — це анотація (annotation). Річ для нас нова, тому одразу знімемо два непорозуміння.

Python нічого не перевіряє. Написати кількість: int і передати туди рядок — можна, і жодної помилки не буде. Анотація не звужує тип і не впливає на виконання; це примітка, яку інтерпретатор запамʼятовує й нікому не навʼязує.

>>> Продаж("кава", "дві штуки", 85.0, "Олена", []) Продаж(товар='кава', кількість='дві штуки', ціна=85.0, продавець='Олена', теги=[])

Але декоратору вона потрібна. Саме за анотаціями @dataclass дізнається, які поля має клас і в якому вони порядку. Рядок без двокрапки полем не стане — і мовчки випаде з __init__. Тобто тут анотація працює як розмітка для декоратора, а не як обіцянка про тип.

@dataclass class Продаж: товар: str # поле — потрапить у __init__ кількість = 0 # НЕ поле: це просто атрибут класу

Що писати після двокрапки, якщо типу ще не знаєш? Пиши найочевидніше: str, int, float, bool, list, dict. Повна історія анотацій — чим вони корисні, як їх перевіряє окремий інструмент і що робити зі складними випадками на кшталт «список рядків» — це наступна тема, 34. Тут вони потрібні рівно як розділовий знак.

Для профі: де вони насправді лежать. Анотації тіла класу складаються у словник Продаж.__annotations__ — саме його читає декоратор. З Python 3.10 їх можна отримати «лінивими» через from __future__ import annotations, і тоді значеннями словника будуть рядки, а не обʼєкти типів. Для @dataclass це майже нічого не міняє, з одним винятком: він розпізнає ClassVar і InitVar за текстом анотації, тож перейменування цих імен при імпорті ламає розпізнавання.

МеханікаЩо саме згенерував декоратор

Декоратор не робить нічого таємного, і це легко довести. По-перше, він лишає в класі перелік полів у службовому атрибуті:

>>> list(Продаж.__dataclass_fields__) ['товар', 'кількість', 'ціна', 'продавець', 'теги']

По-друге, згенеровані методи — звичайні методи, які видно в Продаж.__dict__ поруч із тими, що ти написав руками. Що саме туди потрапить, залежить від параметрів декоратора. Ось усі шість, які трапляються в реальному коді:

параметртиповощо додає
initTrue__init__ із полями в порядку оголошення
reprTrue__repr__ виду Продаж(товар='кава', …)
eqTrue__eq__ — порівняння кортежів усіх полів
orderFalse__lt__, __le__, __gt__, __ge__
frozenFalse__setattr__ і __delattr__, що забороняють зміни
slotsFalse__slots__ замість словника атрибутів

Перемикай параметри й дивись, що зʼявляється в класі:

Що зʼявилось у класі

Кожна галочка — параметр декоратора. Внизу видно, чи можна покласти обʼєкт у множину.

методів3
хешований—
Познач order без eq — і клас не створиться взагалі: впорядкування без рівності не має сенсу, тож декоратор відмовляється працювати. А сама лише пара eq без frozen робить обʼєкт нехешованим: змінюваний обʼєкт із рівністю за значенням не можна класти в множину, бо його хеш змінювався б разом із полями.

Останній рядок підпису вартий окремого абзацу, бо це найнесподіваніша поведінка декоратора. Коли ти пишеш свій __eq__ руками, Python теж прибирає __hash__ — це той самий контракт із теми 32, а не витівка dataclass. Логіка проста: два рівні обʼєкти зобовʼязані мати однаковий хеш, а якщо поля можна міняти, то й хеш «поїде» після першої ж зміни, і обʼєкт загубиться у власній множині. Тому мова радше забирає хеш, ніж дає зіпсований.

ЗамовчуванняЗначення за замовчуванням

Поля можуть мати значення за замовчуванням — так само, як параметри функції в темі 16. Пишеться воно після анотації:

@dataclass class Продаж: товар: str кількість: int ціна: float продавець: str = "невідомий" # необовʼязкове поле

І звідси одразу випливає обмеження, яке ти вже знаєш із функцій: обовʼязкові поля мають стояти перед необовʼязковими. Причина буквально та сама, що й у темі 16: декоратор складає з полів сигнатуру __init__, а в сигнатурі параметр без замовчування після параметра із замовчуванням неможливий — Python не знатиме, куди подіти позиційний аргумент.

@dataclass class Продаж: товар: str = "невідомий" кількість: int # ← поле без замовчування після поля з ним TypeError: non-default argument 'кількість' follows default argument

Зверни увагу на дві приємні дрібниці. По-перше, помилка виникає у момент читання файла, а не при створенні обʼєкта: декоратор виконується одразу, тож поламаний клас не доживе до першого виклику. По-друге, текст помилки називає конкретне поле — шукати нічого не треба.

ПасткаПастка, що нарешті кричить

Тепер найважливіший розділ теми. Спробуй дати полю теги порожній список за замовчуванням — так, як це просить рука:

@dataclass class Продаж: товар: str теги: list = [] ValueError: mutable default <class 'list'> for field теги is not allowed: use default_factory

Клас не створився. Помилка — у момент читання файла, з прямою вказівкою, що робити далі. І це чудова новина, бо ти вже бачив цю саму пастку у зовсім іншому настрої.

Згадай тему 16. Там функція def додати(товар, кошик=[]) була написана абсолютно легально: Python не заперечив ані слова, обчислив [] один-єдиний раз у момент читання def і поклав його в __defaults__. Далі кожен виклик без аргументу дописував у той самий список, і функція починала памʼятати всі попередні виклики. Ніякої помилки — просто дивна поведінка, яку помічають на третьому виклику, а розуміють значно пізніше.

Причина в обох випадках одна: значення за замовчуванням обчислюється один раз, у момент читання означення. У класі це так само: [] став би одним спільним обʼєктом для всіх продажів — рідною сестрою пастки атрибута класу зі теми 30. Різниця лише в тому, що @dataclass бачить оголошення й може відмовитись його виконувати. Функція такої перевірки не має й ніколи не матиме — надто пізно міняти правила мови.

Правильна форма — фабрика, тобто функція, яку викличуть на кожен новий обʼєкт:

from dataclasses import dataclass, field @dataclass class Продаж: товар: str теги: list = field(default_factory=list)

field(...) — це спосіб сказати про поле більше, ніж уміщається в одну анотацію. Параметр default_factory приймає функцію без аргументів; згенерований __init__ викликає її щоразу, коли поле не передали. Сам list — теж функція: list() повертає новий порожній список. Так само сюди годяться dict, set і будь-яка твоя власна функція, наприклад datetime.now.

Один список чи по одному на кожного

Три способи написати «порожній список за замовчуванням». Дивись, на якому етапі спливає різниця.

помилка на етапі—
список спільний—
Порівняй перший і третій режим. Код у них однаковий за суттю — порожній список як значення за замовчуванням, — але доля різна: у класі це помилка на етапі читання файла, у функції це робоча програма з тихо неправильним результатом. Помилка, яка кричить одразу, завжди дешевша за помилку, яка мовчить.
Перевірка не всеосяжна. Декоратор забороняє list, dict і set — типи, які він знає в обличчя. Якщо покласти в замовчування свій змінюваний обʼєкт (скажімо, примірник іншого dataclass без frozen), заперечень не буде, і ти отримаєш тиху пастку зразка теми 16. Правило просте й без винятків: у замовчуванні поля може стояти лише незмінне значення; усе інше — через default_factory.

fieldТонке налаштування поля

field() уміє не лише фабрику. Три його параметри трапляються постійно, і всі троє розвʼязують ту саму задачу: поле є, але в цьому конкретному методі його бути не повинно.

compare=False · не брати участі в порівнянні

Продаж має ще й час запису — момент, коли рядок потрапив у журнал. Для рівності він зайвий: два однакові продажі лишаються однаковими, навіть якщо записані з різницею в секунду. Поле лишається, з порівняння зникає.

записано: float = field(default=0.0, compare=False)
repr=False · не потрапляти у вивід

Якщо поле велике (список на тисячу елементів) або таємне (токен, пароль), його не варто друкувати. repr=False прибирає поле з рядка Продаж(...), не чіпаючи більше нічого.

init=False · не потрапляти в конструктор

Поле, яке обчислюється з інших, не має сенсу приймати ззовні. Сума продажу — це кількість на ціну; передавати її руками означає дозволити комусь передати неправильну.

сума: float = field(init=False)

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

Після__post_init__

Метод __post_init__ — це місце, куди ти дописуєш те, чого декоратор згенерувати не може: перевірки й обчислення. Згенерований __init__ кличе його останнім рядком, коли всі поля вже записані в обʼєкт.

@dataclass class Продаж: товар: str кількість: int ціна: float продавець: str = "невідомий" теги: list = field(default_factory=list) сума: float = field(init=False) def __post_init__(self): if self.кількість <= 0: raise ValueError("кількість має бути додатною") self.сума = round(self.кількість * self.ціна, 2)

Порядок тут принциповий, і його варто побачити покроково: спершу __init__ присвоює всі поля згори вниз, потім кличе фабрики для тих, кому значення не передали, і аж наприкінці — __post_init__. Тому в перевірках уже можна звертатися до будь-якого поля.

Коли саме спрацьовує __post_init__

Ліворуч — рядок згенерованого __init__, який зараз виконується. Праворуч — що вже записано в обʼєкт.

крок1
полів у обʼєкті0
Перемкни на «кількість = 0». Обʼєкт до цієї миті вже наповнений — пʼять полів на місці, — і все одно Продаж(...) нічого не поверне: виняток із __post_init__ вилітає з конструктора, і напівживий обʼєкт просто нікому не дістанеться. Поля сума в ньому так і не зʼявилось.
Для профі: InitVar. Іноді потрібне значення, яке приймається в конструкторі, використовується в __post_init__, але полем не стає — курс валюти, ключ бази, прапорець режиму. Для цього є анотація InitVar[тип]: таке імʼя потрапляє в сигнатуру __init__, а звідти — окремим аргументом у __post_init__, і в самому обʼєкті не лишається жодного сліду. У __repr__ і __eq__ воно теж не бере участі.

Заморозкаfrozen=True і його межа

Параметр frozen=True перетворює обʼєкт на незмінний: після створення жодне поле не присвоїти.

>>> @dataclass(frozen=True) ... class Продаж: ... товар: str ... ціна: float >>> p = Продаж("кава", 85.0) >>> p.ціна = 90.0 dataclasses.FrozenInstanceError: cannot assign to field 'ціна'

Механіка знайома з теми 32: декоратор дописує в клас власні __setattr__ і __delattr__, які просто кидають виняток. Ніякого спеціального «режиму незмінності» в мові немає — це звичайні методи, які відмовляються працювати.

Навіщо це потрібно — та сама причина, з якої в мові є кортежі. Незмінний обʼєкт можна безпечно передати в чужу функцію, покласти в кеш і використати як ключ словника, бо він гарантовано не зміниться за твоєю спиною. Разом із незмінністю обʼєкт отримує назад __hash__, який пара eq+змінюваність у нього забрала, — і відтоді працює set(продажі) й {продаж: коментар}.

А тепер про межу, через яку спотикаються всі. frozen=True захищає поля обʼєкта, а не обʼєкти, на які вони показують.

Що саме заморожено

Чотири дії над одним продажем. Перемикай frozen і тип поля теги.

результат—
Третя дія — головна. Обʼєкт заморожений, а p.теги.append("акція") спокійно спрацьовує: замок стоїть на полі теги, і він забороняє перепризначити поле на інший список. Сам список за стрілкою лишається звичайним змінюваним списком із теми 07. Зніми другу галочку — і кортеж усередині зробить обʼєкт незмінним по-справжньому.

Цю різницю називають поверхневою незмінністю: заморожене саме звʼязування «імʼя поля → обʼєкт», а не вміст того обʼєкта. Тут корисно згадати тему 03: імʼя — це ярлик, приліплений до обʼєкта. frozen забороняє переклеїти ярлик; що робиться всередині коробки, його не обходить.

Практичний висновок: щоб дістати справжню незмінність, усі поля мають бути незмінними. Список замінюй кортежем, словник — теж кортежем пар або незмінною мапою. Побічний доказ того, що це не дрібниця, ти бачив в інтерактиві: у замороженого продажу зі списком усередині hash(p) падає з TypeError: unhashable type: 'list'. Хеш обчислюється по кортежу всіх полів, а список хешувати не можна — і половина користі від frozen пропадає.

Памʼятьslots=True і памʼять

Параметр slots=True зʼявився в Python 3.10 і розвʼязує зовсім іншу задачу — не правильність, а вартість. За замовчуванням атрибути кожного обʼєкта живуть у словнику p.__dict__, і ми на нього дивилися ще в темі 30. Словник — структура зручна, але не безкоштовна.

__slots__ — це домовленість навпаки: клас наперед оголошує повний перелік полів, і Python розкладає їх у фіксовані комірки, без словника взагалі. Виміряймо на нашому продажі з пʼятьма полями (CPython 3.12):

варіантбайтів на обʼєктнове поле «на льоту»
@dataclass104можна: p.знижка = 5
@dataclass(slots=True)72AttributeError

Тридцять два байти на обʼєкт, тобто близько 31 %. На сотні обʼєктів це буквально ніщо; на десятках мільйонів — гігабайти. Подивись, де саме починається різниця:

Скільки коштує словник атрибутів

Повзунок — скільки обʼєктів Продаж живе в памʼяті одночасно.

звичайний—
slots=True—
заощаджено—
Зверни увагу на ліву частину шкали. На десяти тисячах обʼєктів економія — менш ніж третина мегабайта, і морочитись немає сенсу. Правило те саме, що в усій оптимізації: спершу зміряй, потім міняй. slots=True — це інструмент для мільйонів однотипних записів, а не звичка на кожен клас.

Платня за це є, і вона не лише в неможливості дописати поле на льоту. Клас зі слотами не має __dict__, тож ламається все, що на нього розраховує: частина бібліотек серіалізації, кешування через functools.cached_property, деякі відлагоджувальні інструменти. І ще одна дрібниця, що інколи дивує: @dataclass(slots=True) не змінює клас, а створює новий — слоти неможливо додати до вже готового класу. Тому super() без аргументів усередині такого класу поводиться нормально лише завдяки окремому обхідному прийому в самому декораторі.

МежаКоли dataclass не потрібен

У темі 30 був розділ «коли клас не потрібен». Тут потрібен його наступний поверх: клас потрібен, але саме dataclass — не завжди.

Три ознаки, за якими варто взяти щось інше:

Задай свої вимоги й подивись, що з пʼяти варіантів під них підходить:

Що обрати під свої вимоги

Познач те, що тобі справді потрібно. Варіанти, які цього не вміють, гаснуть.

підходить—
Познач другу й третю вимогу разом — і не лишиться нічого: «міняти після створення» й «незмінність» суперечать одна одній, і жоден інструмент цього не полагодить. Вибір структури починається не з переліку можливостей, а з чесної відповіді на питання, що саме тобі потрібно.
що братиімена полівнезмінністьметоди й перевіркиціна
словникключі довільнініні0 рядків, 0 гарантій
кортежнемаєтакнінайдешевший
NamedTupleтакзавждиметоди можна, перевірки — важко2 рядки + поля
dataclassтакза бажаннямтак3 рядки + поля
звичайний кластакнітак, будь-якіусе пишеш сам
Для профі: сусіди по полиці. Бібліотека attrs — старший родич dataclasses, з якого той і виріс: те саме, але з валідаторами, конвертерами полів і купою дрібних зручностей, яких у стандартній бібліотеці свідомо не стали робити. pydantic вирішує іншу задачу: він перевіряє й приводить типи під час виконання, тому його беруть на межі системи — там, де в програму входить чужий JSON. Обидві — сторонні залежності; поки задача вміщається в @dataclass із __post_init__, платити за них не варто.

ПідсумокЩо забрати з теми

Уся тема тримається на одній думці: одноманітний код має писати машина. Не тому, що лінь, а тому, що людина в тридцяти однакових рядках обовʼязково зробить одруківку, якої ніхто не помітить.

На цьому блок «Обʼєкти» закінчено. Чотири теми тому клас був способом покласти дані поруч із правилами; тепер ти вмієш будувати ієрархії, вбудовувати власні типи в синтаксис мови через магічні методи й описувати картку даних трьома рядками замість тридцяти.

А блок 8 починається з питання, яке ця тема залишила відкритим. Ми весь час писали товар: str і чесно казали, що Python цього не перевіряє. Тоді навіщо воно? Виявляється, анотації — це повноцінна мова опису типів, яку читають редактор коду, окремий перевіряльник і людина, що прийде після тебе; вона ловить цілий клас помилок ще до запуску програми. Це тема 34, анотації типів. Далі в блоці — ітератори й генератори, декоратори (де нарешті зʼясується, як влаштований сам @dataclass) і базова асинхронність.