# Домашнє завдання · Магічні методи

> Лекція: [lecture.html](lecture.html) · Практика: [practice.ipynb](practice.ipynb) · Тест: [quiz.html](quiz.html)

Працюємо з тим самим каталогом бібліотеки, що й у лекції. Усе роби в окремому файлі
`katalog.py` або в новому зошиті — практику не редагуй, вона знадобиться для звірки.

---

## 🟢 Рівень 1 — База

Збери клас `Видання` з повним набором методів і доведи `assert`ами, що кожен із них
справді викликається.

1. Напиши `Видання(назва, рік, сторінок)` — три поля замість двох.
2. Додай `__repr__` (відтворюваний: `Видання('Кобзар', 1840, 114)`) і `__str__`
   (людський: `Кобзар (1840)`).
3. Додай `__eq__` і `__hash__` **за всіма трьома полями** — обовʼязково разом.
4. Додай `__len__`, який повертає кількість сторінок.
5. Створи три видання, з яких два мають однаковий вміст, і поклади всі три в множину.

**Зроблено, якщо:**

- `assert repr(видання) == "Видання('Кобзар', 1840, 114)"` проходить;
- `assert eval(repr(видання)) == видання` проходить — тобто `__repr__` справді
  відтворює обʼєкт, а `__eq__` це підтверджує;
- `assert len(множина) == 2` проходить — дублікат схлопнувся;
- `assert len(видання) == 114` і `assert bool(видання) is True` проходять;
- є `assert hash(к1) == hash(к2)` для двох рівних видань.

---

## 🟡 Рівень 2 — Плюс

Тепер контейнер і власний менеджер контексту.

1. Напиши клас `Полиця`, який тримає список видань і реалізує `__len__`,
   `__getitem__`, `__iter__`, `__contains__` і `__repr__`.
2. Додай `__add__`: `полиця1 + полиця2` повертає **нову** полицю з усіма позиціями
   обох. Вихідні полиці не змінюються.
3. Додай `__lt__` для `Видання` (порядок за роком, потім за назвою) і метод
   `Полиця.за_роком()`, який повертає нову полицю з відсортованими позиціями.
4. Напиши менеджер контексту `Інвентаризація`, який на вході запамʼятовує
   `len(полиця)`, а на виході друкує, скільки позицій додали чи прибрали за час блоку,
   і записує різницю в атрибут `зміна`.

**Зроблено, якщо:**

- `assert len(полиця1 + полиця2) == len(полиця1) + len(полиця2)` проходить, і одразу
  після нього `assert len(полиця1) == довжина_до` — доказ, що `+` нічого не зіпсував;
- `assert (полиця1 + полиця2) is not полиця1` проходить;
- `assert [в.рік for в in полиця.за_роком()] == sorted(в.рік for в in полиця)` проходить;
- після блоку `with Інвентаризація(полиця) as інв:`, у якому додали дві позиції,
  проходить `assert інв.зміна == 2`;
- є другий блок `with`, усередині якого кинуто виняток, і після нього проходить
  `assert інв2.зміна is not None` — тобто `__exit__` відпрацював попри виняток.

---

## 🔴 Рівень 3 — Виклик

Дослідження меж: що саме ламається, коли контракт порушено, і скільки це коштує.

1. **Зміряй ціну хешу.** Зроби два класи з однаковим `__eq__`: у першого
   `__hash__` рахується з кортежа полів, у другого — `return 1` (формально контракт
   не порушено: рівні обʼєкти дають рівний хеш!). Заведи по 5000 різних видань кожного
   класу, поклади в множину й зміряй `time.perf_counter()` для побудови та для 5000
   перевірок `in`.
2. **Поясни цифри.** Запиши в коментарі, чому другий варіант повільніший і на скільки
   разів, спираючись на розділ про хеш-таблицю з [теми 8](../08-dictionaries/lecture.html):
   що відбувається, коли всі ключі потрапляють в одну комірку.
3. **Зламай обʼєкт зсередини множини.** Візьми клас із коректним `__hash__`, поклади
   обʼєкт у множину, а потім зміни поле, від якого хеш залежить. Покажи `assert`ами,
   що обʼєкт більше не знаходиться в множині — навіть за самим собою, — хоча
   `len(множина)` і далі дорівнює одиниці.
4. **Полагодь.** Переробі клас так, щоб зламати його стало неможливо: поля
   тільки читаються (`property` без сетера або `__slots__` плюс запис у `__init__`
   через `object.__setattr__`), а «зміна» повертає новий обʼєкт методом
   `з_роком(новий_рік)`.

**Зроблено, якщо:**

- зафіксовані обидва заміри з числами, і повільніший варіант програє щонайменше
  вдесятеро на перевірках `in`;
- у коментарі своїми словами пояснено, у що перетворюється хеш-таблиця, коли всі
  ключі дають одну комірку, і яка складність пошуку виходить замість очікуваної;
- проходить `assert обʼєкт not in множина` **після** зміни поля, і поруч
  `assert len(множина) == 1` — обидва одночасно;
- на полагодженому класі проходить `assert видання.з_роком(1841) is not видання` і
  спроба `видання.рік = 1841` кидає виняток (спіймай його `try/except` і надрукуй тип).

---

## Підказки

- **Рівень 1.** Якщо `eval(repr(видання)) == видання` не проходить, дивись не на
  `__eq__`, а на `__repr__`: найчастіше в ньому забувають `!r` біля рядкового поля,
  і назва виходить без лапок — такий вираз просто не є коректним Python.
- **Рівень 2.** `__add__` має повертати `Полиця(список1 + список2)`, а не дописувати
  в `self`. Якщо `len(полиця1)` після додавання змінилась — ти десь написав `.extend`.
- **Рівень 2.** `__exit__` отримує три аргументи; щоб зрозуміти, чи був виняток,
  досить перевірити перший на `is not None`. Повертати з нього `True` не треба —
  інвентаризація не повинна ковтати чужі помилки.
- **Рівень 3.** Хеш `return 1` не порушує контракту, але знищує сенс хешування:
  усі ключі опиняються в одній комірці, і множина вироджується в те, від чого
  хеш-таблиця мала врятувати.
- **Рівень 3.** Найпростіший спосіб зробити поле незмінним — оголосити його
  через `@property` і не писати сетер. Тоді присвоєння кине `AttributeError`
  із зрозумілим текстом.
