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

Магія, якої немає

Дивні імена з подвійними підкресленнями — не заклинання, а список домовленостей. Реалізуєш метод із такою назвою — і твій обʼєкт починає працювати з len, for, in, + і with так, ніби він вбудований тип.

Ми написали вже десятки методів і жодного разу не спитали, чому в __init__ такі дивні підкреслення. У темі 25 я сказав «так домовились», у темі 26 ми переоформили цю домовленість через super() — і обидва рази лишили питання відкритим. Час на нього відповісти, бо __init__ не самотній: таких методів кілька десятків, і кожен відповідає за конкретне вміння обʼєкта. Написав один рядок — твій клас навчився рахувати довжину. Написав другий — його можна перебирати циклом. Написав третій — він працює під with.

Наскрізний приклад лишається тим самим, що й у попередній темі: каталог невеликої бібліотеки. Позиція каталогу — Видання з назвою й роком; сам Каталог тримає список позицій:

каталог = Каталог([ Видання("Тигролови", 1944), Видання("Кобзар", 1840), Видання("Місто", 1928), Видання("Лісова пісня", 1911), ])

01 / ПротоколПротокол, а не магія

Почнемо з простого спостереження, яке пояснює всю тему. Візьми вбудовану функцію len. Здається, ніби вона «якось знає», як улаштований список, рядок і словник. Насправді вона не знає нічого: len(x) — це коротка форма запису type(x).__len__(x). Уся її робота — покликати метод з наперед домовленою назвою й повернути те, що той віддав.

Це і є протокол (protocol): домовленість між мовою й твоїм класом про те, як називається метод, які аргументи він отримує і що має повернути. Методи з подвійними підкресленнями з обох боків так і називають — магічні, або, точніше, дандер-методи (від dunder, double underscore). Ніякої магії в них немає, і в цьому вся суть: підкреслення тут — просто спосіб виділити імена, за якими стоїть домовленість, від тих, які вигадав ти.

ти пишешPython виконуєпротокол про що
len(x)type(x).__len__(x)розмір
x[0]type(x).__getitem__(x, 0)доступ за ключем
y in xtype(x).__contains__(x, y)належність
for i in xtype(x).__iter__(x)перебір
x + ytype(x).__add__(x, y)додавання
x == ytype(x).__eq__(x, y)рівність
str(x)type(x).__str__(x)текст для людини
with x:type(x).__enter__(x) … __exit__(…)вхід і вихід

Зверни увагу на праву колонку: скрізь type(x), а не просто x. Це не примха. Магічні методи шукаються в класі, а не в примірнику. Покласти функцію в обʼєкт.__len__ і сподіватись, що len() її підхопить, не вийде: вона там лежатиме, а len() її не побачить. Так зроблено заради швидкості — інтерпретатор не зазирає в словник примірника.

Інтерактив 1 · Виклик → магічний метод

Обери операцію — і подивись, у що саме Python її перекладає й що буде, коли методу немає.

магічний метод
Зніми галочку й пройди всі шість операцій. Чотири з них дають TypeError, а дві — ні: x == y тихо порівняє обʼєкти за тотожністю, а str(x) надрукує адресу в памʼяті. Саме тому їх ламають найчастіше — помилки немає, є неправильна відповідь.

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

02 / Показ__repr__ проти __str__

З __repr__ ми познайомились ще в темі 25: без нього обʼєкт друкується як <Видання object at 0x7f3c1a2b4d90>. Тепер розберемо пару цілком, бо методів насправді два, і плутають їх постійно.

class Видання: def __init__(self, назва, рік): self.назва = назва self.рік = рік def __repr__(self): # для розробника return f"Видання({self.назва!r}, {self.рік})" def __str__(self): # для користувача return f"{self.назва} ({self.рік})"

Різниця не в оформленні, а в тому, кому адресований рядок:

Позначка !r у f-рядку означає «підстав repr, а не str» — саме тому назва в прикладі виходить у лапках. Дрібниця, а корисна: за таким рядком одразу видно, що 1840 — число, а "Кобзар" — текст.

Інтерактив 2 · Той самий обʼєкт у чотирьох місцях

Консоль, print, f-рядок і всередині списку. Перемикай варіанти класу — і дивись, який метод спрацював.

