У темі 25 ми навчилися описувати
власні типи: class, __init__, self, атрибути
примірника проти атрибутів класу — і закінчили на конкретному питанні. У журналі
трапляються не лише продажі, а й повернення: майже ті самі поля, майже ті самі методи,
але сума зі знаком мінус і перевірки трохи інші. Копіювати весь клас і правити три
рядки — очевидно погана ідея. Ця тема про два способи цього не робити. Перший —
наслідування, його знають усі. Другий — композиція,
і саме він у більшості випадків правильний, хоч у підручниках стоїть аж у кінці розділу.
Наскрізний приклад теми — не журнал продажів, а каталог невеликої бібліотеки, і причина конкретна: у каталозі є те, чого на парі «продаж — повернення» не побачиш. Спершу наслідування виглядатиме бездоганно, а потім ми додамо одну звичайну вимогу — і воно розсиплеться просто на очах.
01 / ДублюванняТри майже однакові класи
Почнемо з коду, який ще нічого не наслідує. Бібліотека видає читачам книги й журнали. Обидва мають назву, рік і вміють коротко себе описати:
Три рядки в __init__ повторюються дослівно. Поки їх три — не страшно.
Але каталог живе роками: завтра до кожного видання додасться інвентарний номер, післязавтра —
дата надходження, а ще пізніше — метод вік(), який рахує, скільки років
виданню. І щоразу правку доведеться вносити у два місця. Не «незручно» — саме
у два місця, а це означає, що рано чи пізно хтось виправить одне й забуде
друге.
Чому це гірше, ніж просто зайві рядки:
- Правка розповзається. Одна зміна вимоги — кілька місць у коді, і жоден інструмент не підкаже, що ти пропустив одне.
- Класи розходяться непомітно. У
Книгахтось додав перевірку року, уЖурнал— ні. Тепер вони начебто однакові, а поводяться по-різному. - Спільну поведінку неможливо назвати. Немає жодного місця в коді, де було б написано: «усе, що видає бібліотека, має назву й рік».
Наслідування (inheritance) розвʼязує саме це: воно дає спільній частині одне імʼя й одне місце.
02 / ІєрархіяБазовий і похідний клас
Виносимо спільне у клас Видання, а Книга й Журнал
оголошуємо його різновидами. Синтаксис — назва батьківського класу в дужках після імені:
Слово pass тут означає «тіло порожнє»: Книга поки що не додає
нічого свого. І цього вже достатньо, щоб вона працювала:
Ми не писали в Книга ані __init__, ані опис, ані
вік — а вони є. Назви для сторін відносини стандартні й вживаються як
синоніми:
| як кажуть | про що йдеться | у прикладі |
|---|---|---|
| базовий клас | той, від кого беруть; ще кажуть «батьківський», «суперклас» | Видання |
| похідний клас | той, хто бере; ще «дочірній», «підклас», «нащадок» | Книга |
| ієрархія | усе дерево класів разом | Видання → Книга → Аудіокнига |
Важливе уточнення про те, що саме успадковується. Успадковуються методи, а не
дані. У класі Видання немає атрибута назва — він
зʼявляється в конкретному обʼєкті в момент, коли виконується рядок
self.назва = назва. Успадкований __init__ — це просто метод, який
запускається на нашому обʼєкті й записує атрибути в нього. Плутанина тут коштує дорого:
новачки часто думають, ніби нащадок «отримує копію» батьківських полів. Ніякої копії
немає — є один обʼєкт і спільний код, що його наповнює.
Тепер додамо третій рівень — аудіокнигу, яка є різновидом книги:
Про super() буде окремий розділ; поки що читай його як «те саме, але з
класу вище». А спершу — головне питання теми.
03 / ПошукДе Python шукає метод
Коли ти пишеш аудіокнига.опис(), Python не знає наперед, у якому класі цей
метод лежить. Він шукає — і робить це рівно так само, як шукав імена в
темі 15 за правилом LEGB: іде по фіксованому
списку місць і зупиняється на першому ж, де знайшов.
Список місць називається MRO (method resolution order — порядок розвʼязання методів). Побачити його можна прямо:
Чотири класи, чіткий порядок: сам клас, потім батько, потім дід, потім
object — корінь, від якого походять геть усі класи Python, навіть якщо ти
нічого не писав у дужках. Пошук атрибута йде цим кортежем зліва направо. Не знайшов ніде —
AttributeError, повний аналог NameError з теми 15.
Інтерактив 1 · Де знайшовся метод
Познач, у яких класах метод визначено, — і дивись, на якому кроці пошук зупинився.
__init__ пошук усе одно щось
знайде — на рівні object, — але той __init__ не приймає
аргументів, тож Аудіокнига("Солодка Даруся", …) впаде з
TypeError. Для опис на рівні object немає
нічого, і виходить AttributeError. Це та сама пара випадків, що
sum і підсумок у темі 15.Два наслідки, які варто зафіксувати одразу.
Перший знайдений виграє, решта не розглядається. Python не збирає всі
версії методу й не обирає «кращу». Якщо Аудіокнига має свій
опис, версії з Книга й Видання для неї просто не
існує — доки ти не покличеш їх явно.
Пошук іде по класу обʼєкта, а не по тексту, де стоїть виклик. Функція,
що приймає видання й кличе видання.опис(), отримає різний
результат для книги й для журналу — і при цьому в її власному коді немає жодного
if. Це і є поліморфізм (polymorphism): один рядок виклику,
різна поведінка залежно від того, що прийшло.
обʼєкт.__dict__: власний атрибут
примірника завжди перекриває однойменний атрибут класу. Тому self.розмір = 5
в __init__ тихо затіняє розмір = 0, оголошений у тілі класу, —
рівно як локальне імʼя затіняє глобальне.04 / ЗамінаПеревизначення
Оголосити в похідному класі метод з тим самим імʼям, що в базовому, називається
перевизначенням (overriding). Ніякого спеціального синтаксису для цього
немає — просто пишеш def опис(self), і пошук зупиняється на ньому раніше, ніж
дійде до батьківського.
Тут є розвилка, яку новачки часто проходять не в той бік. Перевизначити метод можна двома способами:
- Замінити — написати тіло з нуля, ніби батьківського методу немає.
- Розширити — покликати батьківський через
super()і додати до його результату своє.
Різниця здається косметичною, поки формат опису не змінюється. Подивись, що буде, коли зміниться:
Інтерактив 2 · Заміна чи розширення
Той самий метод опис у трьох класах. Обери обʼєкт — і подивись, чий код виконався й що вийшло.
super() кожен клас складає рядок сам —
і в аудіокниги зникають автор і рік, бо їх ніхто не додав. Головне ж не це: уяви, що
завтра формат у Видання зміниться з Назва (2004) на
Назва, 2004. З галочкою ця зміна долетить до всіх трьох класів,
без неї — нікуди.Практичне правило: якщо твій метод має зробити те саме плюс ще щось —
клич super(). Заміна без виклику батьківського виправдана лише тоді,
коли поведінка справді інша, а не «схожа, але з дрібницею».
__init__. Найпоширеніша
помилка новачка в цій темі — написати в похідному класі свій __init__ і не
покликати в ньому батьківський. Помилки в цю мить не буде: обʼєкт створиться, програма
піде далі. Впаде вона пізніше й в іншому місці — на першому ж зверненні до атрибута, який
мав записати батьківський __init__. Далі ми подивимось на це покроково.05 / supersuper() і його ланцюжок
Виклик super().__init__(...) означає: «знайди __init__ у
наступному класі списку MRO і виконай його на цьому ж обʼєкті». Ключове слово тут —
наступному: не «в батьківському», а саме в наступному, і в наступному
розділі ця різниця стане принциповою.
Коли класів три, виклики шикуються в ланцюжок: Аудіокнига.__init__ кличе
Книга.__init__, та кличе Видання.__init__. Кожна ланка записує
свою частину атрибутів, і обʼєкт наповнюється знизу вгору й назад. Пройди цей ланцюжок
кроками — і зніми галочку, щоб побачити класичну поломку:
Інтерактив 3 · super().__init__() крок за кроком
Ліворуч — які виклики зараз відкриті. Праворуч — що вже записано в обʼєкт.
назва й рік. Падіння станеться потім, у
опис(), і traceback покаже рядок усередині
Видання.опис — за сотні рядків від справжньої причини.Звідси проста звичка, яку варто завести назавжди: перший рядок
__init__ у похідному класі — це super().__init__(...).
Спершу нехай базовий клас доведе обʼєкт до робочого стану, а потім уже додавай своє.
Виняток — коли своє потрібно порахувати до виклику базового, і тоді це варто прокоментувати
прямо в коді.
06 / Чому саме superЧому не Батько.метод(self)
Той самий результат начебто дає й прямий виклик — узяти метод з класу й передати
self руками:
Це не помилка, і в дуже старому коді таке трапляється. Але причин писати
super() три, і кожна серйозна.
У ланцюжку з трьох класів імʼя Видання зʼявляється двічі: у дужках
заголовка й у тілі методу. Зміниш ієрархію — вставиш проміжний клас, перейменуєш
базовий — і тобі доведеться згадати про всі такі рядки в усіх методах. Забудеш один —
отримаєш обʼєкт, який частково ініціалізований по-старому. super() імені
не називає взагалі: він щоразу дивиться в актуальний MRO.
self передається руками
Видання.__init__(self, назва, рік) — це виклик звичайної функції, де
перший аргумент подаєш ти. Забути self дуже легко, і повідомлення про
помилку буде плутане: Python поскаржиться, що рядок "Солодка Даруся" не
має атрибута назва. У super().__init__(назва, рік) обʼєкт
підставляється автоматично — забути нічого.
Це не питання зручності. super() йде наступним у MRO,
а Батько.метод(self) — точно у названий клас. Коли базовий клас один,
це те саме. Коли їх два — ні, і різниця видно неозброєним оком у наступному розділі.
super() без аргументів узагалі працює.
До Python 3 писали super(Книга, self) — обидва аргументи явно. Порожня форма
не «здогадується» ні про що: компілятор бачить виклик super() у тілі методу й
тихо додає до функції приховану комірку __class__ із класом, у якому цей метод
записано. Саме звідти береться перший аргумент, а другий — це self із першого
параметра. Побічний наслідок: super() без аргументів не працює поза тілом
методу — наприклад, у вкладеній функції, куди не потрапляє self.07 / РомбМножинне наслідування й ромб
Python дозволяє вказати в дужках кілька базових класів. Це називається множинним наслідуванням, і в більшості мов його немає саме через задачу, яку ми зараз розберемо.
Візьмімо іншу, ненавчальну ситуацію: бібліотека купила техніку. Є Пристрій,
від нього походять Сканер і Принтер, а багатофункціональний
пристрій — БФП — є і тим, і тим:
Схема звʼязків утворює ромб: від БФП угору йдуть два шляхи, і обидва
сходяться в Пристрій. Звідси й назва — ромбоподібна проблема
(diamond problem). Питання просте: коли ми кличемо бфп.запустити(), скільки
разів має надрукуватися слово «Пристрій»?
Інтерактив 4 · Ромб і __mro__
Помінявши порядок базових класів у дужках, ти міняєш MRO — і порядок викликів.
super() покличе свій
базовий прямо: Сканер.запустити(self) і Принтер.запустити(self).
«Пристрій» надрукується двічі. У навчальному прикладі це просто зайвий рядок; у
справжньому коді це двічі списані гроші або двічі відкрите зʼєднання.Тепер видно, навіщо MRO влаштований саме так. Він не «дерево», по якому Python ходить
угору. Він — плаский список без повторів, побудований за трьома правилами:
клас іде раніше за своїх батьків; базові класи йдуть у тому порядку, як записані в дужках;
кожен клас у списку рівно один раз. Алгоритм, який це рахує, називається C3-лінеаризацією,
і його головна властивість якраз у тому, що super(), ідучи цим списком, зайде
в кожен клас ромба точно один раз.
Зверни увагу на найдивніше місце інтерактиву: у Сканер.запустити написано
super().запустити(), а виконується Принтер.запустити — хоча
Принтер не є базовим для Сканер взагалі. Це не баг:
super() означає «наступний у MRO цього обʼєкта», а не «мій батько».
Який саме клас виявиться наступним, залежить від класу обʼєкта, на якому метод викликали,
тобто визначається не там, де метод написано.
__init__ зі своїми аргументами, узгодити майже неможливо:
щоб ланцюжок super() не розсипався, усі класи ієрархії мають бути
написані під нього — приймати **kwargs і передавати їх далі. Якщо хоча б один
клас у ланцюжку про це не знає, ланцюжок обривається мовчки.08 / КомпозиціяКомпозиція замість наслідування
Це головний розділ теми. Різниця між двома способами повторного використання коду формулюється двома короткими фразами:
композиція — «Книга має всередині Носій»
Композиція (composition) — це коли обʼєкт не успадковує поведінку, а
тримає інший обʼєкт у своєму атрибуті й звертається до нього. Жодного нового синтаксису:
self.носій = носій — ось і вся композиція.
Тепер обіцяний приклад, де наслідування спершу здається доречним. Каталог має розрізняти, на чому видання існує: папір, аудіо, файл. Природний перший крок — три підкласи:
Виглядає бездоганно. Три класи, кожен зі своїм як_видати(), усе на місці.
А потім приходить бібліотекарка й каже: «ще треба позначати мову — українська чи
англійська». І ще: «а подарункові видання видаємо лише в залі». Кожна вимога — новий
незалежний вимір, і класи починають множитися:
Інтерактив 5 · Наслідування проти композиції
Додавай вимоги по одній. Ліворуч — скільки класів доведеться написати, праворуч — скільки при композиції.
Кількість класів — навіть не найгірше. Найгірше те, що у наслідуванні комбінація фіксується в момент написання коду. Клас обʼєкта не змінюється: паперова книга не може стати електронною, її можна тільки викинути й створити іншу — з іншим інвентарним номером і втраченою історією видач. У композиції це один рядок:
Метод як_видати у Книга нічого не робить сам — він передає
роботу тому, хто в цьому розуміється. Це називають делегуванням
(delegation), і воно є основною роботою композиції.
Як обрати між ними
Перевірка проста й називається «тест на є»: підстав обидва класи у фразу «A є різновидом B» і послухай, чи не звучить вона дивно.
| фраза | звучить? | висновок |
|---|---|---|
| Книга є Виданням | так | наслідування |
| Книга є Папером | ні, книга має носій | композиція |
| Замовлення є Списком | ні, воно містить список | композиція |
| Автомобіль є Двигуном | ні, він має двигун | композиція |
Другий орієнтир — практичний. Наслідування доречне, коли похідний клас
підходить скрізь, де очікують базовий: жоден чужий код, що працює з
Видання, не має зламатися, отримавши Книга. Якщо ж нащадок
забороняє щось із того, що вміє базовий («у нас книга без року»), або перевизначає метод
так, що той повертає геть інше, — це вже не різновид, і краще не наслідувати.
S — підтип T, то
обʼєкти T у програмі можна замінити на обʼєкти S без зміни
правильності. Хрестоматійний контрприклад: Квадрат(Прямокутник). Математично
квадрат — окремий випадок прямокутника, а програмно ні: код, який чесно ставить ширину 3,
висоту 4 й очікує площу 12, на квадраті отримає 16, бо квадрат мусив підтягнути другу
сторону. Класи описують не предмети, а поведінку, і геометрія тут не порадник.09 / КачкаКачина типізація й isinstance
Якщо композиція розводить класи по різних гілках, постає питання: як тоді написати функцію, що працює і з книгою, і з журналом, і з настільною грою? Перша ідея — перевіряти тип:
isinstance(обʼєкт, Клас) відповідає «так», якщо клас обʼєкта — це
Клас або будь-який його нащадок. Тобто питання звучить так:
«чи є ти різновидом ось цього?». Але функції насправді байдуже, ким є аргумент, — їй
потрібно лише, щоб у нього був метод опис().
Підхід, у якому питають не про рід, а про вміння, називають качиною типізацією (duck typing): якщо щось ходить як качка й крякає як качка, для нашої задачі воно й є качка. У Python це стиль за замовчуванням — просто клич метод:
Інтерактив 6 · Питаємо про рід чи про вміння
Чотири обʼєкти, дві перевірки. Обери рядок — і подивись, кого який спосіб пропустить.
НастільнаГра не має до
Видання жодного стосунку, але вміє рівно те, що потрібно. Перевірка через
isinstance її не пропустить — і функція стане непридатною для половини
каталогу без жодної технічної причини.Це не означає, що isinstance — зло. Він доречний там, де тобі справді
потрібен саме тип: розгалуження за типом вхідних даних (рядок чи список), перевірка
аргументу на межі бібліотеки, обробка різних гілок у except. Але як спосіб
«пустити чи не пустити» він майже завжди зайвий і завжди звужує код.
type(x) == Клас проти isinstance.
Перше порівняння відповідає «ні» для будь-якого нащадка й тому ламає наслідування —
функція, написана для Видання, не прийме Книга. Якщо перевірка
типу справді потрібна, бери isinstance. А для «чи вміє» є прямий інструмент:
hasattr(обʼєкт, "опис"). Утім, у Python звичніше просто покликати метод і, якщо
треба, спіймати AttributeError — про це була
тема 18.10 / abcАбстрактний базовий клас
У качиної типізації є слабке місце: якщо хтось напише клас і забуде метод
опис, ніхто не помітить цього до першого виклику — можливо, вже в роботі.
Модуль abc зі стандартної бібліотеки дозволяє сказати прямо: «цей метод
зобовʼязані реалізувати всі нащадки».
ABC (abstract base class — абстрактний базовий клас) — це клас, який не
призначений для створення обʼєктів; він лише описує, що мають уміти нащадки. Позначений
@abstractmethod метод перетворює клас на незавершений: створити обʼєкт такого
класу Python не дасть, доки метод не реалізовано.
Інтерактив 7 · Коли саме помітять помилку
Три класи, один із них забув реалізувати опис. Дивись, на якому етапі це спливе.
abc клас без
опис створює обʼєкти без жодних заперечень, і помилка чекає до першого
виклику. З abc вона зʼявляється на два кроки раніше — у момент
Афіша(...), з повідомленням, у якому прямо названо і клас, і метод.Головна цінність abc — не «строгість» заради строгості, а
перенесення помилки ближче до причини. Це та сама ідея, що й у
темі 19: чим раніше зловлено, тим дешевше
виправити.
Брати abc варто, коли базовий клас справді описує контракт для кількох
незалежних реалізацій — наприклад, наші носії Папір, Аудіо,
Файл усі мусять уміти як_видати(). Для пари класів, які пише одна
людина в одному файлі, це зайва церемонія.
11 / ЦінаЦіна наслідування
Розділ для тих, хто вже писав класи й бачив, як ієрархія виходить з-під контролю. Наслідування коштує дорожче, ніж здається, і ось із чого складається його ціна.
Це найсильніший вид звʼязності, який є в мові. Похідний клас залежить не від опублікованого інтерфейсу базового, а від його внутрішнього устрою: які атрибути той заводить, які методи кличе всередині себе, у якому порядку. Автор базового класу міняє приватну деталь — і ламає нащадків, яких він ніколи не бачив. Це явище має власну назву: проблема крихкого базового класу.
Глибина не додається, а множиться. Щоб зрозуміти метод у класі на пʼятому рівні ієрархії, треба тримати в голові пʼять класів одночасно й памʼятати, який із них щось перевизначив. Читання коду перетворюється на стрибки вгору-вниз по файлах. Робочий орієнтир простий: два рівні — нормально, три — привід задуматись, чотири й більше — майже завжди помилка проєктування.
Ієрархію дуже важко переробити. Композицію можна переставити майже
безкарно: замінив один обʼєкт у полі — і все. Ієрархія ж зашита в заголовки класів, і будь-яка
її зміна зачіпає всі методи, що кличуть super(), усі перевірки
isinstance і весь код, що покладався на порядок MRO.
І наслідування «навпаки» — теж помилка. Спокуса успадкувати клас лише
заради того, щоб дістатися його готових методів (успадкувати list, щоб мати
append), закінчується погано: разом із потрібним методом нащадок отримує весь
інтерфейс базового класу, включно з тим, що для нього безглуздо. Твоє
Замовлення(list) раптом можна відсортувати, порізати зрізом і помножити на
три. Правильний хід — покласти список у поле.
12 / ПідсумокЩо забрати з теми
Наслідування й композиція розвʼязують одну задачу — не писати те саме двічі — але платять за це різну ціну.
- Похідний клас отримує методи базового, а не копію його даних.
Атрибути зʼявляються в обʼєкті тоді, коли якийсь
__init__їх запише. - Метод шукається по
__mro__зліва направо й береться перший знайдений — точно так само, як імʼя шукається по LEGB. Не знайшли ніде —AttributeError. - Перевизначення без
super()замінює поведінку, зsuper()— розширює. Забутийsuper().__init__(...)— класична пастка: обʼєкт створюється, а падає значно пізніше й в іншому місці. super()— це «наступний у MRO», а не «мій батько». Тому він не дублює імені базового класу, сам підставляєselfі в ромбі заходить у кожен клас рівно один раз, тоді як прямі викликиБатько.метод(self)виконують спільного предка двічі.- Композиція — «має всередині»: обʼєкт лежить у полі, а робота делегується йому. Вона не множить класи на кожній новій вимозі й дозволяє змінити поведінку під час роботи програми, а не тільки під час написання коду.
- Питай про вміння, а не про рід. Прямий виклик методу приймає все, що
вміє потрібне;
isinstanceлишай для випадків, коли важливий саме тип.abcпотрібен, щоб забутий метод спливав при створенні обʼєкта, а не посеред роботи.
Далі — тема 27, магічні методи. Ми вже двічі стикалися з
__init__ і жодного разу не спитали, чому в нього такі дивні підкреслення. Там
зʼясується, що len(x), x + y, x == y і навіть
print(x) — це виклики методів із такими самими іменами, і що написати свій
клас, який поводиться як вбудований тип, — питання пʼяти рядків. А тема 28 покаже, як
dataclass прибирає з наших класів увесь одноманітний __init__
разом узятий.
practice.ipynb ти збудуєш ієрархію
Видання → Книга → Аудіокнига й доведеш через assert, що
super() справді збирає опис із трьох частин; спеціально забудеш
super().__init__() і зловиш AttributeError; надрукуєш
__mro__ і перевіриш, що порядок саме такий; перепишеш ту саму ієрархію на
композицію та зміниш носій книги під час роботи програми; і напишеш функцію, яка працює з
трьома обʼєктами різних класів без жодного isinstance.Далі в темі
Теорію прочитано. Тепер закріпи її на практиці.