Практика · Магічні методи¶

Лекція: lecture.html · Тест: quiz.html · ДЗ: homework.md

Наскрізний приклад той самий, що в лекції, — каталог невеликої бібліотеки. Тут ми руками зробимо все, про що йшлося:

  1. переконаємось, що len(x) — це справді виклик type(x).__len__(x), а не магія;
  2. навчимо Видання показувати себе через __repr__ і __str__;
  3. додамо __eq__ — і зламаємо контракт із __hash__, щоб побачити дублікат у множині власними очима, а потім полагодимо;
  4. спробуємо відсортувати каталог без __lt__ і з ним;
  5. зробимо Каталог таким, що знає свою довжину, істинність, індекс і перебір;
  6. напишемо ЖурналВидач — власний менеджер контексту, який закриває справжній файл навіть тоді, коли всередині блоку кинуто виняток;
  7. і наприкінці звіримо свій клас із вбудованими типами — переконаємось, що всередині списку й кортежа немає нічого, чого ми щойно не написали самі.

Нічого, крім стандартної бібліотеки, тут не потрібно.

1 · Протокол: len(x) — це виклик методу¶

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

In [1]:
спис = [10, 20, 30]

print("len(спис)                =", len(спис))
print("спис.__len__()           =", спис.__len__())
print("type(спис).__len__(спис) =", type(спис).__len__(спис))

# три записи мають бути буквально одним і тим самим викликом
assert len(спис) == спис.__len__() == type(спис).__len__(спис), "це не той самий виклик!"
print("✅ len() — це коротка форма запису type(x).__len__(x)")
len(спис)                = 3
спис.__len__()           = 3
type(спис).__len__(спис) = 3
✅ len() — це коротка форма запису type(x).__len__(x)

2 · Клас без жодного магічного методу¶

Тепер наш Видання — поки що тільки з __init__. Створимо два обʼєкти з однаковим вмістом і подивимось, як Python їх показує й порівнює.

In [2]:
class Видання:
    def __init__(self, назва, рік):
        self.назва = назва
        self.рік = рік


к1 = Видання("Кобзар", 1840)
к2 = Видання("Кобзар", 1840)          # той самий вміст, інший обʼєкт

print("repr(к1) =", repr(к1))
print("str(к1)  =", str(к1))
print("к1 == к2 =", к1 == к2)
print("к1 is к2 =", к1 is к2)

# без __eq__ рівність означає те саме, що тотожність: «це той самий обʼєкт?»
assert (к1 == к2) is False, "без __eq__ два різні обʼєкти не мають бути рівні"
print("✅ за замовчуванням == працює як is")
repr(к1) = <__main__.Видання object at 0x7fc2c3489fd0>
str(к1)  = <__main__.Видання object at 0x7fc2c3489fd0>
к1 == к2 = False
к1 is к2 = False
✅ за замовчуванням == працює як is

3 · __repr__ і __str__¶

Додаємо два методи показу. Далі перевіримо чотири звичайні місця виводу й побачимо, де спрацьовує який: консоль і список беруть __repr__, а print і f-рядок — __str__.

In [3]:
class Видання:
    def __init__(self, назва, рік):
        self.назва = назва
        self.рік = рік

    def __repr__(self):
        # !r підставляє repr назви, тому вона виходить у лапках — видно, що це текст
        return f"Видання({self.назва!r}, {self.рік})"

    def __str__(self):
        return f"{self.назва} ({self.рік})"


к1 = Видання("Кобзар", 1840)

print("repr(к1)      →", repr(к1))
print("str(к1)       →", str(к1))
print("f-рядок       →", f"Позиція: {к1}")
print("усередині списку →", [к1])

assert repr(к1) == "Видання('Кобзар', 1840)", "__repr__ має бути схожий на виклик конструктора"
assert str(к1) == "Кобзар (1840)", "__str__ — рядок для людини"
print("✅ два методи — два адресати")
repr(к1)      → Видання('Кобзар', 1840)
str(к1)       → Кобзар (1840)
f-рядок       → Позиція: Кобзар (1840)
усередині списку → [Видання('Кобзар', 1840)]
✅ два методи — два адресати

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