repr(в)
str(в)
Другий варіант — найважливіший. Один-єдиний метод __repr__ лагодить усі чотири рядки одразу, бо __str__ за замовчуванням віддає роботу йому. Зворотне неправда: якщо визначити самий __str__, консоль і список і далі показуватимуть адресу.

Звідси правило, яке варто запамʼятати дослівно: якщо робиш один метод — роби __repr__. Він рятує налагодження, друк списків, повідомлення assert і виводи pytest із теми 19. __str__ додавай тоді, коли обʼєкт справді показують кінцевому користувачу й «технічний» вигляд там недоречний.

Для профі: __repr__ має бути відтворюваним. Ідеал, до якого варто прагнути, — рядок, який можна скопіювати назад у код і отримати такий самий обʼєкт: Видання('Кобзар', 1840). Тоді eval(repr(x)) дає x, і це дуже зручно в консолі й у логах. Коли відтворити неможливо (усередині відкрите зʼєднання, файл, потік), домовленість інша: пиши в кутових дужках, щоб було видно, що це опис, а не вираз — <ЖурналВидач vydachi.txt, відкрито>. Обидві форми зустрінеш у стандартній бібліотеці.

03 / РівністьРівність за вмістом

У темі 25 ми бачили неприємність: два продажі з абсолютно однаковими даними давали p == rFalse. Причина тепер зрозуміла. У object є свій __eq__, і він відповідає на питання «це той самий обʼєкт?», тобто працює як is. Для каталогу це майже завжди не те, що потрібно: два записи про «Кобзар» 1840 року — це одне видання, хоч і два обʼєкти в памʼяті.

def __eq__(self, інший): if not isinstance(інший, Видання): return NotImplemented # не моя справа, хай спробує інший бік return (self.назва, self.рік) == (інший.назва, інший.рік)

Два рядки, і обидва варті пояснення.

Порівнюємо кортеж із кортежем. Це той самий прийом, що в темі 7: кортежі порівнюються поелементно, тож один рядок замінює self.назва == інший.назва and self.рік == інший.рік. Коли полів пʼять, різниця в читабельності стає відчутною.

NotImplemented — не помилка, а ввічлива відмова. Це особливе значення, яким метод каже: «я не знаю, як порівняти себе з цим». Отримавши його, Python не здається: він пробує запитати з іншого боку — інший.__eq__(self). І лише коли обидва відмовились, повертає False для ==. Якби ми замість NotImplemented одразу повернули False, ми б відібрали в чужого класу право сказати, що він дорівнює нашому виданню. Найчастіше це нікому не заважає — але саме через такі дрібниці бібліотеки перестають дружити між собою.

Не плутай NotImplemented і NotImplementedError. Перше — значення, яке повертають; воно означає «спробуй інакше». Друге — виняток, який кидають, і означає він «метод має бути реалізований у нащадку». Імена схожі майже дослівно, а поведінка протилежна: перше дає Python шанс упоратись, друге зупиняє програму.

04 / КонтрактКонтракт із __hash__

А тепер найважливіші пʼятнадцять хвилин теми. Щойно ти визначив __eq__, ти взяв на себе зобовʼязання, про яке ніхто не попереджає вголос:

якщо a == b, то обовʼязково hash(a) == hash(b)

Це те саме залізне правило, яким закінчувалась тема 8. Нагадаю, звідки воно береться. Множина й словник шукають елемент не перебором, а за адресою: беруть hash(ключ), ділять на розмір таблиці, беруть остачу — і йдуть одразу в потрібну комірку. Рівність перевіряється вже всередині комірки, щоб розрізнити ключі, які випадково збіглися адресами.

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

Розробники Python цю пастку знали й поставили запобіжник: клас, у якому визначено __eq__, автоматично стає нехешованим — його __hash__ дорівнює None. Спроба покласти такий обʼєкт у множину падає з тим самим TypeError: unhashable type, що й hash([1, 2]) у темі 8. Груба, зате чесна поведінка: краще впасти одразу, ніж мовчки загубити дані.

def __hash__(self): return hash((self.назва, self.рік)) # ті самі поля, що й у __eq__

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

Інтерактив 3 · Що буде, коли контракт порушено

