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

Хто від кого походить

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

У темі 25 ми навчилися описувати власні типи: class, __init__, self, атрибути примірника проти атрибутів класу — і закінчили на конкретному питанні. У журналі трапляються не лише продажі, а й повернення: майже ті самі поля, майже ті самі методи, але сума зі знаком мінус і перевірки трохи інші. Копіювати весь клас і правити три рядки — очевидно погана ідея. Ця тема про два способи цього не робити. Перший — наслідування, його знають усі. Другий — композиція, і саме він у більшості випадків правильний, хоч у підручниках стоїть аж у кінці розділу.

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

01 / ДублюванняТри майже однакові класи

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

class Книга: def __init__(self, назва, рік, автор): self.назва = назва self.рік = рік self.автор = автор def опис(self): return f"{self.автор}. {self.назва} ({self.рік})" class Журнал: def __init__(self, назва, рік, номер): self.назва = назва self.рік = рік self.номер = номер def опис(self): return f"{self.назва}, №{self.номер} ({self.рік})"

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

Чому це гірше, ніж просто зайві рядки:

Наслідування (inheritance) розвʼязує саме це: воно дає спільній частині одне імʼя й одне місце.

02 / ІєрархіяБазовий і похідний клас

Виносимо спільне у клас Видання, а Книга й Журнал оголошуємо його різновидами. Синтаксис — назва батьківського класу в дужках після імені:

class Видання: def __init__(self, назва, рік): self.назва = назва self.рік = рік def опис(self): return f"{self.назва} ({self.рік})" def вік(self, цей_рік=2026): return цей_рік - self.рік class Книга(Видання): # Книга — різновид Видання pass

Слово pass тут означає «тіло порожнє»: Книга поки що не додає нічого свого. І цього вже достатньо, щоб вона працювала:

>>> книга = Книга("Солодка Даруся", 2004) >>> книга.опис() 'Солодка Даруся (2004)' >>> книга.вік() 22

Ми не писали в Книга ані __init__, ані опис, ані вік — а вони є. Назви для сторін відносини стандартні й вживаються як синоніми:

як кажутьпро що йдетьсяу прикладі
базовий кластой, від кого беруть; ще кажуть «батьківський», «суперклас»Видання
похідний кластой, хто бере; ще «дочірній», «підклас», «нащадок»Книга
ієрархіяусе дерево класів разомВидання → Книга → Аудіокнига

Важливе уточнення про те, що саме успадковується. Успадковуються методи, а не дані. У класі Видання немає атрибута назва — він зʼявляється в конкретному обʼєкті в момент, коли виконується рядок self.назва = назва. Успадкований __init__ — це просто метод, який запускається на нашому обʼєкті й записує атрибути в нього. Плутанина тут коштує дорого: новачки часто думають, ніби нащадок «отримує копію» батьківських полів. Ніякої копії немає — є один обʼєкт і спільний код, що його наповнює.

Тепер додамо третій рівень — аудіокнигу, яка є різновидом книги:

class Книга(Видання): def __init__(self, назва, рік, автор): super().__init__(назва, рік) # хай базовий запише назву й рік self.автор = автор def опис(self): return f"{self.автор}. {super().опис()}" class Аудіокнига(Книга): def __init__(self, назва, рік, автор, читець): super().__init__(назва, рік, автор) self.читець = читець def опис(self): return f"{super().опис()} — читає {self.читець}"

Про super() буде окремий розділ; поки що читай його як «те саме, але з класу вище». А спершу — головне питання теми.

03 / ПошукДе Python шукає метод

Коли ти пишеш аудіокнига.опис(), Python не знає наперед, у якому класі цей метод лежить. Він шукає — і робить це рівно так само, як шукав імена в темі 15 за правилом LEGB: іде по фіксованому списку місць і зупиняється на першому ж, де знайшов.

Список місць називається MRO (method resolution order — порядок розвʼязання методів). Побачити його можна прямо:

>>> Аудіокнига.__mro__ (<class 'Аудіокнига'>, <class 'Книга'>, <class 'Видання'>, <class 'object'>)

Чотири класи, чіткий порядок: сам клас, потім батько, потім дід, потім object — корінь, від якого походять геть усі класи Python, навіть якщо ти нічого не писав у дужках. Пошук атрибута йде цим кортежем зліва направо. Не знайшов ніде — AttributeError, повний аналог NameError з теми 15.