eval виконує рядок як вираз Python. У справжніх програмах його майже не використовують (це дірка в безпеці, якщо рядок прийшов ззовні), але тут він робить рівно те, що треба, — доводить, що наш __repr__ не збрехав.

In [4]:
відтворене = eval(repr(к1))          # той самий вираз, що ми надрукували

print("було:      ", repr(к1))
print("відтворено:", repr(відтворене))
print("це різні обʼєкти:", відтворене is not к1)

assert repr(відтворене) == repr(к1), "repr має відтворювати обʼєкт"
print("✅ repr відтворюваний")
було:       Видання('Кобзар', 1840)
відтворено: Видання('Кобзар', 1840)
це різні обʼєкти: True
✅ repr відтворюваний

4 · __eq__ — рівність за вмістом¶

Тепер навчимо Видання порівнюватись за назвою й роком. Порівнюємо кортеж із кортежем: один рядок замість двох перевірок через and, і легко дописати третє поле.

NotImplemented у першій гілці — це ввічлива відмова «я не знаю, як порівняти себе з цим». Отримавши її, Python спитає з іншого боку, і лише потім поверне False.

In [5]:
class ВиданняТількиРівність:
    def __init__(self, назва, рік):
        self.назва = назва
        self.рік = рік

    def __repr__(self):
        return f"Видання({self.назва!r}, {self.рік})"

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


а = ВиданняТількиРівність("Кобзар", 1840)
б = ВиданняТількиРівність("Кобзар", 1840)
в = ВиданняТількиРівність("Місто", 1928)

print("а == б (той самий вміст) =", а == б)
print("а == в (інший вміст)     =", а == в)
print("а == 'Кобзар'            =", а == "Кобзар")

assert а == б, "однаковий вміст — однакові видання"
assert а != в, "різний вміст — різні видання"
print("✅ рівність тепер за вмістом")
а == б (той самий вміст) = True
а == в (інший вміст)     = False
а == 'Кобзар'            = False
✅ рівність тепер за вмістом

І одразу наслідок, про який ніхто не попереджає¶

Щойно ми визначили __eq__, Python автоматично поставив нашому класу __hash__ = None. Це запобіжник: клас із власною рівністю більше не можна класти в множину чи брати як ключ словника, доки ти не скажеш, як рахувати хеш.

Наступна клітинка навмисно падає — подивись на текст помилки, він знайомий із теми 8 про словники.

In [6]:
print("__hash__ нашого класу:", ВиданняТількиРівність.__hash__)

множина = {а, б}      # ← ось тут і впаде
print("сюди ми не дійдемо:", множина)
__hash__ нашого класу: None
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[6], line 3
      1 print("__hash__ нашого класу:", ВиданняТількиРівність.__hash__)
----> 3 множина = {а, б}      # ← ось тут і впаде
      4 print("сюди ми не дійдемо:", множина)

TypeError: unhashable type: 'ВиданняТількиРівність'

5 · Порушений контракт — головна вправа теми¶

Найпоширеніший «ремонт» цієї помилки — повернути класу хеш від object: __hash__ = object.__hash__. Помилка справді зникає. Але хеш від object рахується з адреси обʼєкта, а рівність ми рахуємо з вмісту.

Виходить, що два рівні обʼєкти мають різні хеші, тобто лягають у різні комірки хеш-таблиці й ніколи не зустрічаються. Перевіримо, чим це закінчується.

In [7]:
class ВиданняЗламане:
    def __init__(self, назва, рік):
        self.назва = назва
        self.рік = рік

    def __repr__(self):
        return f"Видання({self.назва!r}, {self.рік})"

    def __eq__(self, інший):
        if not isinstance(інший, ВиданняЗламане):
            return NotImplemented
        return (self.назва, self.рік) == (інший.назва, інший.рік)

    # «полагодили» TypeError — і цим зламали контракт: хеш рахується з адреси
    __hash__ = object.__hash__