Два обʼєкти з однаковим вмістом: к1 і к2. Дивись, куди вони лягають у таблиці й що каже множина.

len({к1, к2})
ціни[к2]
Третій варіант — найгірший із чотирьох, і саме його пишуть руками: хтось додав __hash__ = object.__hash__, щоб «прибрати TypeError». Обʼєкти рівні, хеші різні — множина тримає дублікат, словник не знаходить ключа, і жодного повідомлення про помилку. Хеші тут навчальні, справжні hash() дають 19-значні числа.
Для профі: змінюваний обʼєкт краще лишати нехешованим. Уяви, що після додавання в множину хтось написав к1.рік = 1841. Хеш обʼєкта змінився, а лежить він у старій комірці — тепер множина не знайде його навіть за ним самим. Це та сама причина, через яку в Python нехешовані списки. Тому чесний хід такий: або клас незмінний (поля тільки читають, а зміна створює новий обʼєкт), або __hash__ не пиши взагалі. Порожня форма __hash__ = None явно закриває тему й читається як навмисне рішення, а не як забудькуватість.

05 / ПорядокПорядок і сортування

Наступне, чого зазвичай хочуть від каталогу, — упорядкувати його за роком. І тут несподіванка: sorted(каталог) падає з TypeError, хоча обʼєкти начебто цілком порівнянні.

Причина в тому, що < — теж протокол, і за замовчуванням його немає. Порівняння в Python розкладається на чотири методи: __lt__ (менше), __gt__ (більше), __le__ (не більше), __ge__ (не менше). Але писати всі чотири не треба — досить одного:

def __lt__(self, інший): if not isinstance(інший, Видання): return NotImplemented return (self.рік, self.назва) < (інший.рік, інший.назва)

Чому цього вистачає для сортування: sorted, min, max і list.sort усередині користуються лише операцією «менше». А a > b Python уміє вивести сам — це просто b < a, тобто дзеркальний виклик b.__lt__(a). Але <= так не виводиться: «не менше» не є запереченням «менше» в загальному випадку, і Python не ризикує вгадувати.

Якщо потрібні всі чотири порівняння, є готовий інструмент:

from functools import total_ordering @total_ordering class Видання: def __eq__(self, інший): ... def __lt__(self, інший): ... # решту трьох дописує декоратор

total_ordering — декоратор зі стандартної бібліотеки. Йому потрібні __eq__ і один метод порядку, а решту він добудує за логікою: a <= b означає a < b or a == b і так далі. Плата за це — трохи повільніше, бо кожне порівняння перетворюється на два виклики. Для каталогу з тисячі позицій це непомітно; для мільйона краще написати всі чотири руками.

Інтерактив 4 · Що вміє клас без __lt__ і з ним

Шість звичних операцій. Перемикай варіант — і дивись, які працюють, а які падають.

працює операцій0 / 6
Середній варіант вартий уваги. Один метод __lt__ оживляє пʼять операцій із шести: > Python виводить дзеркально, а sorted, min і max нічого, крім «менше», і не просять. Не працює тільки <= — і саме на ньому найчастіше й спотикаються.

Порада наостанок: якщо порядок потрібен один раз, __lt__ не потрібен взагалі — достатньо sorted(каталог, key=lambda в: в.рік). __lt__ заводять тоді, коли в типу є один природний порядок, з яким погодяться всі: дати, версії, гроші.

06 / АрифметикаАрифметика й зловживання

Бібліотека не тільки видає книжки — вона їх купує, а отже, рахує гроші. Рахувати гроші звичайним float не можна: з теми 4 ми знаємо, що 0.1 + 0.2 дає 0.30000000000000004. Класичний вихід — тримати суму в копійках цілим числом. І ось тут арифметичні магічні методи доречні як ніде:

class Гроші: def __init__(self, копійки): self.копійки = копійки def __repr__(self): return f"Гроші({self.копійки})" def __str__(self): return f"{self.копійки // 100},{self.копійки % 100:02d} грн" def __add__(self, інший): if not isinstance(інший, Гроші): return NotImplemented return Гроші(self.копійки + інший.копійки) # новий обʼєкт, не зміна себе def __mul__(self, множник): if not isinstance(множник, int): return NotImplemented return Гроші(self.копійки * множник)