Інтерактив 1 · Де знайшовся метод

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

Зніми всі три галочки. Для __init__ пошук усе одно щось знайде — на рівні object, — але той __init__ не приймає аргументів, тож Аудіокнига("Солодка Даруся", …) впаде з TypeError. Для опис на рівні object немає нічого, і виходить AttributeError. Це та сама пара випадків, що sum і підсумок у темі 15.

Два наслідки, які варто зафіксувати одразу.

Перший знайдений виграє, решта не розглядається. Python не збирає всі версії методу й не обирає «кращу». Якщо Аудіокнига має свій опис, версії з Книга й Видання для неї просто не існує — доки ти не покличеш їх явно.

Пошук іде по класу обʼєкта, а не по тексту, де стоїть виклик. Функція, що приймає видання й кличе видання.опис(), отримає різний результат для книги й для журналу — і при цьому в її власному коді немає жодного if. Це і є поліморфізм (polymorphism): один рядок виклику, різна поведінка залежно від того, що прийшло.

Для профі: MRO існує не лише для методів. Тим самим кортежем шукаються геть усі атрибути класу — константи, поля-лічильники, вкладені класи. І пошук у класах відбувається після перевірки обʼєкт.__dict__: власний атрибут примірника завжди перекриває однойменний атрибут класу. Тому self.розмір = 5 в __init__ тихо затіняє розмір = 0, оголошений у тілі класу, — рівно як локальне імʼя затіняє глобальне.

04 / ЗамінаПеревизначення

Оголосити в похідному класі метод з тим самим імʼям, що в базовому, називається перевизначенням (overriding). Ніякого спеціального синтаксису для цього немає — просто пишеш def опис(self), і пошук зупиняється на ньому раніше, ніж дійде до батьківського.

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

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

Інтерактив 2 · Заміна чи розширення

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

виконалось методів3
Зніми галочку. Без super() кожен клас складає рядок сам — і в аудіокниги зникають автор і рік, бо їх ніхто не додав. Головне ж не це: уяви, що завтра формат у Видання зміниться з Назва (2004) на Назва, 2004. З галочкою ця зміна долетить до всіх трьох класів, без неї — нікуди.

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

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

05 / supersuper() і його ланцюжок

Виклик super().__init__(...) означає: «знайди __init__ у наступному класі списку MRO і виконай його на цьому ж обʼєкті». Ключове слово тут — наступному: не «в батьківському», а саме в наступному, і в наступному розділі ця різниця стане принциповою.

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

Інтерактив 3 · super().__init__() крок за кроком

Ліворуч — які виклики зараз відкриті. Праворуч — що вже записано в обʼєкт.

крок1
атрибутів0
Зніми галочку й дойди до кінця. Обʼєкт створюється без жодної помилки — просто в ньому немає назва й рік. Падіння станеться потім, у опис(), і traceback покаже рядок усередині Видання.опис — за сотні рядків від справжньої причини.

Звідси проста звичка, яку варто завести назавжди: перший рядок __init__ у похідному класі — це super().__init__(...). Спершу нехай базовий клас доведе обʼєкт до робочого стану, а потім уже додавай своє. Виняток — коли своє потрібно порахувати до виклику базового, і тоді це варто прокоментувати прямо в коді.

06 / Чому саме superЧому не Батько.метод(self)

Той самий результат начебто дає й прямий виклик — узяти метод з класу й передати self руками:

class Книга(Видання): def __init__(self, назва, рік, автор): Видання.__init__(self, назва, рік) # працює — але так не роблять self.автор = автор

Це не помилка, і в дуже старому коді таке трапляється. Але причин писати super() три, і кожна серйозна.

причина 1 · імʼя базового класу продубльоване

У ланцюжку з трьох класів імʼя Видання зʼявляється двічі: у дужках заголовка й у тілі методу. Зміниш ієрархію — вставиш проміжний клас, перейменуєш базовий — і тобі доведеться згадати про всі такі рядки в усіх методах. Забудеш один — отримаєш обʼєкт, який частково ініціалізований по-старому. super() імені не називає взагалі: він щоразу дивиться в актуальний MRO.

причина 2 · self передається руками