з1 = ВиданняЗламане("Кобзар", 1840)
з2 = ВиданняЗламане("Кобзар", 1840)

print("з1 == з2      :", з1 == з2)
print("hash однакові :", hash(з1) == hash(з2))
print("множина       :", {з1, з2})
print("розмір множини:", len({з1, з2}))

# ось те, заради чого вся вправа: рівні обʼєкти, а в множині їх двоє
assert з1 == з2, "обʼєкти рівні"
assert len({з1, з2}) == 2, "порушений контракт має давати дублікат у множині"
print("✅ контракт порушено: два РІВНІ обʼєкти лежать у множині окремо")
з1 == з2      : True
hash однакові : False
множина       : {Видання('Кобзар', 1840), Видання('Кобзар', 1840)}
розмір множини: 2
✅ контракт порушено: два РІВНІ обʼєкти лежать у множині окремо

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

In [8]:
ціни = {з1: "240,00 грн"}

print("ключ з1 у словнику:", з1 in ціни)
print("ключ з2 у словнику:", з2 in ціни)     # хоча з2 == з1
print("а рівні вони?     :", з1 == з2)

assert з1 in ціни, "той самий обʼєкт знаходиться завжди"
assert з2 not in ціни, "рівний обʼєкт з іншим хешем словник не знайде"
print("⚠️  словник не знайшов ключа, який дорівнює наявному — і жодної помилки")
ключ з1 у словнику: True
ключ з2 у словнику: False
а рівні вони?     : True
⚠️  словник не знайшов ключа, який дорівнює наявному — і жодної помилки

6 · Контракт виконано¶

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

In [9]:
class Видання:
    def __init__(self, назва, рік):
        self.назва = назва
        self.рік = рік

    def __repr__(self):
        return f"Видання({self.назва!r}, {self.рік})"

    def __str__(self):
        return f"{self.назва} ({self.рік})"

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

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


к1 = Видання("Кобзар", 1840)
к2 = Видання("Кобзар", 1840)
ціни = {к1: "240,00 грн"}

print("к1 == к2       :", к1 == к2)
print("хеші однакові  :", hash(к1) == hash(к2))
print("множина        :", {к1, к2})
print("ціни[к2]       :", ціни[к2])

assert hash(к1) == hash(к2), "рівні обʼєкти зобовʼязані мати рівні хеші"
assert len({к1, к2}) == 1, "дубліката бути не має"
assert ціни[к2] == "240,00 грн", "словник має знаходити ключ за вмістом"
print("✅ контракт виконано: множина без дублікатів, словник шукає за вмістом")
к1 == к2       : True
хеші однакові  : True
множина        : {Видання('Кобзар', 1840)}
ціни[к2]       : 240,00 грн
✅ контракт виконано: множина без дублікатів, словник шукає за вмістом

7 · Порядок: без __lt__ і з ним¶

Заведемо чотири видання в довільному порядку й спробуємо їх відсортувати. Наступна клітинка навмисно падає: у класі немає жодного методу порівняння, і Python чесно каже, якої саме операції бракує.

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

print("позицій у каталозі:", len(позиції))
print(sorted(позиції))        # ← ось тут і впаде
позицій у каталозі: 4
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[10], line 9
      1 позиції = [
      2     Видання("Тигролови", 1944),
      3     Видання("Кобзар", 1840),
      4     Видання("Місто", 1928),
      5     Видання("Лісова пісня", 1911),
      6 ]
      8 print("позицій у каталозі:", len(позиції))
----> 9 print(sorted(позиції))        # ← ось тут і впаде

TypeError: '<' not supported between instances of 'Видання' and 'Видання'

Додаємо __lt__. Одного цього методу вистачає одразу для чотирьох речей: <, sorted, min і max. А > Python виведе дзеркально — це просто b < a.