Тепер Гроші(24000) + Гроші(15050) дає 390,50 грн, а Гроші(24000) * 3720,00 грн. Зверни увагу на два рішення в коді.

Метод повертає новий обʼєкт, а не змінює себе. Так поводяться всі числа й рядки в Python: 5 + 3 не змінює пʼятірку. Якщо __add__ почне міняти self, обʼєкт зрадить очікування читача в найнепомітніший спосіб.

Множення й додавання — різні за змістом. Гроші додають до грошей, а множать тільки на число: «сто гривень помножити на пʼятдесят гривень» не означає нічого. Тому в __add__ перевіряється Гроші, а в __mul__int.

Для профі: чому sum() падає на власному типі. Функція sum починає з нуля й пише 0 + перший_елемент. Ліворуч ціле число, і int.__add__ не знає, що робити з нашими грішми, — повертає NotImplemented. Тоді Python пробує дзеркальний метод правого операнда, __radd__ (right add). Додай його — і sum запрацює: def __radd__(self, інший): return self if інший == 0 else NotImplemented. Дзеркальна пара є в кожного арифметичного методу: __rmul__, __rsub__ і так далі. А ще охайніше — просто передати початкове значення: sum(ціни, Гроші(0)).

Де арифметика стає зловживанням

Гроші, вектори, дати, матриці, множини — усе це задачі, де + має загальновідомий сенс, і читач зрозуміє його без пояснень. Але спокуса піти далі велика, і межа проходить рівно там, де читачеві доводиться гадати.

виразоцінкачому
Гроші + Грошідоречносума грошей — очевидна операція
Каталог + Каталогдоречнообʼєднання, як у списків
Видання + Виданняніщо це має означати? назви склеїти?
Читач + Книгані«видати книжку» — це метод видати()
Запит >> Базанізсув бітів у ролі стрілочки — ребус

Правило-орієнтир: оператор має означати те саме, що й для вбудованих типів. Якщо в тебе + — це «додати», а не «приєднати, зареєструвати й записати в журнал», усе гаразд. Якщо ж дію доводиться пояснювати в коментарі поруч — це був не оператор, а звичайний метод, у якого відібрали імʼя.

07 / Істинність__len__ та істинність

У темі 11 ми з'ясували, що if працює не лише з True і False: порожній список хибний, непорожній істинний, нуль хибний. Настав час побачити, звідки ця поведінка береться, — і виявиться, що це теж протокол.

Коли Python має вирішити, істинний обʼєкт чи ні, він іде по трьох сходинках і зупиняється на першій, яка спрацювала:

  1. Є __bool__? Клич його — і результат мусить бути True або False.
  2. Немає? Тоді є __len__? Клич його — і вважай обʼєкт істинним, якщо довжина не нуль.
  3. Немає ні того, ні того? Обʼєкт істинний. Завжди.

Третій пункт — саме та пастка, через яку варто взагалі знати цей розділ. Порожній каталог, у якому нема жодної позиції, у перевірці if каталог: поводиться як істинний — і код спокійно йде в гілку «є що показати». Помилки немає, вивід безглуздий.

Інтерактив 5 · Три сходинки істинності

Повзунком міняй кількість позицій. Дивись, яку сходинку Python пройде й у яку гілку піде if.

len(каталог)
bool(каталог)
Постав повзунок на нуль і пройди всі три варіанти. Без жодного методу порожній каталог істинний; із __len__ — хибний, як список; із __bool__, що завжди повертає True, — знову істинний, бо перша сходинка перебиває другу. Останній варіант іноді потрібен свідомо, але частіше це чиясь давня помилка.

Практичний висновок простий: у контейнерному класі майже завжди досить __len__ — він одразу дає і len(), і правильну істинність. Окремий __bool__ потрібен рідко: коли довжини не існує (зʼєднання, налаштування, результат перевірки) або коли рахувати довжину дорого, а відповісти «є щось чи ні» дешево.

08 / КонтейнерСвій обʼєкт у for та in

У темі 12 ми домовились, що for не рахує індекси, а просить у обʼєкта ітератор. Тепер можна побачити цю домовленість з іншого боку — з боку того, у кого просять.