Видання.__init__(self, назва, рік) — це виклик звичайної функції, де перший аргумент подаєш ти. Забути self дуже легко, і повідомлення про помилку буде плутане: Python поскаржиться, що рядок "Солодка Даруся" не має атрибута назва. У super().__init__(назва, рік) обʼєкт підставляється автоматично — забути нічого.

причина 3 · при кількох базових класах результат просто інший

Це не питання зручності. super() йде наступним у MRO, а Батько.метод(self) — точно у названий клас. Коли базовий клас один, це те саме. Коли їх два — ні, і різниця видно неозброєним оком у наступному розділі.

Для профі: чому super() без аргументів узагалі працює. До Python 3 писали super(Книга, self) — обидва аргументи явно. Порожня форма не «здогадується» ні про що: компілятор бачить виклик super() у тілі методу й тихо додає до функції приховану комірку __class__ із класом, у якому цей метод записано. Саме звідти береться перший аргумент, а другий — це self із першого параметра. Побічний наслідок: super() без аргументів не працює поза тілом методу — наприклад, у вкладеній функції, куди не потрапляє self.

07 / РомбМножинне наслідування й ромб

Python дозволяє вказати в дужках кілька базових класів. Це називається множинним наслідуванням, і в більшості мов його немає саме через задачу, яку ми зараз розберемо.

Візьмімо іншу, ненавчальну ситуацію: бібліотека купила техніку. Є Пристрій, від нього походять Сканер і Принтер, а багатофункціональний пристрій — БФП — є і тим, і тим:

class Пристрій: def запустити(self): print("Пристрій") class Сканер(Пристрій): def запустити(self): print("Сканер") super().запустити() class Принтер(Пристрій): def запустити(self): print("Принтер") super().запустити() class БФП(Сканер, Принтер): def запустити(self): print("БФП") super().запустити()

Схема звʼязків утворює ромб: від БФП угору йдуть два шляхи, і обидва сходяться в Пристрій. Звідси й назва — ромбоподібна проблема (diamond problem). Питання просте: коли ми кличемо бфп.запустити(), скільки разів має надрукуватися слово «Пристрій»?

Інтерактив 4 · Ромб і __mro__

Помінявши порядок базових класів у дужках, ти міняєш MRO — і порядок викликів.

рядків4
Пристрій разів1
Зніми галочку — і кожен клас замість super() покличе свій базовий прямо: Сканер.запустити(self) і Принтер.запустити(self). «Пристрій» надрукується двічі. У навчальному прикладі це просто зайвий рядок; у справжньому коді це двічі списані гроші або двічі відкрите зʼєднання.

Тепер видно, навіщо MRO влаштований саме так. Він не «дерево», по якому Python ходить угору. Він — плаский список без повторів, побудований за трьома правилами: клас іде раніше за своїх батьків; базові класи йдуть у тому порядку, як записані в дужках; кожен клас у списку рівно один раз. Алгоритм, який це рахує, називається C3-лінеаризацією, і його головна властивість якраз у тому, що super(), ідучи цим списком, зайде в кожен клас ромба точно один раз.

Зверни увагу на найдивніше місце інтерактиву: у Сканер.запустити написано super().запустити(), а виконується Принтер.запустити — хоча Принтер не є базовим для Сканер взагалі. Це не баг: super() означає «наступний у MRO цього обʼєкта», а не «мій батько». Який саме клас виявиться наступним, залежить від класу обʼєкта, на якому метод викликали, тобто визначається не там, де метод написано.

Чесно про множинне наслідування. Використовувати його варто рідко й тільки в одному сценарії — коли додаткові класи не мають власного стану й лише додають поведінку (їх називають домішками, mixin). Ромб із двох повноцінних класів, у кожного з яких свій __init__ зі своїми аргументами, узгодити майже неможливо: щоб ланцюжок super() не розсипався, усі класи ієрархії мають бути написані під нього — приймати **kwargs і передавати їх далі. Якщо хоча б один клас у ланцюжку про це не знає, ланцюжок обривається мовчки.

08 / КомпозиціяКомпозиція замість наслідування

Це головний розділ теми. Різниця між двома способами повторного використання коду формулюється двома короткими фразами:

наслідування — «Книга є різновидом Видання»
композиція — «Книга має всередині Носій»

Композиція (composition) — це коли обʼєкт не успадковує поведінку, а тримає інший обʼєкт у своєму атрибуті й звертається до нього. Жодного нового синтаксису: self.носій = носій — ось і вся композиція.

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