In [11]:
class Видання(Видання):          # той самий клас, лише дописуємо порядок
    def __lt__(self, інший):
        if not isinstance(інший, Видання):
            return NotImplemented
        # рік головний, назва — щоб порядок був однозначний у межах одного року
        return (self.рік, self.назва) < (інший.рік, інший.назва)


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

for в in за_роком:
    print(f"  {в.рік}  {в.назва}")
print("найстаріше:", min(позиції))
print("найновіше :", max(позиції))
print("Тигролови > Кобзар:", позиції[0] > позиції[1])

роки = [в.рік for в in за_роком]
assert роки == sorted(роки), "після sorted роки мають іти за зростанням"
assert min(позиції).назва == "Кобзар", "найстаріше видання — Кобзар"
assert max(позиції).назва == "Тигролови", "найновіше — Тигролови"
print("✅ один __lt__ дав сортування, min, max і навіть >")
  1840  Кобзар
  1911  Лісова пісня
  1928  Місто
  1944  Тигролови
найстаріше: Кобзар (1840)
найновіше : Тигролови (1944)
Тигролови > Кобзар: True
✅ один __lt__ дав сортування, min, max і навіть >

А ось <= з __lt__ не виводиться: «не менше» не є запереченням «менше» в загальному випадку, і Python не вгадує. Клітинка навмисно падає.

In [12]:
print("порівнюємо:", позиції[1], "і", позиції[0])
print(позиції[1] <= позиції[0])      # ← ось тут і впаде
порівнюємо: Кобзар (1840) і Тигролови (1944)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[12], line 2
      1 print("порівнюємо:", позиції[1], "і", позиції[0])
----> 2 print(позиції[1] <= позиції[0])      # ← ось тут і впаде

TypeError: '<=' not supported between instances of 'Видання' and 'Видання'

Якщо потрібні всі чотири порівняння, їх дописує декоратор total_ordering зі стандартної бібліотеки. Йому потрібні __eq__ і один метод порядку.

In [13]:
from functools import total_ordering


@total_ordering
class ВиданняПовне(Видання):
    pass          # __eq__ і __lt__ уже успадковані, решту допише декоратор


а = ВиданняПовне("Кобзар", 1840)
б = ВиданняПовне("Місто", 1928)

print("а <  б :", а < б)
print("а <= б :", а <= б)
print("а >= б :", а >= б)
print("а >  б :", а > б)

assert (а <= б) is True and (а >= б) is False, "total_ordering має дописати обидва"
print("✅ @total_ordering дописав __le__, __ge__ і __gt__")
а <  б : True
а <= б : True
а >= б : False
а >  б : False
✅ @total_ordering дописав __le__, __ge__ і __gt__

8 · Каталог: довжина, істинність, індекс, перебір¶

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

In [14]:
class Каталог:
    def __init__(self, позиції):
        self.позиції = list(позиції)

    def __repr__(self):
        return f"Каталог({self.позиції!r})"

    def __str__(self):
        return f"каталог, позицій: {len(self.позиції)}"

    def __len__(self):
        return len(self.позиції)


каталог = Каталог(позиції)
порожній = Каталог([])

print("len(каталог)   :", len(каталог))
print("bool(каталог)  :", bool(каталог))
print("len(порожній)  :", len(порожній))
print("bool(порожній) :", bool(порожній))

assert len(каталог) == 4, "у каталозі чотири позиції"
assert bool(порожній) is False, "порожній контейнер має бути хибним"
print("✅ один __len__ дав і довжину, і істинність")
len(каталог)   : 4
bool(каталог)  : True
len(порожній)  : 0
bool(порожній) : False
✅ один __len__ дав і довжину, і істинність

Тепер перебір. Спершу перевіримо, що зараз каталог перебрати не можна — клітинка навмисно падає.

In [15]:
print("у каталозі позицій:", len(каталог))
for видання in каталог:      # ← ось тут і впаде
    print(видання)
у каталозі позицій: 4
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[15], line 2
      1 print("у каталозі позицій:", len(каталог))
