Python з нуля · Блок 6 · Тема 28

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

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

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

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

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

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

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.ціна двічі, а продавець загубити — код працюватиме. Просто два різні продажі часом виявлятимуться рівними, і ти дізнаєшся про це з дивної поведінки звіту через півроку. Мовчазні помилки — найдорожчі, ми це вже бачили і в темі 15, і в темі 26 із забутим super().__init__().

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

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

Стандартний модуль 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). Повну механіку декораторів ми розберемо в темі 31; поки що достатньо однієї фрази: декоратор — це функція, яка отримує щойно створений клас, щось із ним робить і повертає назад. @dataclass робить конкретну річ: читає перелік полів і дописує в клас методи, яких там не було. Жодної магії, жодного нового синтаксису класів — просто код, який пише код.

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

Інтерактив 1 · Вручну проти @dataclass

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Інтерактив 2 · Що зʼявилось у класі

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Інтерактив 3 · Один список чи по одному на кожного

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

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

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

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

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

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

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

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

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

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

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

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

08 / Після__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__. Тому в перевірках уже можна звертатися до будь-якого поля.

Інтерактив 4 · Коли саме спрацьовує __post_init__

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

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

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

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

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

Механіка знайома з теми 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):

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

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

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

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

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

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

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

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

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

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

Інтерактив 7 · Що обрати під свої вимоги

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

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

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

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

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

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

Далі в практиці. У practice.ipynb ти напишеш клас Продаж двічі — руками й декоратором — і доведеш assert-ом, що вони поводяться однаково; спіймаєш ValueError на теги: list = [] і полагодиш його через default_factory; переконаєшся, що два продажі мають різні списки тегів; додаси __post_init__ із перевіркою й обчислюваною сумою; заморозиш клас і доведеш, що frozen не рятує вкладений список; і порівняєш dataclass із кортежем, NamedTuple і звичайним класом за розміром у памʼяті.

Далі в темі

Теорію прочитано. Тепер закріпи її на практиці.