class ПапероваКнига(Книга): ... class АудіоКнига(Книга): ... class ЕлектроннаКнига(Книга): ...

Виглядає бездоганно. Три класи, кожен зі своїм як_видати(), усе на місці. А потім приходить бібліотекарка й каже: «ще треба позначати мову — українська чи англійська». І ще: «а подарункові видання видаємо лише в залі». Кожна вимога — новий незалежний вимір, і класи починають множитися:

Інтерактив 5 · Наслідування проти композиції

Додавай вимоги по одній. Ліворуч — скільки класів доведеться написати, праворуч — скільки при композиції.

класів: наслідув.4
класів: композиція4
+1 новий носій коштує
Познач лише перший рядок — і побачиш, що при одному вимірі різниці немає взагалі: 4 класи проти 4. Саме тому наслідування тут і обирають. Познач усі три — і ліворуч буде 13 класів проти 8, а новий носій коштуватиме чотирьох нових класів замість одного.

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

class Книга: def __init__(self, назва, рік, автор, носій): self.назва = назва self.рік = рік self.автор = автор self.носій = носій # ось тут і є композиція def як_видати(self): return self.носій.як_видати() # делегуємо тому, хто знає class Папір: def як_видати(self): return "видати з полиці, повернути за 14 днів" class Файл: def як_видати(self): return "надіслати посилання, доступ на 14 днів" книга = Книга("Солодка Даруся", 2004, "Марія Матіос", Папір()) книга.носій = Файл() # та сама книга, інший носій — під час роботи програми

Метод як_видати у Книга нічого не робить сам — він передає роботу тому, хто в цьому розуміється. Це називають делегуванням (delegation), і воно є основною роботою композиції.

Як обрати між ними

Перевірка проста й називається «тест на є»: підстав обидва класи у фразу «A є різновидом B» і послухай, чи не звучить вона дивно.

фразазвучить?висновок
Книга є Виданнямтакнаслідування
Книга є Паперомні, книга має носійкомпозиція
Замовлення є Спискомні, воно містить списоккомпозиція
Автомобіль є Двигуномні, він має двигункомпозиція

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

Для профі: правило підстановки. Формально описану щойно вимогу називають принципом підстановки Лісков: якщо S — підтип T, то обʼєкти T у програмі можна замінити на обʼєкти S без зміни правильності. Хрестоматійний контрприклад: Квадрат(Прямокутник). Математично квадрат — окремий випадок прямокутника, а програмно ні: код, який чесно ставить ширину 3, висоту 4 й очікує площу 12, на квадраті отримає 16, бо квадрат мусив підтягнути другу сторону. Класи описують не предмети, а поведінку, і геометрія тут не порадник.

09 / КачкаКачина типізація й isinstance

Якщо композиція розводить класи по різних гілках, постає питання: як тоді написати функцію, що працює і з книгою, і з журналом, і з настільною грою? Перша ідея — перевіряти тип:

def надрукувати(позиція): if isinstance(позиція, Видання): print(позиція.опис())

isinstance(обʼєкт, Клас) відповідає «так», якщо клас обʼєкта — це Клас або будь-який його нащадок. Тобто питання звучить так: «чи є ти різновидом ось цього?». Але функції насправді байдуже, ким є аргумент, — їй потрібно лише, щоб у нього був метод опис().

Підхід, у якому питають не про рід, а про вміння, називають качиною типізацією (duck typing): якщо щось ходить як качка й крякає як качка, для нашої задачі воно й є качка. У Python це стиль за замовчуванням — просто клич метод:

Інтерактив 6 · Питаємо про рід чи про вміння

Чотири обʼєкти, дві перевірки. Обери рядок — і подивись, кого який спосіб пропустить.

isinstance
виклик .опис()
Цікавий рядок — третій. НастільнаГра не має до Видання жодного стосунку, але вміє рівно те, що потрібно. Перевірка через isinstance її не пропустить — і функція стане непридатною для половини каталогу без жодної технічної причини.

Це не означає, що isinstance — зло. Він доречний там, де тобі справді потрібен саме тип: розгалуження за типом вхідних даних (рядок чи список), перевірка аргументу на межі бібліотеки, обробка різних гілок у except. Але як спосіб «пустити чи не пустити» він майже завжди зайвий і завжди звужує код.

