Практика · Наслідування й композиція¶
Лекція: lecture.html · Тест: quiz.html · ДЗ: homework.md
Наскрізний приклад той самий, що в лекції, — каталог невеликої бібліотеки. Тут ми руками зробимо все, про що йшлося:
- побачимо дублювання в двох незалежних класах — і приберемо його наслідуванням;
- надрукуємо
__mro__і самі напишемо пошук методу по цьому списку, а потім звіримо свій результат із тим, що дає Python; - складемо опис із трьох класів через
super()і доведемоassertом, що всі три ланки спрацювали; - навмисно забудемо
super().__init__()і зловимо справжнійAttributeError; - побудуємо ромб із чотирьох класів і побачимо, чим
super()відрізняється відБатько.метод(self); - перепишемо ту саму ієрархію на композицію і змінимо носій книги під час роботи програми;
- напишемо функцію, яка працює з трьома обʼєктами різних класів без жодного
isinstance; - закриємо все абстрактним базовим класом і побачимо, як помилка переїжджає на два кроки раніше.
Зошит виконується згори вниз. Кожна клітинка щось друкує — читай вивід, а не тільки код.
1 · Проблема: два класи з однаковою серединою¶
Бібліотека видає книги й журнали. Обидва мають назву й рік, обидва вміють себе описати. Поки що це два незалежні класи — подивись, скільки в них спільного.
class КнигаБезНаслідування:
def __init__(self, назва, рік, автор):
self.назва = назва
self.рік = рік
self.автор = автор
def вік(self, цей_рік=2026):
return цей_рік - self.рік
class ЖурналБезНаслідування:
def __init__(self, назва, рік, номер):
self.назва = назва
self.рік = рік # рядок дослівно такий самий, як у книзі
self.номер = номер
def вік(self, цей_рік=2026):
return цей_рік - self.рік # і метод теж дослівно такий самий
книга = КнигаБезНаслідування("Солодка Даруся", 2004, "Марія Матіос")
журнал = ЖурналБезНаслідування("Куншт", 2024, 18)
print("вік книги: ", книга.вік())
print("вік журналу:", журнал.вік())
print("метод вік у двох класах — це два різні обʼєкти:",
КнигаБезНаслідування.вік is not ЖурналБезНаслідування.вік)
вік книги: 22 вік журналу: 2 метод вік у двох класах — це два різні обʼєкти: True
Останній рядок і є суть проблеми: вік існує у двох примірниках. Виправиш формулу
в одному — другий залишиться старим, і жоден інструмент про це не попередить.
2 · Базовий клас: спільне в одному місці¶
Виносимо назву, рік і вік() у клас Видання. Похідні класи оголошуємо через дужки.
Зверни увагу: у Книга немає ані __init__, ані вік — вони успадковані.
class Видання:
"""Усе, що бібліотека видає читачам: має назву й рік."""
def __init__(self, назва, рік):
self.назва = назва
self.рік = рік
def опис(self):
return f"{self.назва} ({self.рік})"
def вік(self, цей_рік=2026):
return цей_рік - self.рік
class Книга(Видання):
pass # поки що нічого свого не додає
книга = Книга("Солодка Даруся", 2004)
print("книга.опис() =", книга.опис())
print("книга.вік() =", книга.вік())
print("Книга — різновид Видання:", issubclass(Книга, Видання))
# метод у похідному класі — той самий обʼєкт, що й у базовому: копії не робиться
assert Книга.вік is Видання.вік, "нащадок мусить брати той самий метод, а не копію"
print("✅ Книга.вік і Видання.вік — один і той самий обʼєкт")
книга.опис() = Солодка Даруся (2004) книга.вік() = 22 Книга — різновид Видання: True ✅ Книга.вік і Видання.вік — один і той самий обʼєкт
3 · Три рівні ієрархії й __mro__¶
Додаємо Книга(Видання) з автором і Аудіокнига(Книга) з читцем. Кожен похідний
__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.читець}"
аудіокнига = Аудіокнига("Солодка Даруся", 2004, "Марія Матіос", "Іван Марчук")
print("опис:", аудіокнига.опис())
print("атрибути обʼєкта:", аудіокнига.__dict__)
опис: Марія Матіос. Солодка Даруся (2004) — читає Іван Марчук
атрибути обʼєкта: {'назва': 'Солодка Даруся', 'рік': 2004, 'автор': 'Марія Матіос', 'читець': 'Іван Марчук'}
Три класи склали рядок разом: Видання дало назву й рік, Книга — автора,
Аудіокнига — читця. Перевіримо це assertом, щоб не покладатись на око.
опис = аудіокнига.опис()
# кожна ланка ланцюжка мусить лишити слід у результаті
assert "Іван Марчук" in опис, "не спрацював опис з Аудіокниги"
assert "Марія Матіос" in опис, "не спрацював опис з Книги — забули super()?"
assert "(2004)" in опис, "не спрацював опис з Видання — обірвався ланцюжок super()"
assert опис == "Марія Матіос. Солодка Даруся (2004) — читає Іван Марчук"
print("✅ у рядку є внесок усіх трьох класів")
print(опис)
✅ у рядку є внесок усіх трьох класів Марія Матіос. Солодка Даруся (2004) — читає Іван Марчук
А тепер — сам список, по якому Python шукає метод. Це не дерево, а плаский кортеж класів у чіткому порядку.
for номер, клас in enumerate(Аудіокнига.__mro__, start=1):
print(номер, клас.__name__)
# object є в кінці MRO будь-якого класу, навіть якщо ми його ніде не писали
assert Аудіокнига.__mro__[-1] is object
assert [к.__name__ for к in Аудіокнига.__mro__] == \
["Аудіокнига", "Книга", "Видання", "object"]
print("✅ порядок саме такий, як показував інтерактив 1")
1 Аудіокнига 2 Книга 3 Видання 4 object ✅ порядок саме такий, як показував інтерактив 1
4 · Пишемо пошук методу самі й звіряємо з Python¶
Найкращий спосіб повірити, що всередині немає магії, — повторити механізм руками.
Наша функція йде по __mro__ зліва направо й повертає перший клас, у власному
словнику якого є потрібне імʼя.
def де_знайшовся(клас, імя_методу):
"""Перший клас у MRO, який справді визначає цей метод. None — якщо ніде немає."""
for кандидат in клас.__mro__:
# __dict__ класу містить лише його ВЛАСНІ атрибути, без успадкованих —
# саме тому по ньому можна крокувати вручну
if імя_методу in кандидат.__dict__:
return кандидат
return None
for імя in ["опис", "вік", "__init__", "тривалість"]:
знайдено = де_знайшовся(Аудіокнига, імя)
print(f"{імя:<12} → {знайдено.__name__ if знайдено else 'ніде немає'}")
опис → Аудіокнига вік → Видання __init__ → Аудіокнига тривалість → ніде немає
Перевіримо, що наш пошук збігається з тим, який робить сам Python. Порівнюємо функцію, знайдену нашим обходом, із тією, яку віддає звичайне звертання по імені.
for імя in ["опис", "вік", "__init__"]:
наш_клас = де_знайшовся(Аудіокнига, імя)
наша_функція = наш_клас.__dict__[імя]
бібліотечна = getattr(Аудіокнига, імя) # так шукає сам Python
assert наша_функція is бібліотечна, f"розійшлись на методі {імя}"
# а якщо методу немає ніде — Python дає AttributeError, наш обхід дає None
assert де_знайшовся(Аудіокнига, "тривалість") is None
assert not hasattr(аудіокнига, "тривалість")
print("✅ наш обхід MRO дає точно ті самі функції, що й сам Python")
✅ наш обхід MRO дає точно ті самі функції, що й сам Python
5 · Перевизначення: заміна проти розширення¶
Той самий метод опис у трьох класах. Порівняймо два варіанти похідного класу:
один кличе super(), другий складає рядок сам.
class КнигаЗамість(Видання):
"""Похідний клас, який переписує опис з нуля — без super()."""
def __init__(self, назва, рік, автор):
super().__init__(назва, рік)
self.автор = автор
def опис(self):
# копія коду з Видання, у якій «загубився» рік
return f"{self.автор}. {self.назва}"
звичайна = Книга("Солодка Даруся", 2004, "Марія Матіос")
переписана = КнигаЗамість("Солодка Даруся", 2004, "Марія Матіос")
print("із super():", звичайна.опис())
print("без super():", переписана.опис())
assert "(2004)" in звичайна.опис()
assert "(2004)" not in переписана.опис(), "у версії без super() року й не мало бути"
print("✅ версія без super() втратила частину, за яку відповідає базовий клас")
із super(): Марія Матіос. Солодка Даруся (2004) без super(): Марія Матіос. Солодка Даруся ✅ версія без super() втратила частину, за яку відповідає базовий клас
Головне не втрачений рік, а те, що станеться при зміні формату. Змінимо опис
у базовому класі — і подивимось, до кого зміна долетить.
# правимо формат в одному місці — у базовому класі
def новий_опис(self):
return f"{self.назва}, {self.рік}"
Видання.опис = новий_опис # так робити в бойовому коді не варто, але для досліду зручно
print("із super():", звичайна.опис())
print("без super():", переписана.опис())
assert звичайна.опис() == "Марія Матіос. Солодка Даруся, 2004", "зміна мала долетіти"
assert переписана.опис() == "Марія Матіос. Солодка Даруся", "а сюди — ні"
print("✅ зміна базового класу долетіла тільки туди, де є super()")
із super(): Марія Матіос. Солодка Даруся, 2004 без super(): Марія Матіос. Солодка Даруся ✅ зміна базового класу долетіла тільки туди, де є super()
Повертаємо базовий клас у попередній вигляд, щоб решта зошита рахувала ті самі числа.
def опис_із_дужками(self):
return f"{self.назва} ({self.рік})"
Видання.опис = опис_із_дужками
print("перевірка:", аудіокнига.опис())
assert аудіокнига.опис().endswith("читає Іван Марчук")
print("✅ формат відновлено")
перевірка: Марія Матіос. Солодка Даруся (2004) — читає Іван Марчук ✅ формат відновлено
6 · Забутий super().__init__() — найдорожча помилка теми¶
Пишемо клас, у якому похідний __init__ є, а виклику базового немає. Обʼєкт
створиться без жодної помилки — і саме це найгірше.
class АудіокнигаБезSuper(Книга):
def __init__(self, назва, рік, автор, читець):
# super().__init__(...) навмисно НЕ викликано
self.читець = читець
зламана = АудіокнигаБезSuper("Солодка Даруся", 2004, "Марія Матіос", "Іван Марчук")
print("обʼєкт створено, помилки немає:", зламана)
print("атрибути обʼєкта:", зламана.__dict__)
print("а мало бути: ", аудіокнига.__dict__)
assert "назва" not in зламана.__dict__, "без super() назви взятися нізвідки"
assert "назва" in аудіокнига.__dict__
print("✅ у зламаному обʼєкті два атрибути замість чотирьох — і ніхто не поскаржився")
обʼєкт створено, помилки немає: <__main__.АудіокнигаБезSuper object at 0x7f724383afc0>
атрибути обʼєкта: {'читець': 'Іван Марчук'}
а мало бути: {'назва': 'Солодка Даруся', 'рік': 2004, 'автор': 'Марія Матіос', 'читець': 'Іван Марчук'}
✅ у зламаному обʼєкті два атрибути замість чотирьох — і ніхто не поскаржився
Скаржитись Python почне пізніше — на першому ж зверненні до атрибута, якого немає.
Наступна клітинка навмисно падає: подивись на traceback уважно. Рядок, який він
показує, лежить усередині Видання.опис — а виправляти треба
АудіокнигаБезSuper.__init__, тобто зовсім інше місце.
зламана.опис()
--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) Cell In[12], line 1 ----> 1 зламана.опис() Cell In[3], line 8, in Книга.опис(self) 6 def опис(self): 7 # додаємо тільки свій шматок, решту бере на себе Видання ----> 8 return f"{self.автор}. {super().опис()}" AttributeError: 'АудіокнигаБезSuper' object has no attribute 'автор'
Ту саму помилку можна спіймати й розглянути, не зупиняючи зошит.
try:
зламана.опис()
except AttributeError as помилка:
print("тип помилки:", type(помилка).__name__)
print("повідомлення:", помилка)
зловили = True
assert зловили, "помилка мала виникнути"
print("✅ AttributeError виник саме там, де ми його чекали")
тип помилки: AttributeError повідомлення: 'АудіокнигаБезSuper' object has no attribute 'автор' ✅ AttributeError виник саме там, де ми його чекали
7 · Ромб: чому super(), а не Батько.метод(self)¶
Чотири класи: Пристрій, від нього Сканер і Принтер, а БФП — від обох.
Замість друку збиратимемо сліди у список, щоб потім перевірити його assertом.
слід = [] # сюди кожен клас запише своє імʼя, коли його метод виконається
class Пристрій:
def запустити(self):
слід.append("Пристрій")
class Сканер(Пристрій):
def запустити(self):
слід.append("Сканер")
super().запустити()
class Принтер(Пристрій):
def запустити(self):
слід.append("Принтер")
super().запустити()
class БФП(Сканер, Принтер):
def запустити(self):
слід.append("БФП")
super().запустити()
print("MRO:", [к.__name__ for к in БФП.__mro__])
слід.clear()
БФП().запустити()
print("слід виконання:", слід)
assert слід == ["БФП", "Сканер", "Принтер", "Пристрій"]
assert слід.count("Пристрій") == 1, "спільний предок мусить виконатись рівно раз"
print("✅ super() пройшов кожен клас ромба точно один раз, у порядку MRO")
MRO: ['БФП', 'Сканер', 'Принтер', 'Пристрій', 'object'] слід виконання: ['БФП', 'Сканер', 'Принтер', 'Пристрій'] ✅ super() пройшов кожен клас ромба точно один раз, у порядку MRO
Зверни увагу на найдивніше: у Сканер.запустити написано super(), а виконався
Принтер — клас, який не є базовим для Сканер. Так і має бути:
super() означає «наступний у MRO цього обʼєкта», а не «мій батько».
Тепер той самий ромб, але з прямими викликами базових класів.
class СканерПрямо(Пристрій):
def запустити(self):
слід.append("Сканер")
Пристрій.запустити(self) # прямий виклик замість super()
class ПринтерПрямо(Пристрій):
def запустити(self):
слід.append("Принтер")
Пристрій.запустити(self)
class БФППрямо(СканерПрямо, ПринтерПрямо):
def запустити(self):
слід.append("БФП")
СканерПрямо.запустити(self)
ПринтерПрямо.запустити(self)
слід.clear()
БФППрямо().запустити()
print("слід виконання:", слід)
assert слід == ["БФП", "Сканер", "Пристрій", "Принтер", "Пристрій"]
assert слід.count("Пристрій") == 2, "саме це й ламається без super()"
print("⚠️ спільний предок виконався двічі — у справжньому коді це подвійне списання")
слід виконання: ['БФП', 'Сканер', 'Пристрій', 'Принтер', 'Пристрій'] ⚠️ спільний предок виконався двічі — у справжньому коді це подвійне списання
8 · Композиція: та сама задача, інша конструкція¶
Вимога змінюється: одна й та сама книга буває на папері, в аудіо й файлом, і формат може змінитися вже після того, як книгу занесли в каталог. Наслідування такого не вміє — клас обʼєкта не змінюється. Тому носій стає окремим обʼєктом усередині книги.
class Папір:
назва_носія = "папір"
def як_видати(self):
return "видати з полиці, повернути за 14 днів"
class Аудіо:
назва_носія = "аудіо"
def як_видати(self):
return "видати навушники в залі"
class Файл:
назва_носія = "файл"
def як_видати(self):
return "надіслати посилання, доступ на 14 днів"
class КнигаЗНосієм:
"""Книга не Є носієм — вона МАЄ носій усередині."""
def __init__(self, назва, рік, автор, носій):
self.назва = назва
self.рік = рік
self.автор = автор
self.носій = носій # ось тут і є композиція
def опис(self):
return f"{self.автор}. {self.назва} ({self.рік}) — {self.носій.назва_носія}"
def як_видати(self):
# нічого не робимо самі: передаємо роботу тому, хто в цьому розуміється
return self.носій.як_видати()
даруся = КнигаЗНосієм("Солодка Даруся", 2004, "Марія Матіос", Папір())
print(даруся.опис())
print("як видати:", даруся.як_видати())
Марія Матіос. Солодка Даруся (2004) — папір як видати: видати з полиці, повернути за 14 днів
Головна перевага композиції — комбінацію можна змінити під час роботи програми, не створюючи іншого обʼєкта. Клас книги при цьому не змінюється взагалі.
клас_до = type(даруся)
опис_до = даруся.опис()
даруся.носій = Файл() # один рядок — і поведінка інша
print("до: ", опис_до)
print("після:", даруся.опис())
print("як видати тепер:", даруся.як_видати())
assert type(даруся) is клас_до, "клас обʼєкта не мав змінитися"
assert "папір" in опис_до and "файл" in даруся.опис()
assert "посилання" in даруся.як_видати()
print("✅ той самий обʼєкт того самого класу поводиться інакше")
до: Марія Матіос. Солодка Даруся (2004) — папір після: Марія Матіос. Солодка Даруся (2004) — файл як видати тепер: надіслати посилання, доступ на 14 днів ✅ той самий обʼєкт того самого класу поводиться інакше
Скільки класів коштує кожен підхід? Порахуємо чесно для трьох незалежних вимірів (носій — 3 варіанти, мова — 2, оправа — 2), як в інтеракти́ві 5 лекції.
носіїв, мов, оправ = 3, 2, 2
# наслідуванням: один базовий клас плюс окремий підклас на КОЖНУ комбінацію
класів_наслідуванням = 1 + носіїв * мов * оправ
# композицією: один клас Книга плюс по одному маленькому класу на кожен варіант
класів_композицією = 1 + носіїв + мов + оправ
# скільки нових класів коштує ще один носій
новий_носій_наслідуванням = мов * оправ
новий_носій_композицією = 1
print("класів наслідуванням:", класів_наслідуванням)
print("класів композицією: ", класів_композицією)
print("ще один носій коштує:", новий_носій_наслідуванням, "проти", новий_носій_композицією)
assert класів_наслідуванням == 13 and класів_композицією == 8
assert новий_носій_наслідуванням == 4
print("✅ числа збігаються з інтерактивом 5")
класів наслідуванням: 13 класів композицією: 8 ще один носій коштує: 4 проти 1 ✅ числа збігаються з інтерактивом 5
9 · Качина типізація: питаємо про вміння, а не про рід¶
Функція нижче не знає й не питає, якого класу її аргументи. Їй потрібен один-єдиний
метод — опис(). Тому в каталог спокійно потрапляє настільна гра, яка не має до
Видання жодного стосунку.
class Журнал(Видання):
def __init__(self, назва, рік, номер):
super().__init__(назва, рік)
self.номер = номер
def опис(self):
return f"{self.назва}, №{self.номер} ({self.рік})"
class НастільнаГра:
"""Не нащадок Видання взагалі — але вміє те, що потрібно каталогу."""
def __init__(self, назва, фішок):
self.назва = назва
self.фішок = фішок
def опис(self):
return f"{self.назва}, {self.фішок} фішки"
def надрукувати_каталог(позиції):
"""Друкує опис кожної позиції. Про типи не питає нічого."""
рядки = []
for позиція in позиції:
рядок = позиція.опис() # єдина вимога до аргументу — цей метод
рядки.append(рядок)
print(" ·", рядок)
return рядки
каталог = [
Книга("Солодка Даруся", 2004, "Марія Матіос"),
Журнал("Куншт", 2024, 18),
НастільнаГра("Каркасон", 72),
]
рядки = надрукувати_каталог(каталог)
· Марія Матіос. Солодка Даруся (2004) · Куншт, №18 (2024) · Каркасон, 72 фішки
Порівняймо два способи «пустити чи не пустити»: перевірку роду через isinstance
і перевірку вміння через сам виклик.
гра = каталог[2]
за_родом = isinstance(гра, Видання) # чи ти різновид Видання?
за_вмінням = hasattr(гра, "опис") # чи ти вмієш описати себе?
print("isinstance(гра, Видання) =", за_родом)
print("hasattr(гра, 'опис') =", за_вмінням)
assert за_родом is False, "гра справді не нащадок Видання"
assert за_вмінням is True, "але метод у неї є"
assert len(рядки) == 3, "у каталог мали потрапити всі три позиції"
print("✅ перевірка за родом відкинула б робочу позицію; перевірка за вмінням — ні")
isinstance(гра, Видання) = False hasattr(гра, 'опис') = True ✅ перевірка за родом відкинула б робочу позицію; перевірка за вмінням — ні
І контрольний приклад: обʼєкт, який не вміє нічого потрібного. Тут падають обидва підходи — просто по-різному.
class Стілець:
def __init__(self, стиль):
self.стиль = стиль
стілець = Стілець("віденський")
assert isinstance(стілець, Видання) is False
assert hasattr(стілець, "опис") is False
try:
надрукувати_каталог([стілець])
except AttributeError as помилка:
print("AttributeError:", помилка)
print("✅ качина типізація не «пропускає все підряд» — вона просто питає інше питання")
AttributeError: 'Стілець' object has no attribute 'опис' ✅ качина типізація не «пропускає все підряд» — вона просто питає інше питання
10 · Абстрактний базовий клас: помилка на два кроки раніше¶
Метод можна просто забути. Модуль abc дозволяє записати вимогу в базовому класі,
і тоді Python не дасть створити обʼєкт класу, який цю вимогу не виконав.
from abc import ABC, abstractmethod
class ВиданняABC(ABC):
def __init__(self, назва, рік):
self.назва = назва
self.рік = рік
@abstractmethod
def опис(self):
"""Кожен нащадок зобовʼязаний це реалізувати."""
class КнигаABC(ВиданняABC):
def __init__(self, назва, рік, автор):
super().__init__(назва, рік)
self.автор = автор
def опис(self):
return f"{self.автор}. {self.назва} ({self.рік})"
class АфішаABC(ВиданняABC):
pass # метод опис забули реалізувати
ок = КнигаABC("Солодка Даруся", 2004, "Марія Матіос")
print("клас із реалізацією створюється нормально:", ок.опис())
try:
АфішаABC("Афіша вересня", 2026)
except TypeError as помилка:
print("TypeError:", помилка)
спіймали = True
assert спіймали, "abc мав заборонити створення обʼєкта"
print("✅ помилка виникла на СТВОРЕННІ обʼєкта, а не десь потім на виклику методу")
клас із реалізацією створюється нормально: Марія Матіос. Солодка Даруся (2004) TypeError: Can't instantiate abstract class АфішаABC without an implementation for abstract method 'опис' ✅ помилка виникла на СТВОРЕННІ обʼєкта, а не десь потім на виклику методу
Порівняй з тим самим класом без abc: там обʼєкт створюється мовчки, і помилка
чекає до першого виклику — можливо, вже після того, як обʼєкт потрапив у список,
у базу або в чужу функцію.
class АфішаБезABC(Видання):
pass # опис теж не реалізовано, але заборонити нікому
афіша = АфішаБезABC("Афіша вересня", 2026)
print("обʼєкт створено без заперечень:", афіша.__dict__)
# опис успадкувався від Видання — тобто метод є, але він «не про афішу»
print("успадкований опис:", афіша.опис())
print("клас, у якому знайшовся опис:", де_знайшовся(АфішаБезABC, "опис").__name__)
assert де_знайшовся(АфішаБезABC, "опис") is Видання
assert де_знайшовся(КнигаABC, "опис") is КнигаABC
print("✅ без abc ніхто не помітить, що клас не написав свою версію методу")
обʼєкт створено без заперечень: {'назва': 'Афіша вересня', 'рік': 2026}
успадкований опис: Афіша вересня (2026)
клас, у якому знайшовся опис: Видання
✅ без abc ніхто не помітить, що клас не написав свою версію методу
Що далі — завдання трьох рівнів¶
Повне домашнє завдання з критеріями «зроблено» — у homework.md. Коротко, щоб не закривати зошит без справи:
🟢 Рівень 1. Додай до ієрархії клас Журнал(Видання) зі своїм опис, який
кличе super(), і клас Стаття(Журнал) з автором. Доведи assertами, що
в описі статті присутні внески всіх трьох класів, і надрукуй Стаття.__mro__.
🟡 Рівень 2. Перепиши ієрархію Видання → Книга → Аудіокнига на композицію так,
щоб залишився один клас Позиція і три маленькі класи-носії. Доведи assertами, що
одна й та сама позиція після зміни носія лишається обʼєктом того самого класу,
але дає інший результат як_видати().
🔴 Рівень 3. Напиши функцію мій_mro(клас), яка сама будує лінеаризацію C3
(клас іде раніше за батьків; базові — у порядку з дужок; кожен клас рівно один раз),
і звір її з клас.__mro__ щонайменше на пʼятьох ієрархіях, серед яких обовʼязково
є ромб і випадок, коли Python узагалі відмовляється будувати MRO.