----> 2 for видання in каталог:      # ← ось тут і впаде
      3     print(видання)

TypeError: 'Каталог' object is not iterable

Дописуємо три методи контейнера. __getitem__ передає індекс далі, у внутрішній список, — тому IndexError на виході за межі кине сам список, і нам не треба про це дбати. __iter__ віддає готовий ітератор списку, а __contains__ робить in явним і швидшим.

In [16]:
class Каталог(Каталог):          # доповнюємо той самий клас
    def __getitem__(self, індекс):
        return self.позиції[індекс]        # список сам кине IndexError, коли треба

    def __iter__(self):
        return iter(self.позиції)

    def __contains__(self, видання):
        return видання in self.позиції


каталог = Каталог(позиції)

print("каталог[0]      :", каталог[0])
print("каталог[-1]     :", каталог[-1])
print("зріз каталог[:2]:", каталог[:2])
print("перебір циклом  :")
for видання in каталог:
    print("   ", видання)
print('Видання("Кобзар", 1840) in каталог:', Видання("Кобзар", 1840) in каталог)

assert list(каталог) == позиції, "перебір має віддавати ті самі позиції"
assert Видання("Кобзар", 1840) in каталог, "in шукає за вмістом, бо є __eq__"
assert Видання("Аенеїда", 1798) not in каталог, "чужого видання в каталозі немає"
print("✅ каталог поводиться як контейнер")
каталог[0]      : Тигролови (1944)
каталог[-1]     : Лісова пісня (1911)
зріз каталог[:2]: [Видання('Тигролови', 1944), Видання('Кобзар', 1840)]
перебір циклом  :
    Тигролови (1944)
    Кобзар (1840)
    Місто (1928)
    Лісова пісня (1911)
Видання("Кобзар", 1840) in каталог: True
✅ каталог поводиться як контейнер

9 · Арифметика там, де вона доречна: Гроші¶

Бібліотека купує книжки, а гроші не можна тримати в float — з теми 4 ми знаємо, чому 0.1 + 0.2 не дорівнює 0.3. Тримаємо суму в копійках цілим числом.

Зверни увагу: __add__ повертає новий обʼєкт, а не змінює себе, — так поводяться всі числа в Python. А __radd__ потрібен, щоб працював sum(): він починає з нуля, і перше додавання виглядає як 0 + Гроші(...).

In [17]:
class Гроші:
    def __init__(self, копійки):
        self.копійки = копійки

    def __repr__(self):
        return f"Гроші({self.копійки})"

    def __str__(self):
        return f"{self.копійки // 100},{self.копійки % 100:02d} грн"

    def __eq__(self, інший):
        if not isinstance(інший, Гроші):
            return NotImplemented
        return self.копійки == інший.копійки

    def __hash__(self):
        return hash(self.копійки)

    def __add__(self, інший):
        if not isinstance(інший, Гроші):
            return NotImplemented
        return Гроші(self.копійки + інший.копійки)

    def __mul__(self, множник):
        if not isinstance(множник, int):
            return NotImplemented          # на гроші гроші не множать
        return Гроші(self.копійки * множник)

    def __radd__(self, інший):
        # sum() починає з нуля: перший крок — це 0 + Гроші(...)
        if інший == 0:
            return self
        return NotImplemented


закупівля = [Гроші(24000), Гроші(15050), Гроші(9990)]

print("перша + друга :", закупівля[0] + закупівля[1])
print("перша × 3     :", закупівля[0] * 3)
print("sum(закупівля):", sum(закупівля))

assert закупівля[0] + закупівля[1] == Гроші(39050), "додавання рахує копійки"
assert закупівля[0] * 3 == Гроші(72000), "множення на ціле число"
assert sum(закупівля) == Гроші(49040), "sum працює завдяки __radd__"
print("✅ арифметика працює й не втрачає копійок")
перша + друга : 390,50 грн
перша × 3     : 720,00 грн
sum(закупівля): 490,40 грн
✅ арифметика працює й не втрачає копійок

10 · Власний менеджер контексту¶