Для профі: type(x) == Клас проти isinstance. Перше порівняння відповідає «ні» для будь-якого нащадка й тому ламає наслідування — функція, написана для Видання, не прийме Книга. Якщо перевірка типу справді потрібна, бери isinstance. А для «чи вміє» є прямий інструмент: hasattr(обʼєкт, "опис"). Утім, у Python звичніше просто покликати метод і, якщо треба, спіймати AttributeError — про це була тема 18.

10 / abcАбстрактний базовий клас

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

from abc import ABC, abstractmethod class Видання(ABC): def __init__(self, назва, рік): self.назва = назва self.рік = рік @abstractmethod def опис(self): """Кожен нащадок мусить це реалізувати."""

ABC (abstract base class — абстрактний базовий клас) — це клас, який не призначений для створення обʼєктів; він лише описує, що мають уміти нащадки. Позначений @abstractmethod метод перетворює клас на незавершений: створити обʼєкт такого класу Python не дасть, доки метод не реалізовано.

Інтерактив 7 · Коли саме помітять помилку

Три класи, один із них забув реалізувати опис. Дивись, на якому етапі це спливе.

помилка на етапі
Перемкни галочку на «Афіші». Без abc клас без опис створює обʼєкти без жодних заперечень, і помилка чекає до першого виклику. З abc вона зʼявляється на два кроки раніше — у момент Афіша(...), з повідомленням, у якому прямо названо і клас, і метод.

Головна цінність abc — не «строгість» заради строгості, а перенесення помилки ближче до причини. Це та сама ідея, що й у темі 19: чим раніше зловлено, тим дешевше виправити.

Брати abc варто, коли базовий клас справді описує контракт для кількох незалежних реалізацій — наприклад, наші носії Папір, Аудіо, Файл усі мусять уміти як_видати(). Для пари класів, які пише одна людина в одному файлі, це зайва церемонія.

11 / ЦінаЦіна наслідування

Розділ для тих, хто вже писав класи й бачив, як ієрархія виходить з-під контролю. Наслідування коштує дорожче, ніж здається, і ось із чого складається його ціна.

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

Глибина не додається, а множиться. Щоб зрозуміти метод у класі на пʼятому рівні ієрархії, треба тримати в голові пʼять класів одночасно й памʼятати, який із них щось перевизначив. Читання коду перетворюється на стрибки вгору-вниз по файлах. Робочий орієнтир простий: два рівні — нормально, три — привід задуматись, чотири й більше — майже завжди помилка проєктування.

Ієрархію дуже важко переробити. Композицію можна переставити майже безкарно: замінив один обʼєкт у полі — і все. Ієрархія ж зашита в заголовки класів, і будь-яка її зміна зачіпає всі методи, що кличуть super(), усі перевірки isinstance і весь код, що покладався на порядок MRO.

І наслідування «навпаки» — теж помилка. Спокуса успадкувати клас лише заради того, щоб дістатися його готових методів (успадкувати list, щоб мати append), закінчується погано: разом із потрібним методом нащадок отримує весь інтерфейс базового класу, включно з тим, що для нього безглуздо. Твоє Замовлення(list) раптом можна відсортувати, порізати зрізом і помножити на три. Правильний хід — покласти список у поле.

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

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

Наслідування й композиція розвʼязують одну задачу — не писати те саме двічі — але платять за це різну ціну.

Далі — тема 27, магічні методи. Ми вже двічі стикалися з __init__ і жодного разу не спитали, чому в нього такі дивні підкреслення. Там зʼясується, що len(x), x + y, x == y і навіть print(x) — це виклики методів із такими самими іменами, і що написати свій клас, який поводиться як вбудований тип, — питання пʼяти рядків. А тема 28 покаже, як dataclass прибирає з наших класів увесь одноманітний __init__ разом узятий.

Далі в практиці. У practice.ipynb ти збудуєш ієрархію Видання → Книга → Аудіокнига й доведеш через assert, що super() справді збирає опис із трьох частин; спеціально забудеш super().__init__() і зловиш AttributeError; надрукуєш __mro__ і перевіриш, що порядок саме такий; перепишеш ту саму ієрархію на композицію та зміниш носій книги під час роботи програми; і напишеш функцію, яка працює з трьома обʼєктами різних класів без жодного isinstance.

Далі в темі

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