Для перебору є два протоколи, старий і новий, і Python досі підтримує обидва.

старий протокол · __getitem__

Якщо в класі є __getitem__, for починає питати елементи за номерами: нульовий, перший, другий — доки метод не кине IndexError. Цей виняток тут не помилка, а сигнал «усе». Отже, один метод __getitem__ дає одразу і каталог[0], і for, і in, і list(каталог).

новий протокол · __iter__

__iter__ повертає ітератор — обʼєкт, який уміє __next__ і сам памʼятає, де зупинився. Найпростіша реалізація — віддати ітератор внутрішнього списку: return iter(self.позиції). Цей шлях правильніший, бо працює для того, у чого немає номерів: множини, словника, читання файла рядок за рядком.

окремо · __contains__

Без нього in усе одно працює — перебором усіх елементів і порівнянням. __contains__ потрібен, коли ти вмієш відповісти швидше: наприклад, тримаєш усередині множину назв і відповідаєш за одну дію замість тисячі.

Інтерактив 6 · Робимо каталог ітерованим

Три операції над тим самим обʼєктом. Перемикай, що визначено в класі, — і дивись, що робить Python усередині.

операцій працює0 / 3
Порівняй, що діється в правій колонці для другого й третього варіанта. Старий протокол ходить по номерах і зупиняється на IndexError — видно всі пʼять звернень. Новий просить ітератор один раз. Для списку всередині різниці не помітно, але для обʼєкта без номерів працює тільки другий шлях.
Пастка, у яку легко втрапити. Якщо __getitem__ не кидає IndexError на виході за межі, а, скажімо, повертає None, цикл for стане нескінченним: сигналу «усе» не надійде ніколи. Найбезпечніший спосіб — не вигадувати індексацію самому, а передати індекс далі: return self.позиції[індекс]. Список кине IndexError сам, і заразом безкоштовно підтримає зрізи.

09 / withВласний менеджер контексту

У темі 20 я сказав: обʼєкт, який уміє стояти після with, називають менеджером контексту, він знає дві дії — що зробити на вході в блок і що на виході. Тоді ми користувалися готовим менеджером з open(). Тепер напишемо свій, і це буде рівно два методи.

Задача практична: бібліотека веде журнал видач. Файл треба відкрити на дописування, записати кілька рядків і закрити за будь-яких обставин — навіть якщо посеред блоку щось упало.

class ЖурналВидач: def __init__(self, шлях): self.шлях = шлях self.файл = None self.записано = 0 def __enter__(self): self.файл = open(self.шлях, "a", encoding="utf-8") return self # саме це значення отримає імʼя після as def записати(self, видання): self.файл.write(f"{видання}\n") self.записано += 1 def __exit__(self, тип, значення, слід): self.файл.close() self.файл = None return False # виняток летить далі with ЖурналВидач("vydachi.txt") as журнал: журнал.записати(Видання("Кобзар", 1840))

Три деталі, які варто розібрати окремо.

__enter__ повертає те, що потрапить у as. Найчастіше це self — тоді після as ти маєш сам менеджер із усіма його методами. Але не обовʼязково: менеджер може повернути що завгодно, і саме тому open() віддає файловий обʼєкт, а не якийсь службовий.

__exit__ отримує три аргументи про виняток. Якщо блок завершився нормально, усі три — None. Якщо всередині щось упало, у них лежать тип винятку, сам виняток і обʼєкт-слід (traceback). Тому прибирати можна по-різному: підтвердити транзакцію, коли все добре, і скасувати, коли ні.

Повернене значення __exit__ вирішує долю винятку. False (і будь-яке хибне значення, зокрема None — те, що метод повертає без return) означає «прибрав, але не лікував»: виняток летить далі. True означає «я його проковтнув», і програма продовжує роботу так, ніби нічого не сталось. Останнє потрібно дуже рідко: це і є та сама тиша, через яку в темі 18 ми забороняли голий except: pass.

Інтерактив 7 · __enter__ → тіло → __exit__

Крок за кроком по блоку with. Постав галочку — і подивись, що зміниться, коли всередині виняток.

