Ми написали вже десятки методів і жодного разу не спитали, чому в
__init__ такі дивні підкреслення. У темі 25
я сказав «так домовились», у темі 26 ми
переоформили цю домовленість через super() — і обидва рази лишили питання
відкритим. Час на нього відповісти, бо __init__ не самотній: таких методів
кілька десятків, і кожен відповідає за конкретне вміння обʼєкта. Написав один рядок —
твій клас навчився рахувати довжину. Написав другий — його можна перебирати циклом.
Написав третій — він працює під with.
Наскрізний приклад лишається тим самим, що й у попередній темі: каталог
невеликої бібліотеки. Позиція каталогу — Видання з назвою й роком;
сам Каталог тримає список позицій:
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 x | type(x).__contains__(x, y) | належність |
| for i in x | type(x).__iter__(x) | перебір |
| x + y | type(x).__add__(x, y) | додавання |
| x == y | type(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>. Тепер розберемо пару цілком,
бо методів насправді два, і плутають їх постійно.
Різниця не в оформленні, а в тому, кому адресований рядок:
__repr__— для того, хто налагоджує код. Він має відповісти на питання «що це за обʼєкт і що в ньому лежить». Його бачить консоль, коли ти просто вводиш імʼя; його бачитьrepr(x); його бачать усі контейнери, коли друкують свій вміст.__str__— для того, хто читає вивід програми. Його кличутьprint(x),str(x)і f-рядокf"{x}". Тут доречно писати людською мовою, без лапок і назв класів.
Позначка !r у f-рядку означає «підстав repr, а не
str» — саме тому назва в прикладі виходить у лапках. Дрібниця, а корисна:
за таким рядком одразу видно, що 1840 — число, а "Кобзар" — текст.
Інтерактив 2 · Той самий обʼєкт у чотирьох місцях
Консоль, print, f-рядок і всередині списку. Перемикай варіанти класу — і дивись, який метод спрацював.
__repr__ лагодить усі чотири рядки одразу, бо __str__
за замовчуванням віддає роботу йому. Зворотне неправда: якщо визначити самий
__str__, консоль і список і далі показуватимуть адресу.Звідси правило, яке варто запамʼятати дослівно: якщо робиш один метод —
роби __repr__. Він рятує налагодження, друк списків, повідомлення
assert і виводи pytest із теми 19.
__str__ додавай тоді, коли обʼєкт справді показують кінцевому користувачу
й «технічний» вигляд там недоречний.
__repr__ має бути відтворюваним.
Ідеал, до якого варто прагнути, — рядок, який можна скопіювати назад у код і отримати
такий самий обʼєкт: Видання('Кобзар', 1840). Тоді eval(repr(x))
дає x, і це дуже зручно в консолі й у логах. Коли відтворити неможливо
(усередині відкрите зʼєднання, файл, потік), домовленість інша: пиши в кутових дужках,
щоб було видно, що це опис, а не вираз — <ЖурналВидач vydachi.txt, відкрито>.
Обидві форми зустрінеш у стандартній бібліотеці.03 / РівністьРівність за вмістом
У темі 25 ми бачили неприємність: два продажі з абсолютно однаковими даними давали
p == r → False. Причина тепер зрозуміла. У object
є свій __eq__, і він відповідає на питання «це той самий обʼєкт?»,
тобто працює як is. Для каталогу це майже завжди не те, що потрібно: два
записи про «Кобзар» 1840 року — це одне видання, хоч і два обʼєкти в памʼяті.
Два рядки, і обидва варті пояснення.
Порівнюємо кортеж із кортежем. Це той самий прийом, що в
темі 7: кортежі порівнюються поелементно, тож
один рядок замінює self.назва == інший.назва and self.рік == інший.рік.
Коли полів пʼять, різниця в читабельності стає відчутною.
NotImplemented — не помилка, а ввічлива відмова. Це
особливе значення, яким метод каже: «я не знаю, як порівняти себе з цим».
Отримавши його, Python не здається: він пробує запитати з іншого боку —
інший.__eq__(self). І лише коли обидва відмовились, повертає
False для ==. Якби ми замість NotImplemented
одразу повернули False, ми б відібрали в чужого класу право сказати,
що він дорівнює нашому виданню. Найчастіше це нікому не заважає — але саме через
такі дрібниці бібліотеки перестають дружити між собою.
NotImplemented і
NotImplementedError. Перше — значення, яке повертають; воно означає
«спробуй інакше». Друге — виняток, який кидають, і означає він «метод має бути
реалізований у нащадку». Імена схожі майже дослівно, а поведінка протилежна: перше
дає Python шанс упоратись, друге зупиняє програму.04 / КонтрактКонтракт із __hash__
А тепер найважливіші пʼятнадцять хвилин теми. Щойно ти визначив
__eq__, ти взяв на себе зобовʼязання, про яке ніхто не попереджає вголос:
Це те саме залізне правило, яким закінчувалась тема 8.
Нагадаю, звідки воно береться. Множина й словник шукають елемент не перебором, а за
адресою: беруть hash(ключ), ділять на розмір таблиці, беруть остачу — і
йдуть одразу в потрібну комірку. Рівність перевіряється вже всередині комірки,
щоб розрізнити ключі, які випадково збіглися адресами.
Тому наслідок прямий: якщо два рівні обʼєкти дають різні хеші, вони опиняються в різних комірках і ніколи не зустрінуться. Множина не помітить дубліката, словник не знайде ключа — і жодної помилки при цьому не буде.
Розробники Python цю пастку знали й поставили запобіжник: клас, у якому
визначено __eq__, автоматично стає нехешованим — його
__hash__ дорівнює None. Спроба покласти такий обʼєкт у
множину падає з тим самим TypeError: unhashable type, що й
hash([1, 2]) у темі 8. Груба, зате чесна поведінка: краще впасти одразу,
ніж мовчки загубити дані.
Рецепт короткий: склади кортеж із тих самих полів, за якими порівнюєш, і
віддай його вбудованому hash. Кортеж уміє хешуватись сам, якщо
хешовані всі його елементи, — і робить це узгоджено з власною рівністю. Тобто контракт
виконується автоматично, і вигадувати нічого не треба.
Інтерактив 3 · Що буде, коли контракт порушено
Два обʼєкти з однаковим вмістом: к1 і к2. Дивись, куди вони лягають у таблиці й що каже множина.
__hash__ = object.__hash__, щоб «прибрати
TypeError». Обʼєкти рівні, хеші різні — множина тримає дублікат,
словник не знаходить ключа, і жодного повідомлення про помилку. Хеші тут
навчальні, справжні hash() дають 19-значні числа.к1.рік = 1841. Хеш обʼєкта
змінився, а лежить він у старій комірці — тепер множина не знайде його навіть за ним
самим. Це та сама причина, через яку в Python нехешовані списки. Тому чесний хід
такий: або клас незмінний (поля тільки читають, а зміна створює новий обʼєкт), або
__hash__ не пиши взагалі. Порожня форма __hash__ = None
явно закриває тему й читається як навмисне рішення, а не як забудькуватість.05 / ПорядокПорядок і сортування
Наступне, чого зазвичай хочуть від каталогу, — упорядкувати його за роком. І тут
несподіванка: sorted(каталог) падає з TypeError, хоча
обʼєкти начебто цілком порівнянні.
Причина в тому, що < — теж протокол, і за замовчуванням його немає.
Порівняння в Python розкладається на чотири методи: __lt__ (менше),
__gt__ (більше), __le__ (не більше), __ge__
(не менше). Але писати всі чотири не треба — досить одного:
Чому цього вистачає для сортування: sorted, min,
max і list.sort усередині користуються лише
операцією «менше». А a > b Python уміє вивести сам — це просто
b < a, тобто дзеркальний виклик b.__lt__(a). Але
<= так не виводиться: «не менше» не є запереченням «менше» в загальному
випадку, і Python не ризикує вгадувати.
Якщо потрібні всі чотири порівняння, є готовий інструмент:
total_ordering — декоратор зі стандартної бібліотеки. Йому потрібні
__eq__ і один метод порядку, а решту він добудує за
логікою: a <= b означає a < b or a == b і так далі.
Плата за це — трохи повільніше, бо кожне порівняння перетворюється на два виклики.
Для каталогу з тисячі позицій це непомітно; для мільйона краще написати всі чотири руками.
Інтерактив 4 · Що вміє клас без __lt__ і з ним
Шість звичних операцій. Перемикай варіант — і дивись, які працюють, а які падають.
__lt__
оживляє пʼять операцій із шести: > Python виводить дзеркально, а
sorted, min і max нічого, крім «менше», і не
просять. Не працює тільки <= — і саме на ньому найчастіше й спотикаються.Порада наостанок: якщо порядок потрібен один раз, __lt__ не потрібен
взагалі — достатньо sorted(каталог, key=lambda в: в.рік).
__lt__ заводять тоді, коли в типу є один природний порядок,
з яким погодяться всі: дати, версії, гроші.
06 / АрифметикаАрифметика й зловживання
Бібліотека не тільки видає книжки — вона їх купує, а отже, рахує гроші. Рахувати
гроші звичайним float не можна: з теми 4
ми знаємо, що 0.1 + 0.2 дає 0.30000000000000004. Класичний
вихід — тримати суму в копійках цілим числом. І ось тут арифметичні магічні методи
доречні як ніде:
Тепер Гроші(24000) + Гроші(15050) дає 390,50 грн, а
Гроші(24000) * 3 — 720,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 має вирішити, істинний обʼєкт чи ні, він іде по трьох сходинках і зупиняється на першій, яка спрацювала:
- Є
__bool__? Клич його — і результат мусить бутиTrueабоFalse. - Немає? Тоді є
__len__? Клич його — і вважай обʼєкт істинним, якщо довжина не нуль. - Немає ні того, ні того? Обʼєкт істинний. Завжди.
Третій пункт — саме та пастка, через яку варто взагалі знати цей розділ. Порожній
каталог, у якому нема жодної позиції, у перевірці if каталог: поводиться
як істинний — і код спокійно йде в гілку «є що показати». Помилки немає, вивід
безглуздий.
Інтерактив 5 · Три сходинки істинності
Повзунком міняй кількість позицій. Дивись, яку сходинку Python пройде й у яку гілку піде if.
__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 усередині.
IndexError —
видно всі пʼять звернень. Новий просить ітератор один раз. Для списку всередині
різниці не помітно, але для обʼєкта без номерів працює тільки другий шлях.__getitem__
не кидає IndexError на виході за межі, а, скажімо, повертає
None, цикл for стане нескінченним: сигналу
«усе» не надійде ніколи. Найбезпечніший спосіб — не вигадувати індексацію самому, а
передати індекс далі: return self.позиції[індекс]. Список кине
IndexError сам, і заразом безкоштовно підтримає
зрізи.09 / withВласний менеджер контексту
У темі 20 я сказав: обʼєкт, який уміє
стояти після with, називають менеджером контексту, він знає дві дії —
що зробити на вході в блок і що на виході. Тоді ми користувалися готовим менеджером
з open(). Тепер напишемо свій, і це буде рівно два методи.
Задача практична: бібліотека веде журнал видач. Файл треба відкрити на дописування, записати кілька рядків і закрити за будь-яких обставин — навіть якщо посеред блоку щось упало.
Три деталі, які варто розібрати окремо.
__enter__ повертає те, що потрапить у as.
Найчастіше це self — тоді після as ти маєш сам менеджер із
усіма його методами. Але не обовʼязково: менеджер може повернути що завгодно, і саме
тому open() віддає файловий обʼєкт, а не якийсь службовий.
__exit__ отримує три аргументи про виняток. Якщо блок
завершився нормально, усі три — None. Якщо всередині щось упало, у них
лежать тип винятку, сам виняток і обʼєкт-слід (traceback). Тому прибирати можна
по-різному: підтвердити транзакцію, коли все добре, і скасувати, коли ні.
Повернене значення __exit__ вирішує долю винятку.
False (і будь-яке хибне значення, зокрема None — те, що метод
повертає без return) означає «прибрав, але не лікував»: виняток летить далі.
True означає «я його проковтнув», і програма продовжує
роботу так, ніби нічого не сталось. Останнє потрібно дуже рідко: це і є та сама тиша,
через яку в темі 18 ми забороняли голий
except: pass.
Інтерактив 7 · __enter__ → тіло → __exit__
Крок за кроком по блоку with. Постав галочку — і подивись, що зміниться, коли всередині виняток.
__exit__
отримує три None; з винятком — тип, значення й слід. Але викликається
він однаково в обох випадках, і файл закривається обома шляхами. Це і є та
гарантія, заради якої with існує.contextlib є декоратор
@contextmanager: пишеш звичайну функцію, ставиш yield
посередині, і все, що до yield, стає __enter__, а все після —
__exit__. Клас лишається кращим вибором тоді, коли менеджер має ще й
власні методи, як наше записати().10 / callОбʼєкт, який можна викликати
Останній метод на сьогодні — __call__. Він дозволяє поставити дужки
після обʼєкта, тобто зробити його схожим на функцію:
Питання, яке тут виникає першим: навіщо, якщо є звичайна функція? Відповідь у
третьому рядку виводу. Функція нічого не памʼятає між викликами, а обʼєкт
памʼятає. Наш новинки — це «функція з налаштуванням і
лічильником»: поріг заданий один раз при створенні, статистика накопичується сама.
Такий обʼєкт можна передати всюди, де чекають функцію: у filter, у
sorted(key=...), у map. Вони теж бачать лише протокол — тобто
наявність __call__. Зі звичайними функціями все так само: у них цей метод
просто вже є.
__call__. Так улаштовані
всі шари нейромереж у PyTorch: шар(вхід) — це виклик обʼєкта, який
всередині тримає ваги. Ваги — стан, який має жити між викликами; синтаксис — як у
функції. Наступного разу, побачивши модель(дані), ти вже знатимеш, що
там немає жодної функції: там обʼєкт із __call__.11 / МежаМежа розумного
Тепер, коли інструмент у руках, найголовніше: магічних методів має бути рівно стільки, скільки потрібно, щоб обʼєкт поводився як вбудований тип, — і жодного більше.
Причина в тому, що магія коротшає код і водночас ускладнює читання. Щоб зрозуміти
рядок каталог + нові, треба відкрити клас; рядок
каталог.обʼєднати(нові) пояснює все на місці. Перший виграє, коли операція
очевидна. Другий виграє завжди інакше.
| метод | коли писати |
|---|---|
| __repr__ | завжди — це найдешевша інвестиція в налагодження |
| __eq__ + __hash__ | коли обʼєкти порівнюють за вмістом; тільки разом |
| __len__ | коли всередині є щось, що природно рахується |
| __iter__ | коли обʼєкт — набір і його логічно перебирати |
| __lt__ | коли в типу є один природний порядок |
| __enter__ / __exit__ | коли є ресурс, який треба звільнити напевно |
| __add__ і компанія | коли операція має загальновідомий сенс |
| __call__ | коли обʼєкт — це «функція з памʼяттю» |
І три перевірки, які варто прогнати подумки перед тим, як додавати черговий дандер-метод.
- Чи зрозуміє це людина, яка не читала мій клас? Якщо ні — звичайний метод із назвою словами кращий.
- Чи не суперечить це поведінці вбудованих типів?
+, який змінює лівий операнд замість того, щоб повернути новий обʼєкт, — саме така суперечність. - Чи не тягне цей метод за собою контракт?
__eq__тягне__hash__;__lt__обіцяє, що порядок узгоджений із рівністю;__iter__обіцяє, що перебір коли-небудь закінчиться.
12 / ПідсумокЩо забрати з теми
Уся тема тримається на одному реченні: магічний метод — це не магія, а домовленість про імʼя. З нього випливає решта:
- Оператор — це виклик методу.
len(x)— цеtype(x).__len__(x). Пошук іде по класу, не по примірнику, і по тому самому__mro__, який ми розбирали в темі 26. - Робиш один метод — роби
__repr__. Він рятує консоль, друк списків і повідомлення тестів.__str__додається тоді, коли обʼєкт бачить кінцевий користувач. __eq__і__hash__— нерозлучні. Рівні обʼєкти зобовʼязані мати рівні хеші, інакше множина тримає дублікати, а словник не знаходить ключа — і все це мовчки. Найпростіший чесний хеш: кортеж із тих самих полів, за якими порівнюєш.- Одного
__lt__досить для сортування.sorted,minіmaxпросять лише «менше»;>Python виводить дзеркально, а<=— ні. Усі чотири порівняння дописує@total_ordering. - Істинність перевіряється трьома сходинками:
__bool__, потім__len__, потім «істинний за замовчуванням». Останнє й робить порожній контейнер без__len__тихою пасткою. - Перебір дає
__iter__, а__getitem__дає його задарма — через старий протокол із зупинкою наIndexError.__contains__потрібен лише тоді, коли ти вмієш відповісти швидше за перебір. __enter__віддає значення вas, а__exit__викликається завжди — і саме він вирішує, летіти винятку далі чи ні.- Межа розумного одна: роби обʼєкт схожим на вбудований тип — і зупиняйся. Кожен зайвий оператор економить рядок і додає читачеві питання.
Далі — тема 28, dataclass. Подивись іще раз на наше
Видання: назва й рік перелічені в ньому чотири рази — в
__init__, у __repr__, у __eq__ і в
__hash__. Це чотири місця, які треба правити разом, коли зʼявиться третє
поле. Наступна тема покаже, як описати поля один раз і отримати всі ці методи згенерованими —
з тим самим контрактом рівності й хешу, який ми щойно розібрали руками.
practice.ipynb ти збереш
Видання й Каталог із повним набором методів: побачиш власними
очима, як len() перетворюється на __len__; зламаєш контракт
__eq__/__hash__ і доведеш assertом, що множина
справді тримає дублікат; спіймаєш TypeError на сортуванні без
__lt__; зробиш каталог ітерованим двома способами; і напишеш
ЖурналВидач, який закриває справжній файл навіть тоді, коли всередині
блоку кинуто виняток.Далі в темі
Теорію прочитано. Тепер закріпи її на практиці.