Обіцянка з теми 20: обʼєкт, який уміє стояти після with, — це два методи. __enter__ віддає те, що потрапить у as, а __exit__ викликається завжди: і коли блок дійшов до кінця, і коли всередині кинуто виняток.

Писатимемо в тимчасову теку, щоб не смітити поруч із зошитом.

In [18]:
import pathlib
import tempfile

тека = pathlib.Path(tempfile.mkdtemp())
шлях_журналу = тека / "vydachi.txt"


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.вихід_з_винятком = тип is not None
        return False              # виняток летить далі: прибираємо, а не лікуємо


with ЖурналВидач(шлях_журналу) as журнал:
    журнал.записати(Видання("Кобзар", 1840))
    журнал.записати(Видання("Лісова пісня", 1911))

print("файл закрито      :", журнал.файл.closed)
print("вийшли з винятком :", журнал.вихід_з_винятком)
print("вміст файла       :", шлях_журналу.read_text(encoding="utf-8").split("\n")[:-1])

assert журнал.файл.closed, "__exit__ мусив закрити файл"
assert журнал.вихід_з_винятком is False, "блок завершився без винятку"
assert журнал.записано == 2, "два записи"
print("✅ звичайний вихід із блоку")
файл закрито      : True
вийшли з винятком : False
вміст файла       : ['Кобзар (1840)', 'Лісова пісня (1911)']
✅ звичайний вихід із блоку

А тепер найцікавіше: кидаємо виняток посеред блоку. Файл усе одно має бути закритий, а перший запис — долетіти на диск. Виняток ловимо звичайним try/except навколо with, як ми домовлялись у темі 18.

In [19]:
шлях_другий = тека / "vydachi-2.txt"
спійманий = None

try:
    with ЖурналВидач(шлях_другий) as журнал2:
        журнал2.записати(Видання("Місто", 1928))
        журнал2.записати(None)          # None не має атрибута назва — впаде
        журнал2.записати(Видання("Тигролови", 1944))   # сюди вже не дійдемо
except AttributeError as помилка:
    спійманий = помилка

print("спіймали виняток  :", type(спійманий).__name__)
print("файл закрито      :", журнал2.файл.closed)
print("вийшли з винятком :", журнал2.вихід_з_винятком)
print("рядків на диску   :", len(шлях_другий.read_text(encoding="utf-8").splitlines()))

assert спійманий is not None, "виняток мав вилетіти назовні: __exit__ повернув False"
assert журнал2.файл.closed, "файл мусить бути закритий навіть після винятку"
assert журнал2.вихід_з_винятком is True, "__exit__ отримав тип винятку, а не None"
assert len(шлях_другий.read_text(encoding="utf-8").splitlines()) == 1, "один запис долетів"
print("✅ __exit__ спрацював попри виняток — це і є гарантія with")
спіймали виняток  : AttributeError
файл закрито      : True
вийшли з винятком : True
рядків на диску   : 1
✅ __exit__ спрацював попри виняток — це і є гарантія with

Виняток AttributeError виник тому, що None не має атрибута назва, — його шукає f-рядок усередині записати. Головне тут інше: менеджер відпрацював обидва рази однаково, і саме тому файловий дескриптор не тече.

11 · __call__: обʼєкт, який можна викликати¶

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

In [20]:
class ВиданіПісля:
    def __init__(self, рік):
        self.рік = рік
        self.викликів = 0

    def __call__(self, видання):
        self.викликів += 1
        return видання.рік > self.рік


новинки = ВиданіПісля(1900)

print("Кобзар новинка? :", новинки(Видання("Кобзар", 1840)))
print("після фільтра   :", [str(в) for в in filter(новинки, каталог)])
print("разів покликали :", новинки.викликів)

