Практика · Наслідування й композиція¶

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

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

  1. побачимо дублювання в двох незалежних класах — і приберемо його наслідуванням;
  2. надрукуємо __mro__ і самі напишемо пошук методу по цьому списку, а потім звіримо свій результат із тим, що дає Python;
  3. складемо опис із трьох класів через super() і доведемо assertом, що всі три ланки спрацювали;
  4. навмисно забудемо super().__init__() і зловимо справжній AttributeError;
  5. побудуємо ромб із чотирьох класів і побачимо, чим super() відрізняється від Батько.метод(self);
  6. перепишемо ту саму ієрархію на композицію і змінимо носій книги під час роботи програми;
  7. напишемо функцію, яка працює з трьома обʼєктами різних класів без жодного isinstance;
  8. закриємо все абстрактним базовим класом і побачимо, як помилка переїжджає на два кроки раніше.

Зошит виконується згори вниз. Кожна клітинка щось друкує — читай вивід, а не тільки код.

1 · Проблема: два класи з однаковою серединою¶

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

In [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__, ані вік — вони успадковані.

In [2]:
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__ першим рядком кличе базовий, кожен опис розширює батьківський, а не переписує його з нуля.

In [3]:
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ом, щоб не покладатись на око.

In [4]:
опис = аудіокнига.опис()

# кожна ланка ланцюжка мусить лишити слід у результаті
assert "Іван Марчук" in опис, "не спрацював опис з Аудіокниги"
assert "Марія Матіос" in опис, "не спрацював опис з Книги — забули super()?"
assert "(2004)" in опис, "не спрацював опис з Видання — обірвався ланцюжок super()"
assert опис == "Марія Матіос. Солодка Даруся (2004) — читає Іван Марчук"

print("✅ у рядку є внесок усіх трьох класів")
print(опис)
✅ у рядку є внесок усіх трьох класів
Марія Матіос. Солодка Даруся (2004) — читає Іван Марчук

А тепер — сам список, по якому Python шукає метод. Це не дерево, а плаский кортеж класів у чіткому порядку.

In [5]:
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__ зліва направо й повертає перший клас, у власному словнику якого є потрібне імʼя.

In [6]:
def де_знайшовся(клас, імя_методу):
    """Перший клас у MRO, який справді визначає цей метод. None — якщо ніде немає."""
    for кандидат in клас.__mro__:
        # __dict__ класу містить лише його ВЛАСНІ атрибути, без успадкованих —
        # саме тому по ньому можна крокувати вручну
        if імя_методу in кандидат.__dict__:
            return кандидат
    return None


for імя in ["опис", "вік", "__init__", "тривалість"]:
    знайдено = де_знайшовся(Аудіокнига, імя)
    print(f"{імя:<12} → {знайдено.__name__ if знайдено else 'ніде немає'}")
опис         → Аудіокнига
вік          → Видання
__init__     → Аудіокнига
тривалість   → ніде немає

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

In [7]:
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(), другий складає рядок сам.

In [8]:
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() втратила частину, за яку відповідає базовий клас

Головне не втрачений рік, а те, що станеться при зміні формату. Змінимо опис у базовому класі — і подивимось, до кого зміна долетить.

In [9]:
# правимо формат в одному місці — у базовому класі
def новий_опис(self):
    return f"{self.назва}, {self.рік}"

Видання.опис = новий_опис    # так робити в бойовому коді не варто, але для досліду зручно

print("із super():", звичайна.опис())
print("без super():", переписана.опис())

assert звичайна.опис() == "Марія Матіос. Солодка Даруся, 2004", "зміна мала долетіти"
assert переписана.опис() == "Марія Матіос. Солодка Даруся", "а сюди — ні"
print("✅ зміна базового класу долетіла тільки туди, де є super()")
із super(): Марія Матіос. Солодка Даруся, 2004
без super(): Марія Матіос. Солодка Даруся
✅ зміна базового класу долетіла тільки туди, де є super()

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

In [10]:
def опис_із_дужками(self):
    return f"{self.назва} ({self.рік})"

Видання.опис = опис_із_дужками

print("перевірка:", аудіокнига.опис())
assert аудіокнига.опис().endswith("читає Іван Марчук")
print("✅ формат відновлено")
перевірка: Марія Матіос. Солодка Даруся (2004) — читає Іван Марчук
✅ формат відновлено

6 · Забутий super().__init__() — найдорожча помилка теми¶

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

In [11]:
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__, тобто зовсім інше місце.

In [12]:
зламана.опис()
---------------------------------------------------------------------------
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 'автор'

Ту саму помилку можна спіймати й розглянути, не зупиняючи зошит.

In [13]:
try:
    зламана.опис()
except AttributeError as помилка:
    print("тип помилки:", type(помилка).__name__)
    print("повідомлення:", помилка)
    зловили = True

assert зловили, "помилка мала виникнути"
print("✅ AttributeError виник саме там, де ми його чекали")
тип помилки: AttributeError
повідомлення: 'АудіокнигаБезSuper' object has no attribute 'автор'
✅ AttributeError виник саме там, де ми його чекали

7 · Ромб: чому super(), а не Батько.метод(self)¶

Чотири класи: Пристрій, від нього Сканер і Принтер, а БФП — від обох. Замість друку збиратимемо сліди у список, щоб потім перевірити його assertом.

In [14]:
слід = []      # сюди кожен клас запише своє імʼя, коли його метод виконається


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 цього обʼєкта», а не «мій батько».

Тепер той самий ромб, але з прямими викликами базових класів.

In [15]:
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 · Композиція: та сама задача, інша конструкція¶

Вимога змінюється: одна й та сама книга буває на папері, в аудіо й файлом, і формат може змінитися вже після того, як книгу занесли в каталог. Наслідування такого не вміє — клас обʼєкта не змінюється. Тому носій стає окремим обʼєктом усередині книги.

In [16]:
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 днів

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

In [17]:
клас_до = type(даруся)
опис_до = даруся.опис()

даруся.носій = Файл()          # один рядок — і поведінка інша

print("до: ", опис_до)
print("після:", даруся.опис())
print("як видати тепер:", даруся.як_видати())

assert type(даруся) is клас_до, "клас обʼєкта не мав змінитися"
assert "папір" in опис_до and "файл" in даруся.опис()
assert "посилання" in даруся.як_видати()
print("✅ той самий обʼєкт того самого класу поводиться інакше")
до:  Марія Матіос. Солодка Даруся (2004) — папір
після: Марія Матіос. Солодка Даруся (2004) — файл
як видати тепер: надіслати посилання, доступ на 14 днів
✅ той самий обʼєкт того самого класу поводиться інакше

Скільки класів коштує кожен підхід? Порахуємо чесно для трьох незалежних вимірів (носій — 3 варіанти, мова — 2, оправа — 2), як в інтеракти́ві 5 лекції.

In [18]:
носіїв, мов, оправ = 3, 2, 2

# наслідуванням: один базовий клас плюс окремий підклас на КОЖНУ комбінацію
класів_наслідуванням = 1 + носіїв * мов * оправ

# композицією: один клас Книга плюс по одному маленькому класу на кожен варіант
класів_композицією = 1 + носіїв + мов + оправ

# скільки нових класів коштує ще один носій
новий_носій_наслідуванням = мов * оправ
новий_носій_композицією = 1

print("класів наслідуванням:", класів_наслідуванням)
print("класів композицією:  ", класів_композицією)
print("ще один носій коштує:", новий_носій_наслідуванням, "проти", новий_носій_композицією)

assert класів_наслідуванням == 13 and класів_композицією == 8
assert новий_носій_наслідуванням == 4
print("✅ числа збігаються з інтерактивом 5")
класів наслідуванням: 13
класів композицією:   8
ще один носій коштує: 4 проти 1
✅ числа збігаються з інтерактивом 5

9 · Качина типізація: питаємо про вміння, а не про рід¶

Функція нижче не знає й не питає, якого класу її аргументи. Їй потрібен один-єдиний метод — опис(). Тому в каталог спокійно потрапляє настільна гра, яка не має до Видання жодного стосунку.

In [19]:
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 і перевірку вміння через сам виклик.

In [20]:
гра = каталог[2]

за_родом = isinstance(гра, Видання)          # чи ти різновид Видання?
за_вмінням = hasattr(гра, "опис")            # чи ти вмієш описати себе?

print("isinstance(гра, Видання) =", за_родом)
print("hasattr(гра, 'опис')     =", за_вмінням)

assert за_родом is False, "гра справді не нащадок Видання"
assert за_вмінням is True, "але метод у неї є"
assert len(рядки) == 3, "у каталог мали потрапити всі три позиції"
print("✅ перевірка за родом відкинула б робочу позицію; перевірка за вмінням — ні")
isinstance(гра, Видання) = False
hasattr(гра, 'опис')     = True
✅ перевірка за родом відкинула б робочу позицію; перевірка за вмінням — ні

І контрольний приклад: обʼєкт, який не вміє нічого потрібного. Тут падають обидва підходи — просто по-різному.

In [21]:
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 не дасть створити обʼєкт класу, який цю вимогу не виконав.

In [22]:
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: там обʼєкт створюється мовчки, і помилка чекає до першого виклику — можливо, вже після того, як обʼєкт потрапив у список, у базу або в чужу функцію.

In [23]:
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.