крок1 / 6
файл
Порівняй крок 5 у двох режимах. Без винятку __exit__ отримує три None; з винятком — тип, значення й слід. Але викликається він однаково в обох випадках, і файл закривається обома шляхами. Це і є та гарантія, заради якої with існує.
Коротший шлях для простих випадків. Якщо менеджер потрібен одноразово й уся його робота — «зроби це до, зроби те після», клас можна не писати взагалі. У модулі contextlib є декоратор @contextmanager: пишеш звичайну функцію, ставиш yield посередині, і все, що до yield, стає __enter__, а все після — __exit__. Клас лишається кращим вибором тоді, коли менеджер має ще й власні методи, як наше записати().

10 / callОбʼєкт, який можна викликати

Останній метод на сьогодні — __call__. Він дозволяє поставити дужки після обʼєкта, тобто зробити його схожим на функцію:

class ВиданіПісля: def __init__(self, рік): self.рік = рік self.викликів = 0 # обʼєкт памʼятає між викликами def __call__(self, видання): self.викликів += 1 return видання.рік > self.рік новинки = ВиданіПісля(1900) print(новинки(Видання("Кобзар", 1840))) # False print([в for в in каталог if новинки(в)]) # три видання після 1900 print(новинки.викликів) # 5 — за всі виклики разом

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

Такий обʼєкт можна передати всюди, де чекають функцію: у filter, у sorted(key=...), у map. Вони теж бачать лише протокол — тобто наявність __call__. Зі звичайними функціями все так само: у них цей метод просто вже є.

Найпоширеніший сюжет із __call__. Так улаштовані всі шари нейромереж у PyTorch: шар(вхід) — це виклик обʼєкта, який всередині тримає ваги. Ваги — стан, який має жити між викликами; синтаксис — як у функції. Наступного разу, побачивши модель(дані), ти вже знатимеш, що там немає жодної функції: там обʼєкт із __call__.

11 / МежаМежа розумного

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

Причина в тому, що магія коротшає код і водночас ускладнює читання. Щоб зрозуміти рядок каталог + нові, треба відкрити клас; рядок каталог.обʼєднати(нові) пояснює все на місці. Перший виграє, коли операція очевидна. Другий виграє завжди інакше.

методколи писати
__repr__завжди — це найдешевша інвестиція в налагодження
__eq__ + __hash__коли обʼєкти порівнюють за вмістом; тільки разом
__len__коли всередині є щось, що природно рахується
__iter__коли обʼєкт — набір і його логічно перебирати
__lt__коли в типу є один природний порядок
__enter__ / __exit__коли є ресурс, який треба звільнити напевно
__add__ і компаніяколи операція має загальновідомий сенс
__call__коли обʼєкт — це «функція з памʼяттю»

І три перевірки, які варто прогнати подумки перед тим, як додавати черговий дандер-метод.

  1. Чи зрозуміє це людина, яка не читала мій клас? Якщо ні — звичайний метод із назвою словами кращий.
  2. Чи не суперечить це поведінці вбудованих типів? +, який змінює лівий операнд замість того, щоб повернути новий обʼєкт, — саме така суперечність.
  3. Чи не тягне цей метод за собою контракт? __eq__ тягне __hash__; __lt__ обіцяє, що порядок узгоджений із рівністю; __iter__ обіцяє, що перебір коли-небудь закінчиться.

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

Уся тема тримається на одному реченні: магічний метод — це не магія, а домовленість про імʼя. З нього випливає решта:

Далі — тема 28, dataclass. Подивись іще раз на наше Видання: назва й рік перелічені в ньому чотири рази — в __init__, у __repr__, у __eq__ і в __hash__. Це чотири місця, які треба правити разом, коли зʼявиться третє поле. Наступна тема покаже, як описати поля один раз і отримати всі ці методи згенерованими — з тим самим контрактом рівності й хешу, який ми щойно розібрали руками.

Далі в практиці. У practice.ipynb ти збереш Видання й Каталог із повним набором методів: побачиш власними очима, як len() перетворюється на __len__; зламаєш контракт __eq__/__hash__ і доведеш assertом, що множина справді тримає дублікат; спіймаєш TypeError на сортуванні без __lt__; зробиш каталог ітерованим двома способами; і напишеш ЖурналВидач, який закриває справжній файл навіть тоді, коли всередині блоку кинуто виняток.

Далі в темі

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