assert callable(новинки), "обʼєкт із __call__ вважається викликуваним"
assert новинки.викликів == 5, "один прямий виклик плюс чотири від filter"
print("✅ обʼєкт спрацював там, де чекали функцію, і порахував себе сам")
Кобзар новинка? : False
після фільтра   : ['Тигролови (1944)', 'Місто (1928)', 'Лісова пісня (1911)']
разів покликали : 5
✅ обʼєкт спрацював там, де чекали функцію, і порахував себе сам

12 · Звіряємось із вбудованими типами¶

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

In [21]:
звичайний_список = list(позиції)

print(f"{'перевірка':<26} {'наш Каталог':<16} список")
пари = [
    ("len", len(каталог), len(звичайний_список)),
    ("bool", bool(каталог), bool(звичайний_список)),
    ("перший елемент", каталог[0], звичайний_список[0]),
    ("останній елемент", каталог[-1], звичайний_список[-1]),
    ("in (Кобзар)", Видання("Кобзар", 1840) in каталог,
     Видання("Кобзар", 1840) in звичайний_список),
    ("довжина перебору", len(list(каталог)), len(list(звичайний_список))),
]
for назва, наше, вбудоване in пари:
    print(f"{назва:<26} {str(наше):<16} {вбудоване}")
    assert наше == вбудоване, f"розійшлись на перевірці «{назва}»!"

print("✅ наш контейнер не відрізняється від списку в жодній із шести перевірок")
перевірка                  наш Каталог      список
len                        4                4
bool                       True             True
перший елемент             Тигролови (1944) Тигролови (1944)
останній елемент           Лісова пісня (1911) Лісова пісня (1911)
in (Кобзар)                True             True
довжина перебору           4                4
✅ наш контейнер не відрізняється від списку в жодній із шести перевірок
In [22]:
# те саме для елемента: рівність, хеш і порядок мають збігатися з кортежем
наші = [Видання("Кобзар", 1840), Видання("Кобзар", 1840), Видання("Місто", 1928)]
кортежі = [(1840, "Кобзар"), (1840, "Кобзар"), (1928, "Місто")]

наші_ключі = sorted((в.рік, в.назва) for в in set(наші))
кортежі_ключі = sorted(set(кортежі))

print("унікальних видань:", len(set(наші)))
print("унікальних кортежів:", len(set(кортежі)))
print("наші після dedup   :", наші_ключі)
print("кортежі після dedup:", кортежі_ключі)

assert len(set(наші)) == len(set(кортежі)), "множина має схлопнути дублікат так само"
assert наші_ключі == кортежі_ключі, "порядок сортування має збігатися з кортежами"
print("✅ наш __eq__ + __hash__ + __lt__ поводяться як у кортежа")
унікальних видань: 2
унікальних кортежів: 2
наші після dedup   : [(1840, 'Кобзар'), (1928, 'Місто')]
кортежі після dedup: [(1840, 'Кобзар'), (1928, 'Місто')]
✅ наш __eq__ + __hash__ + __lt__ поводяться як у кортежа

13 · Завдання¶

Роби в цьому ж зошиті, нижче. Розгорнутіші варіанти цих завдань — у homework.md.

🟢 Рівень 1. Додай класу Видання метод __len__, який повертає довжину назви, і перевір assertом, що len(Видання("Кобзар", 1840)) == 6. Потім подумай і напиши в коментарі одним реченням, чому це погана ідея (підказка: що тепер означає if видання: для видання з порожньою назвою?).

🟡 Рівень 2. Напиши клас Полиця, який тримає видання й уміє +: полиця1 + полиця2 має повертати нову полицю з усіма позиціями обох, не змінюючи жодну з вихідних. Доведи assertами три речі: результат містить усі позиції, довжини вихідних полиць не змінились, і полиця1 is not результат.

🔴 Рівень 3. Напиши менеджер контексту ТихийЖурнал, який проковтує AttributeError (повертає True з __exit__), а всі інші винятки пропускає далі. Доведи assertами обидві поведінки: після AttributeError код після блоку with виконується, а після ZeroDivisionError — ні. І запиши в коментарі, чому такий менеджер небезпечний.

In [23]:
# ↓ тут твій код


print("готово")
готово