Python

Наслідування, MRO та суперсила super()

Глибоке дослідження механізмів наслідування в Python — від одиночного наслідування та перевизначення методів до cooperative multiple inheritance, внутрішнього влаштування super() як проксі-об'єкта, детального математичного аналізу C3-лінеаризації, CPython Internals MRO та патерну Mixins.

Наслідування, MRO та суперсила super()

Вступ: Коли код перестає масштабуватися

Уявіть: ви розробляєте систему сповіщень для SaaS-платформи. Є три канали — Email, SMS, Push. Кожен потребує логування, авторизації та серіалізації для збереження в базу даних. Перша версія виглядає так:

# ❌ Монолітний підхід — катастрофа при масштабуванні
class EmailNotification:
    def send(self, user, msg): ...
    def log(self, msg): print(f"[EMAIL] {msg}")     # дублювання
    def to_json(self): return json.dumps(self.__dict__)  # дублювання

class SmsNotification:
    def send(self, user, msg): ...
    def log(self, msg): print(f"[SMS] {msg}")       # те саме!
    def to_json(self): return json.dumps(self.__dict__)  # те саме!

class PushNotification:
    def send(self, user, msg): ...
    def log(self, msg): print(f"[PUSH] {msg}")      # і знову те саме!
    def to_json(self): return json.dumps(self.__dict__)  # і знову!

Три класи — і вже три копії однакового коду. Через місяць вимога змінилась: логування тепер у JSON-форматі. Вам доведеться змінювати код у трьох місцях — і це гарантований шлях до помилок.

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

Проблема дублювання

Без наслідування зміна в одній «спільній» функції потребує правки у кожному класі окремо. Це порушує принцип DRY (Don't Repeat Yourself).

Проблема ієрархії

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

Проблема конфліктів

При множинному наслідуванні кілька батьків можуть мати однойменні методи. Без суворого алгоритму вирішення конфліктів поведінка непередбачувана.

Python-рішення

Python вирішує всі три проблеми: super() як проксі-об'єкт по MRO, C3-лінеаризація для детермінованого порядку, Mixins для горизонтального розподілу поведінки.

Частина I: Одиночне наслідування та перевизначення методів

Базовий синтаксис та відношення IS-A

Одиночне наслідування — це коли клас має рівно одного безпосереднього батька. У Python базовий клас вказується в дужках після назви дочірнього класу. Якщо батько не вказаний явно, клас автоматично наслідує від вбудованого object.

Головне правило: використовуйте наслідування лише тоді, коли між сутностями є чітке відношення IS-A («є об'єктом типу»). Для відношення HAS-A («містить у собі») — використовуйте композицію.

python main.py
$ python main.py
Двигун Generic (2020) запущено.
Двигун Toyota (2022) запущено. [Car mode: ремені пристебнуті]
Tesla: Тихий запуск електромотора. Заряд: 100.0%

Перевизначення методів (Method Overriding) — ключовий механізм поліморфізму. Коли Python шукає метод на об'єкті Tesla.start_engine(), він перевіряє класи в порядку MRO і знаходить першу відповідну реалізацію. У нашому випадку — в ElectricCar, ігноруючи Car.start_engine та Vehicle.start_engine.

__dict__ екземпляра проти __dict__ класу

Важливо розуміти відмінність між атрибутами, що зберігаються безпосередньо на екземплярі, та тими, що належать класу. Python шукає атрибут спочатку в obj.__dict__, а потім — у type(obj).__dict__ по MRO.

class Vehicle:
    wheels = 4  # атрибут класу — спільний для всіх екземплярів

    def __init__(self, brand: str):
        self.brand = brand  # атрибут екземпляра — індивідуальний

car1 = Vehicle("Toyota")
car2 = Vehicle("Honda")

# Атрибути класу — спільні
print(Vehicle.wheels)   # 4
print(car1.wheels)      # 4 ← знайдено у Vehicle.__dict__, НЕ у car1.__dict__
print(car2.wheels)      # 4

# Перевіряємо: car1.__dict__ не містить 'wheels'
print(car1.__dict__)    # {'brand': 'Toyota'} ← лише атрибут екземпляра!
print(Vehicle.__dict__.keys())  # ..., 'wheels', ...

# Якщо присвоїти 'wheels' через екземпляр — він стає атрибутом ЕКЗЕМПЛЯРА,
# і ТІНЬУЄ (shadows) атрибут класу
car1.wheels = 2
print(car1.wheels)   # 2  ← тепер з car1.__dict__
print(car2.wheels)   # 4  ← Vehicle.__dict__ незмінений
print(Vehicle.wheels)# 4  ← Vehicle.__dict__ незмінений
Типова пастка: зміна мутабельного атрибута класу (наприклад, списку) через клас (Vehicle.tags.append(...)) змінює його для всіх екземплярів. Тоді як присвоєння через екземпляр (car.tags = [...]) лише створює новий атрибут екземпляра, не змінюючи класовий. Тому мутабельні атрибути (списки, словники) завжди ініціалізуйте у __init__, а не на рівні класу.

Дослідження ієрархії: __base__, __bases__ та __subclasses__()

Python зберігає повну інформацію про ієрархію наслідування безпосередньо в об'єктах класів. Ці dunder-атрибути є вашими інструментами для дослідження та налагодження ієрархій.

base
type
Перший (або єдиний) безпосередній батьківський клас. При одиночному наслідуванні — завжди є єдиним батьком. При множинному — перший у списку. Еквівалентно cls.__bases__[0].
bases
tuple[type, ...]
Кортеж усіх безпосередніх батьківських класів. При одиночному наслідуванні — кортеж з одного елемента. При множинному — кортеж у порядку оголошення. Порожній лише у object.
mro
tuple[type, ...]
Повна черга пошуку методів (Method Resolution Order) — кортеж класів від поточного до object. Визначається алгоритмом C3-лінеаризації при створенні класу.
subclasses()
() -> list[type]
Метод, що повертає список усіх живих прямих підкласів даного класу. «Живих» — тобто тих, чий код був завантажений (виконаний) інтерпретатором. Корисний для динамічних реєстрів та плагін-систем.
from vehicle import Vehicle
from car import Car
from electric_car import ElectricCar

# ── Перевірка ієрархії ────────────────────────────────────────────────────────

print("ElectricCar.__base__:  ", ElectricCar.__base__)
# ElectricCar.__base__:   <class 'car.Car'>

print("ElectricCar.__bases__: ", ElectricCar.__bases__)
# ElectricCar.__bases__:  (<class 'car.Car'>,)

print("Car.__base__:          ", Car.__base__)
# Car.__base__:            <class 'vehicle.Vehicle'>

print("Vehicle.__base__:      ", Vehicle.__base__)
# Vehicle.__base__:        <class 'object'>   ← всі класи неявно від object

print("object.__base__:       ", object.__base__)
# object.__base__:         None               ← вершина ієрархії

# ── MRO для ElectricCar ───────────────────────────────────────────────────────
print("\nElectricCar.__mro__:")
for cls in ElectricCar.__mro__:
    print(f"  {cls}")
# <class 'electric_car.ElectricCar'>
# <class 'car.Car'>
# <class 'vehicle.Vehicle'>
# <class 'object'>

# ── Живі підкласи ─────────────────────────────────────────────────────────────
print("\nVehicle.__subclasses__(): ", Vehicle.__subclasses__())
# [<class 'car.Car'>]
# (ElectricCar тут не відображений — він є підкласом Car, а не Vehicle напряму)

print("Car.__subclasses__():     ", Car.__subclasses__())
# [<class 'electric_car.ElectricCar'>]

# ── isinstance та issubclass ──────────────────────────────────────────────────
tesla = ElectricCar("Tesla", 2024, 75.0)

# isinstance перевіряє весь MRO-ланцюжок!
print(isinstance(tesla, ElectricCar))  # True  — прямий тип
print(isinstance(tesla, Car))          # True  — батько
print(isinstance(tesla, Vehicle))      # True  — дід
print(isinstance(tesla, object))       # True  — завжди True

# issubclass: перевірка на рівні класів
print(issubclass(ElectricCar, Car))     # True
print(issubclass(ElectricCar, Vehicle)) # True
print(issubclass(Car, ElectricCar))     # False — зворотній напрям неможливий
Дослідження ієрархії наслідування
$ python hierarchy_inspect.py
ElectricCar.__base__: <class 'car.Car'>
ElectricCar.__bases__: (<class 'car.Car'>,)
Car.__base__: <class 'vehicle.Vehicle'>
Vehicle.__base__: <class 'object'>
object.__base__: None
ElectricCar.__mro__:
<class 'ElectricCar'>
<class 'Car'>
<class 'Vehicle'>
<class 'object'>
isinstance(tesla, Vehicle): True # весь ланцюжок перевірено!
issubclass(Car, ElectricCar): False # зворотній напрям
__subclasses__() — потужний інструмент для реалізації реєстрів плагінів та фабричних методів. Якщо всі плагіни наслідують від базового класу, ви можете динамічно отримати їх список без явної реєстрації. Але пам'ятайте: клас з'являється у __subclasses__() лише після того, як його модуль буде імпортований.

Частина II: super() — проксі-об'єкт, а не батьківський клас

Чому super() — це не просто «виклик батька»

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

Реальна поведінка super(): він повертає проксі-об'єкт, який делегує виклик методів на наступний клас у черзі MRO від поточного, а не на безпосереднього батька.

Різниця стає очевидною в ієрархії з множинним наслідуванням:

class A:
    def greet(self):
        print("A.greet")
        super().greet()   # Наступний у MRO — не object, а може бути B або C!

class B(A):
    def greet(self):
        print("B.greet")
        super().greet()   # Наступний у MRO від B в контексті D — це C

class C(A):
    def greet(self):
        print("C.greet")
        super().greet()   # Наступний у MRO від C — object (немає greet — AttributeError)

class D(B, C):
    def greet(self):
        print("D.greet")
        super().greet()   # Наступний у MRO від D — B

# MRO: [D, B, C, A, object]
d = D()
d.greet()
# D.greet → B.greet → C.greet → A.greet

Якби super() у B.greet означав «виклик батька» (A.greet), то C.greet ніколи б не виконався. Але завдяки MRO виклик проходить через увесь ланцюжок — саме це називається кооперативним наслідуванням (cooperative inheritance).

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam ArrowColor #6366f1
skinparam sequence {
    ParticipantBackgroundColor #f3f4f6
    ParticipantBorderColor #d1d5db
    LifeLineBackgroundColor #e0e7ff
}

participant "d: D" as D #d1fae5
participant "B" as B #dbeafe
participant "C" as C #fef3c7
participant "A" as A #fee2e2
participant "object" as OBJ #f3f4f6

D -> B : super().greet()\n[MRO: D→**B**→C→A]
note right of B
  super() у B — це не A!
  Це наступний у MRO
  в контексті об'єкта D
end note
B -> C : super().greet()\n[MRO: D→B→**C**→A]
C -> A : super().greet()\n[MRO: D→B→C→**A**]
A -> OBJ : super().greet()\n[object не має greet → тихо]
@enduml

__class__ як cell variable: чому super() без аргументів не є магією

У Python 3 super() без аргументів — це не магія, а синтаксичний цукор компілятора. Коли Python компілює метод класу, він автоматично додає приховану клітинну змінну (cell variable) __class__, яка замикається (closure) на об'єкт класу, в якому описано метод.

class MyClass:
    def my_method(self):
        # Компілятор автоматично перетворює super() на super(__class__, self)
        # де __class__ — це cell variable, що замикається на MyClass

        # Ці два записи АБСОЛЮТНО еквівалентні:
        super().some_method()          # синтаксичний цукор Python 3
        super(__class__, self).some_method()  # явна форма Python 2/3

# Перевіримо: __class__ доступна у будь-якому методі
class Inspector:
    def check(self):
        # __class__ — це Inspector, незалежно від того, хто викликає метод
        print(f"__class__ = {__class__}")
        print(f"type(self) = {type(self)}")

class Child(Inspector):
    pass

Child().check()
# __class__ = <class 'Inspector'>  ← НЕЗМІННО: клас, де описано метод
# type(self) = <class 'Child'>     ← тип реального об'єкта
Критична пастка: оскільки super() без аргументів використовує __class__ (cell variable), він працює лише всередині методу класу. Якщо ви витягнете метод назовні (наприклад, через unwrapped = MyClass.my_method або у декораторі, що замінює функцію), __class__ може бути недоступною, і super() кине RuntimeError. У таких сценаріях використовуйте явну форму super(MyClass, self).

Повна сигнатура super()

type
class
Клас, що є відправною точкою в черзі MRO. Пошук методів починається з наступного класу після type у черзі MRO. При super() без аргументів — компілятор підставляє поточний клас (__class__).
object_or_type
instance | class
Об'єкт (екземпляр) або підклас, чий MRO використовується для пошуку. При super() без аргументів — компілятор підставляє self (або cls у @classmethod). Це визначає, який саме MRO-ланцюжок буде обходитися.
class Manager(Employee):
    def __init__(self, name, salary, team_size):
        # Python автоматично підставляє super(Manager, self)
        # __class__ = Manager (cell variable)
        # self = поточний екземпляр
        super().__init__(name, salary)

super() у @classmethod: фабричний патерн

Особливість super() у @classmethod — він отримує клас (cls), а не екземпляр (self). Це ключовий інструмент для успадкованих фабричних методів:

# factory_pattern.py
import json
from datetime import datetime

class BaseRecord:
    """Базовий запис з фабричним методом."""

    def __init__(self, record_id: int, created_at: str):
        self.record_id = record_id
        self.created_at = created_at

    @classmethod
    def from_dict(cls, data: dict) -> "BaseRecord":
        """
        Фабричний метод: створює екземпляр з словника.
        cls — це реальний клас, на якому викликається метод!
        Завдяки цьому підкласи автоматично отримують правильний тип.
        """
        return cls(
            record_id=data["id"],
            created_at=data.get("created_at", datetime.now().isoformat())
        )

    @classmethod
    def from_json(cls, json_str: str) -> "BaseRecord":
        return cls.from_dict(json.loads(json_str))


class UserRecord(BaseRecord):
    """Підклас з розширеним конструктором."""

    def __init__(self, record_id: int, created_at: str, username: str, email: str):
        super().__init__(record_id, created_at)
        self.username = username
        self.email = email

    @classmethod
    def from_dict(cls, data: dict) -> "UserRecord":
        # Викликаємо from_dict батька через super() у classmethod
        # super() тут — super(UserRecord, cls), тобто BaseRecord
        base_instance = super().from_dict(data)

        # Але нам потрібен UserRecord, тому використовуємо cls напряму
        return cls(
            record_id=data["id"],
            created_at=data.get("created_at", datetime.now().isoformat()),
            username=data["username"],
            email=data["email"],
        )

    def __repr__(self) -> str:
        return f"UserRecord(id={self.record_id}, user={self.username!r})"


# Тест
data = {"id": 42, "username": "arakviel", "email": "arakviel@example.com"}

# Виклик from_dict на базовому класі → BaseRecord
base = BaseRecord.from_dict({"id": 1, "created_at": "2024-01-01"})
print(type(base))    # <class 'BaseRecord'>

# Виклик from_dict на підкласі → UserRecord (cls = UserRecord)
user = UserRecord.from_dict(data)
print(type(user))    # <class 'UserRecord'>
print(user)          # UserRecord(id=42, user='arakviel')
python factory_pattern.py
$ python factory_pattern.py
type(base): <class 'BaseRecord'>
type(user): <class 'UserRecord'> # правильний підклас!
UserRecord(id=42, user='arakviel')
Патерн «успадкований фабричний метод» — один з найважливіших застосувань @classmethod. Завдяки тому, що cls містить реальний клас (а не той, де описаний метод), метод from_dict або from_json визначений один раз у базовому класі, але автоматично повертає правильний підтип при виклику через підклас.

Кооперативне наслідування та *args, **kwargs

Коли класи проектуються для роботи у складних ієрархіях, виникає проблема несумісності сигнатур конструкторів __init__. Кожен клас приймає свій набір аргументів, але повинен передати «зайві» аргументи далі по MRO.

Стандартне рішення — патерн **kwargs:

# cooperative_init.py

class GraphicElement:
    def __init__(self, color: str = "black", **kwargs):
        print(f"  → GraphicElement.__init__(color={color!r})")
        super().__init__(**kwargs)  # передаємо залишок далі по MRO
        self.color = color


class ClickableElement:
    def __init__(self, on_click: str = "none", **kwargs):
        print(f"  → ClickableElement.__init__(on_click={on_click!r})")
        super().__init__(**kwargs)  # передаємо залишок далі
        self.on_click = on_click


class ResizableElement:
    def __init__(self, min_size: int = 10, **kwargs):
        print(f"  → ResizableElement.__init__(min_size={min_size})")
        super().__init__(**kwargs)
        self.min_size = min_size


# Button наслідує всіх трьох
class Button(GraphicElement, ClickableElement, ResizableElement):
    def __init__(self, label: str, **kwargs):
        print(f"  → Button.__init__(label={label!r})")
        super().__init__(**kwargs)
        self.label = label


print("MRO:", [c.__name__ for c in Button.__mro__])
# MRO: ['Button', 'GraphicElement', 'ClickableElement', 'ResizableElement', 'object']

print("\nСтворення кнопки:")
btn = Button(
    label="Submit",
    color="blue",
    on_click="submit_form",
    min_size=20,
)

print(f"\nАтрибути: label={btn.label!r}, color={btn.color!r}, "
      f"on_click={btn.on_click!r}, min_size={btn.min_size}")
python cooperative_init.py
$ python cooperative_init.py
MRO: ['Button', 'GraphicElement', 'ClickableElement', 'ResizableElement', 'object']
Створення кнопки:
Button.__init__(label='Submit')
GraphicElement.__init__(color='blue')
ClickableElement.__init__(on_click='submit_form')
ResizableElement.__init__(min_size=20)
Атрибути: label='Submit', color='blue', on_click='submit_form', min_size=20
Якщо один з класів у ланцюжку НЕ передає **kwargsдалі черезsuper().**init**(**kwargs), і при цьому залишилися нерозпізнані аргументиobject.__init__ отримає їх і кине TypeError. Тому у всіх класах, що беруть участь у кооперативному наслідуванні, обов'язково використовуйте **kwargs і передавайте їх далі.

Частина III: Множинне наслідування та проблема «діаманта»

Diamond Problem: як це виглядає

«Проблема діаманта» виникає, коли два батьківські класи мають спільного предка, і дочірній клас наслідує обох. Структура нагадує форму ромба (діаманта):

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam ArrowColor #6366f1
skinparam class {
    BackgroundColor #f3f4f6
    BorderColor #d1d5db
    FontName "Outfit"
}

class A {
    + speak() : void
}

class B extends A {
    + speak() : void
}

class C extends A {
    + speak() : void
}

class D extends B
class D extends C

note top of D
  D наслідує від B і C,
  обидва наслідують від A.
  Який speak() викликати?
  B.speak() чи C.speak()?
end note

note right of A
  Спільний предок —
  джерело конфлікту
end note
@enduml
# diamond.py

class A:
    def speak(self) -> str:
        return "A.speak"

class B(A):
    def speak(self) -> str:
        return f"B.speak → {super().speak()}"

class C(A):
    def speak(self) -> str:
        return f"C.speak → {super().speak()}"

class D(B, C):
    def speak(self) -> str:
        return f"D.speak → {super().speak()}"

d = D()
print(d.speak())
# D.speak → B.speak → C.speak → A.speak

print(D.mro())
# [D, B, C, A, object]

Стара проблема: DFLS (Depth-First, Left-to-Right)

До Python 2.2 пошук методів відбувався за наївним алгоритмом DFLS: спочатку вглиб по лівій гілці, потім по правій. Для класу D(B, C) черга виглядала так:

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam ArrowColor #e11d48

rectangle "Черга пошуку (DFLS)" as Q #fee2e2 {
    card "D" as D1 #fca5a5
    card "B" as B1 #fca5a5
    card "A" as A1 #fca5a5
    card "C" as C1 #fca5a5
    card "A" as A2 #fca5a5 {
        note "Дублікат!\nОчищується" as N1
    }
}

D1 -right-> B1 : 1
B1 -right-> A1 : 2 (вглиб)
A1 -right-> C1 : 3 (вправо)
C1 -right-> A2 : 4

note bottom of A1
  ❌ Катастрофа!
  A знайдено РАНІШЕ ніж C.
  Перевизначений метод у C
  повністю ігнорується.
end note
@enduml

Чому DFLS руйнує логіку: якщо C перевизначає метод speak() для специфічної поведінки, DFLS ніколи до нього не дістанеться — він знаходить A.speak() раніше. Специфічні нащадки ігноруються на користь далеких предків. Це пряме порушення принципу Liskov Substitution.

Саме ця проблема змусила розробників Python у версії 2.2 запровадити C3-лінеаризацію та класи нового стилю.

Ще одна небезпека DFLS: при «ромбовій» ієрархії A.__init__ викликається двічі: один раз через ліву гілку B, інший — через праву C. Це призводить до повторної ініціалізації атрибутів батьківського класу — непередбачувана поведінка, що у сучасних Django/SQLAlchemy-проектах могла б стати критичним багом. C3-лінеаризація гарантує, що кожен клас у MRO відвідується рівно один раз.

Частина IV: Глибокий розбір C3-лінеаризації

Математична основа алгоритму

C3-лінеаризація — алгоритм, запозичений з мови Dylan, що гарантує три властивості MRO:

  1. Локальний порядок предків: якщо class D(B, C), то в MRO B завжди перед C
  2. Монотонність: якщо X перед Y в MRO будь-якого батька, то X перед Y і в MRO нащадка
  3. Єдиний обхід: кожен клас відвідується рівно один раз

Формула лінеаризації класу Завантаження...C, що наслідує Завантаження...B_1, B_2, \dots, B_N:

Завантаження...
LC(B_1, B_2, \dots, B_N) = C + \text{merge}(LB_1, LB_2, \dots, LB_N, B_1, B_2, \dots, B_N)

Де Завантаження...\text{merge} — операція злиття кількох впорядкованих списків за правилом:

  1. Кожен список має голову (перший елемент) та хвіст (решта)
  2. Беремо голову першого списку — перевіряємо, чи немає її у хвостах інших списків
  3. Якщо її немає у жодному хвості — вилучаємо з усіх списків, додаємо до MRO
  4. Якщо є хоча б в одному хвості — вона заблокована, переходимо до голови наступного списку
  5. Якщо всі голови заблоковані — MRO неможливо побудувати (TypeError)

Покроковий ручний розрахунок для «діаманта»

class A(object): pass
class B(A): pass
class C(A): pass
class D(B, C): pass

Відомі лінеаризації:

  • Завантаження...L\text{object} = \text{object}
  • Завантаження...LA = A, \text{object}
  • Завантаження...LB = B, A, \text{object}
  • Завантаження...LC = C, A, \text{object}

Розрахунок Завантаження...LD:

Завантаження...
LD = D + \text{merge}(B, A, \text{object},\ C, A, \text{object},\ B, C)

Крок 1: Беремо голову першого списку — B

Перевіряємо хвости:

  • Хвіст [B, A, object][A, object]: B відсутній ✅
  • Хвіст [C, A, object][A, object]: B відсутній ✅
  • Хвіст [B, C][C]: B відсутній ✅ (хвіст — це список БЕЗ голови)

B не заблокований → додаємо до MRO. Видаляємо B звідусіль.

Завантаження...
LD = D, B + \text{merge}(A, \text{object},\ C, A, \text{object},\ C)

Крок 2: Беремо голову першого списку — A

Перевіряємо хвости:

  • Хвіст [A, object][object]: A відсутній ✅
  • Хвіст [C, A, object][A, object]: A присутній

A заблокований → переходимо до голови наступного списку → C.

  • Хвіст [A, object]: C відсутній ✅
  • Хвіст [C][] (порожній): C відсутній ✅ (голова не є частиною свого хвоста)

C не заблокований → додаємо до MRO. Видаляємо C звідусіль.

Завантаження...
LD = D, B, C + \text{merge}(A, \text{object},\ A, \text{object})

Крок 3: Беремо голову — A

Перевіряємо хвости:

  • Хвіст [A, object][object]: A відсутній ✅
  • Хвіст [A, object][object]: A відсутній ✅

A не заблокований → додаємо до MRO.

Завантаження...
LD = D, B, C, A + \text{merge}(\text{object},\ \text{object})

Крок 4: Залишається object

Обидва списки містять лише object як голову (хвости порожні). Додаємо.

Завантаження...
LD = D,\ B,\ C,\ A,\ \text{object}
# Верифікація розрахунку
print(D.mro())
# [<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>]
Верифікація ручного розрахунку MRO
$ python -c "print(D.mro())"
[<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>]
✓ Збігається з ручним розрахунком!

Що це означає практично: при виклику d.speak() Python шукає метод у порядку D → B → C → A → object. Перевизначений у C метод знаходиться раніше ніж загальний у A — проблема DFLS вирішена.


Конфлікт лінеаризації: коли MRO неможливо побудувати

Бувають ієрархії, де вимоги двох батьків суперечать одна одній. Python виявляє це під час оголошення класу і кидає TypeError:

# mro_conflict.py

class X: pass
class Y: pass

class A(X, Y): pass  # A вимагає: X перед Y
class B(Y, X): pass  # B вимагає: Y перед X (суперечить A!)

# C не може задовольнити обидві вимоги одночасно
class C(A, B): pass  # ← TypeError при оголошенні!
python mro_conflict.py
$ python mro_conflict.py
Traceback (most recent call last):
File "mro_conflict.py", line 8, in <module>
TypeError: Cannot create a consistent method resolution order (MRO) for bases X, Y
# A вимагає X→Y, B вимагає Y→X. Обидві вимоги неможливо задовольнити.
Python захищає вас на етапі оголошення класу, а не під час виконання. Якщо ви бачите TypeError: Cannot create a consistent MRO — це сигнал архітектурної проблеми. Рішення: перегляньте порядок батьків або виділіть спільну логіку у третій незалежний клас.

Налагодження MRO: __mro__ vs mro()

# Два способи отримати MRO — різний тип результату

# 1. Атрибут __mro__ — кортеж (tuple), незмінний
print(type(D.__mro__))   # <class 'tuple'>
print(D.__mro__)         # (<class 'D'>, <class 'B'>, ..., <class 'object'>)

# 2. Метод mro() — список (list), зручніший для ітерації
print(type(D.mro()))     # <class 'list'>
print(D.mro())           # [<class 'D'>, <class 'B'>, ..., <class 'object'>]

# Практичне використання: пошук класу, що надає метод
def find_method_owner(cls, method_name: str) -> type | None:
    """Знаходить, у якому класі фактично визначено метод."""
    for klass in cls.__mro__:
        if method_name in klass.__dict__:  # __dict__ — лише власні атрибути!
            return klass
    return None

print(find_method_owner(D, "speak"))   # <class 'D'>  — D.speak існує
print(find_method_owner(D, "__init__")) # <class 'object'> — жоден не визначив
Порівняння **mro** та mro()
$ python mro_inspect.py
type(D.__mro__): <class 'tuple'>
type(D.mro()): <class 'list'>
find_method_owner(D, "speak"): <class 'D'>
find_method_owner(D, "__init__"): <class 'object'>

Частина V: CPython Internals — як MRO зберігається у пам'яті

PyTypeObject та tp_mro

На рівні CPython (реалізація Python на C) кожен клас Python є екземпляром структури PyTypeObject. MRO зберігається у полі tp_mro цієї структури як звичайний кортеж Python-об'єктів.

/* Спрощено з CPython/Include/cpython/object.h */
typedef struct _typeobject {
    PyObject_VAR_HEAD
    const char *tp_name;        /* ім'я типу */
    Py_ssize_t  tp_basicsize;   /* розмір екземпляра */
    /* ... інші поля ... */
    PyObject   *tp_mro;         /* кортеж MRO — обчислюється C3 при type.__new__ */
    PyObject   *tp_bases;       /* кортеж батьківських класів (__bases__) */
    PyObject   *tp_base;        /* перший батьківський клас (__base__) */
    /* ... */
} PyTypeObject;

Ключові факти про tp_mro:

  • MRO обчислюється один раз — під час виконання type.__new__() при оголошенні класу
  • Результат кешується у tp_mro назавжди (клас незмінний після створення)
  • Кожен виклик obj.method() використовує цей кеш — пошук методів є O(n) по довжині MRO
  • Атрибут cls.__mro__ — це Python-обгортка навколо tp_mro

Як Python шукає метод: LOAD_ATTR та type_getattro

Коли Python виконує obj.method(), відбуваються такі кроки на рівні CPython:

# Ілюстрація Python-еквівалента алгоритму type_getattro
def type_getattro(obj, name: str):
    """
    Спрощений Python-еквівалент C-функції type_getattro з CPython.
    Саме цей алгоритм виконується при кожному доступі до атрибута.
    """
    obj_type = type(obj)

    # 1. Шукаємо дескриптор у MRO (перевіряємо __dict__ кожного класу)
    meta_attribute = None
    for base in obj_type.__mro__:
        if name in base.__dict__:
            meta_attribute = base.__dict__[name]
            break

    # 2. Якщо знайдений атрибут — дескриптор даних (має __set__) — він має пріоритет
    if meta_attribute is not None and hasattr(meta_attribute, '__set__'):
        return meta_attribute.__get__(obj, obj_type)  # виклик дескриптора

    # 3. Перевіряємо власний __dict__ екземпляра
    if name in obj.__dict__:
        return obj.__dict__[name]

    # 4. Повертаємо non-data дескриптор або просто атрибут класу
    if meta_attribute is not None:
        if hasattr(meta_attribute, '__get__'):
            return meta_attribute.__get__(obj, obj_type)  # наприклад, метод
        return meta_attribute

    raise AttributeError(f"'{obj_type.__name__}' object has no attribute '{name}'")
Це пояснює, чому функції класу доступні через екземпляр: функція є non-data дескриптором (має __get__, але не __set__). При доступі через екземпляр викликається function.__get__(obj, type), що повертає зв'язаний метод (bound method) з вже підставленим self.

Дескрипторний протокол: data vs non-data

Алгоритм type_getattro вище згадує дескриптори — об'єкти, що реалізують спеціальні методи __get__, __set__ і/або __delete__. Розуміння різниці між двома типами дескрипторів є ключовим для правильного трактування пріоритету атрибутів.

Data Descriptor (дескриптор даних)
реалізує __get__ + __set__ (або __delete__)
Має вищий пріоритет за атрибути екземпляра (obj.__dict__). Типовий приклад — property. При записі obj.attr = value викликається descriptor.__set__(obj, value), а не безпосередній запис у obj.__dict__.
Non-data Descriptor (дескриптор без запису)
реалізує лише __get__
Має нижчий пріоритет за атрибути екземпляра. Типовий приклад — звичайна функція (метод). Якщо в obj.__dict__ є атрибут з таким самим ім'ям, він тіньує non-data дескриптор.
# Ілюстрація пріоритетів дескрипторів

class DataDesc:
    """Data descriptor: має __get__ та __set__."""
    def __get__(self, obj, objtype=None):
        return obj.__dict__.get('_data_value', 'default')
    def __set__(self, obj, value):
        print(f"DataDesc.__set__({value!r})")
        obj.__dict__['_data_value'] = value

class NonDataDesc:
    """Non-data descriptor: лише __get__."""
    def __get__(self, obj, objtype=None):
        return 'non_data_result'

class MyClass:
    data_attr    = DataDesc()      # data descriptor
    nondata_attr = NonDataDesc()   # non-data descriptor

obj = MyClass()

# Data descriptor: __set__ перехоплює присвоєння
obj.data_attr = "hello"           # → DataDesc.__set__('hello')
print(obj.data_attr)              # → DataDesc.__get__ → 'hello'

# Non-data descriptor: атрибут екземпляра ПЕРЕМАГАЄ
obj.__dict__['nondata_attr'] = 'instance_value'
print(obj.nondata_attr)           # → 'instance_value' (не NonDataDesc!)

# property — це data descriptor:
class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property  # data descriptor: __get__ + __set__ + __delete__
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("Радіус не може бути від'ємним")
        self._radius = value

c = Circle(5)
c.radius = 10     # → property.__set__ → перевірка → c._radius = 10
print(c.radius)   # → property.__get__ → 10
# c.__dict__ не містить 'radius' — лише '_radius'
print(c.__dict__) # {'_radius': 10}
Практичне значення: коли ви використовуєте @property або @cached_property, ці дескриптори завжди перехоплюють доступ до атрибута навіть якщо в obj.__dict__ є запис з тим самим ім'ям (у разі data descriptor). Саме тому присвоєння obj.radius = -1 викликає сеттер, а не записує у __dict__.

Вимірювання: вартість пошуку по MRO

# mro_performance.py
import timeit

class A: pass
class B(A): pass
class C(B): pass
class D(C): pass
class E(D): pass

# Метод визначено тільки в A (кінець MRO)
class A:
    def method(self): return 42

# Наскільки повільніше шукати метод через 4 рівні vs через 0?
e = E()
a = A() if hasattr(A, 'method') else E()

# Benchmark
t_short = timeit.timeit(lambda: a.method(), number=1_000_000)
t_long  = timeit.timeit(lambda: e.method(), number=1_000_000)

print(f"Виклик через 1 рівень MRO: {t_short:.3f}с")
print(f"Виклик через 5 рівнів MRO: {t_long:.3f}с")
print(f"Різниця: {(t_long/t_short - 1)*100:.1f}%")
python mro_performance.py
$ python mro_performance.py
Виклик через 1 рівень MRO: 0.041с
Виклик через 5 рівнів MRO: 0.048с
Різниця: ~17% # для 1 млн викликів — незначно

Накладні витрати пошуку по MRO є незначними для більшості застосунків. CPython додатково використовує inline cache (починаючи з Python 3.11) для кешування результату пошуку методу безпосередньо у байткоді — повторні виклики того самого методу фактично безкоштовні.


Частина VI: Liskov Substitution Principle та правильне наслідування

LSP: математична основа правильного IS-A

Принцип підстановки Барбари Лісков (LSP) стверджує: якщо S є підтипом T, то об'єкти типу T можна замінити об'єктами типу S без зміни коректності програми.

Простіше: підклас повинен повністю виконувати контракт батьківського класу.

# ❌ Порушення LSP: підклас змінює очікувану поведінку

class Rectangle:
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height

    def set_width(self, w: float) -> None:
        self.width = w

    def set_height(self, h: float) -> None:
        self.height = h


class Square(Rectangle):
    """❌ НЕПРАВИЛЬНО: квадрат порушує LSP при наслідуванні від прямокутника."""

    def set_width(self, w: float) -> None:
        # Квадрат вимушений змінювати обидва розміри одночасно —
        # це порушує контракт Rectangle
        self.width = w
        self.height = w

    def set_height(self, h: float) -> None:
        self.width = h
        self.height = h


def test_rectangle_contract(rect: Rectangle) -> None:
    """Тест, що повинен проходити для будь-якого Rectangle та його підкласів."""
    rect.set_width(5)
    rect.set_height(10)
    expected_area = 5 * 10  # 50
    actual_area = rect.area()
    assert actual_area == expected_area, \
        f"LSP ПОРУШЕНО: очікувалось {expected_area}, отримано {actual_area}"
    print(f"Контракт виконано: {actual_area}")

r = Rectangle(3, 4)
test_rectangle_contract(r)   # ✅ Площа: 50

s = Square(3, 3)
test_rectangle_contract(s)   # ❌ AssertionError: LSP порушено!
# Після set_height(10) — width теж стало 10 → area = 100, а не 50
Демонстрація порушення LSP
$ python lsp_demo.py
Контракт виконано: 50
AssertionError: LSP ПОРУШЕНО: очікувалось 50, отримано 100

Правильне вирішення: якщо Square не може повністю виконати контракт Rectangle, вони не повинні бути у відношенні IS-A. Обидва можуть наслідувати від спільного абстрактного класу Shape, який не нав'язує незалежні сеттери розмірів.

# ✅ Правильна архітектура: спільний предок без порушення контракту
import math
from abc import ABC, abstractmethod

class Shape(ABC):
    """Абстрактна фігура: спільний предок Rectangle та Square."""

    @abstractmethod
    def area(self) -> float: ...

    @abstractmethod
    def perimeter(self) -> float: ...


class Rectangle(Shape):
    """Прямокутник: незалежні ширина та висота."""

    def __init__(self, width: float, height: float):
        self.width  = width
        self.height = height

    def area(self)      -> float: return self.width * self.height
    def perimeter(self) -> float: return 2 * (self.width + self.height)


class Square(Shape):
    """
    Квадрат: єдина сторона.
    НЕ наслідує Rectangle — вони обидва Shape, але не пов'язані IS-A.
    """

    def __init__(self, side: float):
        self.side = side

    def area(self)      -> float: return self.side ** 2
    def perimeter(self) -> float: return 4 * self.side


# Тепер LSP виконується для обох:
def print_shape_info(shape: Shape) -> None:
    print(f"{type(shape).__name__}: area={shape.area():.2f}, perimeter={shape.perimeter():.2f}")

for s in [Rectangle(4, 6), Square(5)]:
    print_shape_info(s)   # ✅ Будь-який Shape працює коректно
Спільний патерн вирішення LSP-конфліктів: якщо підклас вимушений звужувати (обмежувати) поведінку батька або змінювати семантику його методів — винесіть спільну поведінку у нейтральний абстрактний клас, від якого обидва класи наслідують незалежно. Це зберігає поліморфізм без порушення контракту.
LSP — це ваш критерій правильності наслідування. Перед тим як написати class Child(Parent), запитайте: «Чи можу я замінити кожне використання Parent на Child без зміни коректності програми?» Якщо ні — переходьте до композиції або перегляньте ієрархію абстракцій.

Частина VII: Патерн Mixins (Класи-домішки)

Концепція та правила проектування

Принципове розмежування: наслідування моделює онтологічні відносини предметної галузі (IS-A), тоді як Mixins є технічним механізмом горизонтального перевикористання коду, що не несе семантичного навантаження.

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

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam ArrowColor #6366f1
skinparam class {
    BackgroundColor #f3f4f6
    BorderColor #d1d5db
}

class JSONSerializableMixin #d1fae5 {
    + to_json() : str
    + from_json(s: str) : Self
}

class LoggingMixin #fef3c7 {
    + log(msg: str) : void
    + log_error(err: Exception) : void
}

class ValidationMixin #dbeafe {
    + validate() : bool
    + assert_valid() : void
}

class Product #f3f4f6 {
    + name : str
    + price : float
}

class User #f3f4f6 {
    + username : str
    + email : str
}

class Order #f3f4f6 {
    + order_id : str
    + items : list
}

Product --|> JSONSerializableMixin
Product --|> LoggingMixin
Product --|> ValidationMixin

User --|> JSONSerializableMixin
User --|> LoggingMixin

Order --|> JSONSerializableMixin
Order --|> ValidationMixin

note bottom
  Mixins — горизонтальне розширення.
  Один Mixin використовується у
  непов'язаних класах без успадкування
  реального IS-A відношення.
end note
@enduml

Без власного стану

Домішка не повинна зберігати стан у власному __init__. Якщо __init__ потрібен — він обов'язково має викликати super().__init__(*args, **kwargs), щоб не обривати ланцюжок MRO.

Single Responsibility

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

Порядок у наслідуванні

Домішки вказуються ліворуч від основного базового класу. Це забезпечує їм вищий пріоритет у MRO — їхні методи перехоплюють виклики першими.

Суфікс 'Mixin'

Назва домішки має закінчуватися на Mixin. Це конвенція, що сигналізує читачу коду: «цей клас не призначений для самостійного використання».

Практична реалізація: повноцінний набір домішок

# mixins.py
import json
import hashlib
from datetime import datetime
from typing import Any


class JSONSerializableMixin:
    """
    Домішка для автоматичної JSON-серіалізації та десеріалізації.
    Ігнорує атрибути з підкресленням (_protected, __private).
    """

    def to_json(self) -> str:
        """Серіалізує публічні атрибути об'єкта у JSON-рядок."""
        public_data = {
            key: value
            for key, value in self.__dict__.items()
            if not key.startswith('_')
        }
        return json.dumps(public_data, ensure_ascii=False, default=str)

    @classmethod
    def from_json(cls, json_str: str) -> "JSONSerializableMixin":
        """Десеріалізує JSON-рядок у новий об'єкт класу."""
        data = json.loads(json_str)
        return cls(**data)

    def to_dict(self) -> dict[str, Any]:
        """Повертає публічні атрибути як словник."""
        return {
            key: value
            for key, value in self.__dict__.items()
            if not key.startswith('_')
        }


class LoggingMixin:
    """
    Домішка для структурованого логування операцій.
    Автоматично підставляє ім'я реального класу в лог.
    """

    def log(self, message: str, level: str = "INFO") -> None:
        timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
        class_name = self.__class__.__name__
        print(f"[{timestamp}] [{level:5}] [{class_name}] {message}")

    def log_error(self, error: Exception) -> None:
        self.log(f"Помилка: {type(error).__name__}: {error}", level="ERROR")


class ValidationMixin:
    """
    Домішка для декларативної валідації даних.
    Підкласи можуть перевизначити `_validation_rules` — список функцій-перевірок.
    """

    _validation_rules: list = []  # клас-рівень: перевизначте у підкласі

    def validate(self) -> list[str]:
        """Запускає всі правила валідації. Повертає список помилок."""
        errors = []
        for rule in self._validation_rules:
            error = rule(self)
            if error:
                errors.append(error)
        return errors

    def assert_valid(self) -> None:
        """Перевіряє валідність або кидає ValueError зі списком помилок."""
        errors = self.validate()
        if errors:
            raise ValueError(f"Помилки валідації: {'; '.join(errors)}")


class HashableMixin:
    """
    Домішка для генерації хешу об'єкта на основі його JSON-представлення.
    Вимагає JSONSerializableMixin.
    """

    @property
    def content_hash(self) -> str:
        """SHA-256 хеш вмісту об'єкта. Ідентичні дані → ідентичний хеш."""
        if not hasattr(self, 'to_json'):
            raise TypeError("HashableMixin вимагає JSONSerializableMixin")
        content = self.to_json().encode('utf-8')
        return hashlib.sha256(content).hexdigest()[:16]
# business_entities.py
from mixins import JSONSerializableMixin, LoggingMixin, ValidationMixin, HashableMixin


class Product(JSONSerializableMixin, LoggingMixin, ValidationMixin, HashableMixin):
    """Продукт: поєднує всі чотири домішки."""

    _validation_rules = [
        lambda self: "Ціна має бути > 0" if self.price <= 0 else None,
        lambda self: "Назва не може бути порожньою" if not self.name.strip() else None,
        lambda self: "Кількість не може бути від'ємною" if self.stock < 0 else None,
    ]

    def __init__(self, name: str, price: float, stock: int = 0):
        self.name = name
        self.price = price
        self.stock = stock
        self.log(f"Створено продукт '{self.name}' (ціна: {self.price} грн)")

    def __repr__(self) -> str:
        return f"Product(name={self.name!r}, price={self.price})"


class User(JSONSerializableMixin, LoggingMixin, ValidationMixin):
    """Користувач: серіалізація, логування, валідація (без хешування)."""

    _validation_rules = [
        lambda self: "Username занадто короткий" if len(self.username) < 3 else None,
        lambda self: "Email повинен містити '@'" if '@' not in self.email else None,
    ]

    def __init__(self, username: str, email: str):
        self.username = username
        self.email = email
        self._password_hash = "..."  # захищений — не потрапить у to_json()
        self.log(f"Зареєстровано користувача {self.username!r}")

    def __repr__(self) -> str:
        return f"User(username={self.username!r})"
# demo.py
from business_entities import Product, User

print("=== Демонстрація Mixins ===\n")

# ── Product ────────────────────────────────────────────────────────────────────
prod = Product("MacBook Pro", 89999.0, stock=5)
print(f"JSON: {prod.to_json()}")
print(f"Hash: {prod.content_hash}")

# Валідація
errors = prod.validate()
print(f"Помилки валідації: {errors or 'немає'}")

# Невалідний продукт
bad = Product("", -100.0, stock=-1)
errors = bad.validate()
print(f"Помилки bad-продукту: {errors}")

print()

# ── User ───────────────────────────────────────────────────────────────────────
user = User("arakviel", "arakviel@example.com")
print(f"JSON: {user.to_json()}")
# Зверніть увагу: _password_hash відсутній!

# MRO
print(f"\nMRO Product: {[c.__name__ for c in Product.__mro__]}")
print(f"MRO User:    {[c.__name__ for c in User.__mro__]}")
python demo.py
$ python demo.py
=== Демонстрація Mixins ===
[14:22:01.341] [INFO ] [Product] Створено продукт 'MacBook Pro' (ціна: 89999.0 грн)
JSON: {"name": "MacBook Pro", "price": 89999.0, "stock": 5}
Hash: 3f8a91bc2e7d4012
Помилки валідації: немає
[14:22:01.342] [INFO ] [Product] Створено продукт '' (ціна: -100.0 грн)
Помилки bad-продукту: ['Ціна має бути > 0', 'Назва не може бути порожньою', 'Кількість не може бути від'ємною']
[14:22:01.343] [INFO ] [User] Зареєстровано користувача 'arakviel'
JSON: {"username": "arakviel", "email": "arakviel@example.com"}
# _password_hash відфільтровано автоматично!
MRO Product: ['Product', 'JSONSerializableMixin', 'LoggingMixin', 'ValidationMixin', 'HashableMixin', 'object']
MRO User: ['User', 'JSONSerializableMixin', 'LoggingMixin', 'ValidationMixin', 'object']

Антипатерни Mixin: що не слід робити

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

❌ Mixin зі станом

Mixin з власним __init__, що зберігає стан без передачі **kwargs далі через super(), обриває ланцюжок ініціалізації MRO. Усі наступні класи у черзі залишаться неініціалізованими.

❌ Залежності між Mixin'ами

Якщо один Mixin неявно покладається на методи іншого (наприклад, HashableMixin потребує to_json з JSONSerializableMixin), це прихована залежність, яка не видна зі сигнатури класу. Документуйте такі вимоги явно або перевіряйте через hasattr.

❌ Занадто глибокі ланцюжки

Коли клас наслідує 6+ Mixin'ів — це сигнал про необхідність рефакторингу: можливо, частину функціональності слід перемістити у композицію (HAS-A), або виділити проміжний базовий клас.

❌ Mixin перевизначає бізнес-метод

Мixin не повинен перевизначати методи, що несуть бізнес-логіку. Він має лише розширювати функціональність «навколо» основного методу (через super()), а не замінювати саму логіку.
# ❌ Антипатерн 1: Mixin зі станом без super().__init__(**kwargs)
class BadCacheMixin:
    def __init__(self):
        self._cache = {}  # ← ПОМИЛКА: kwargs не передаються!
        # Якщо ця домішка у ланцюжку, наступні __init__ ніколи не викличуться


# ✅ Правильно: завжди передавайте **kwargs
class GoodCacheMixin:
    def __init__(self, **kwargs):
        super().__init__(**kwargs)  # ← ланцюжок не обривається
        self._cache: dict = {}

    def get_cached(self, key: str):
        return self._cache.get(key)

    def set_cached(self, key: str, value) -> None:
        self._cache[key] = value


# ❌ Антипатерн 2: прихована залежність між Mixin'ами
class BadHashMixin:
    @property
    def content_hash(self) -> str:
        # Неявна вимога: об'єкт повинен мати метод to_json()
        # Якщо JSONSerializableMixin не підключений — RuntimeError
        return hashlib.sha256(self.to_json().encode()).hexdigest()[:16]  # type: ignore


# ✅ Правильно: явна перевірка з інформативним повідомленням
class SafeHashMixin:
    @property
    def content_hash(self) -> str:
        if not hasattr(self, 'to_json'):
            raise TypeError(
                f"{type(self).__name__} використовує SafeHashMixin, але не має методу "
                f"to_json(). Додайте JSONSerializableMixin до переліку батьків."
            )
        return hashlib.sha256(self.to_json().encode()).hexdigest()[:16]

__init_subclass__: сучасна альтернатива метакласам

__init_subclass__ (PEP 487, Python 3.6+) — це метод, що автоматично викликається Python щоразу, коли оголошується новий підклас даного класу. Це дозволяє базовому класу реагувати на факт успадкування без використання метакласів.

class Base:
    def __init_subclass__(cls, /, **kwargs):
        """
        Викликається при оголошенні КОЖНОГО підкласу Base.
        cls — це щойно створений підклас (не екземпляр!).
        kwargs — ключові аргументи, передані в дужках при оголошенні.
        """
        super().__init_subclass__(**kwargs)  # обов'язково для кооперативності
        print(f"Новий підклас: {cls.__name__}")

class Child(Base):      # → друкує: «Новий підклас: Child»
    pass

class GrandChild(Child): # → друкує: «Новий підклас: GrandChild»
    pass

Практичне застосування — автоматичний реєстр без метакласів:

# auto_registry.py
from abc import ABC, abstractmethod

class Serializer(ABC):
    """
    Базовий клас для серіалізаторів з автоматичним реєстром форматів.
    Підкласи оголошуються з аргументом format_name='...' у дужках.
    """
    _registry: dict[str, type["Serializer"]] = {}

    def __init_subclass__(cls, format_name: str | None = None, **kwargs) -> None:
        super().__init_subclass__(**kwargs)
        if format_name is not None:
            # Реєструємо підклас у момент його оголошення
            Serializer._registry[format_name] = cls

    @abstractmethod
    def dumps(self, data: object) -> str: ...

    @abstractmethod
    def loads(self, raw: str) -> object: ...

    @classmethod
    def for_format(cls, fmt: str) -> "Serializer":
        try:
            return cls._registry[fmt]()
        except KeyError:
            raise ValueError(f"Невідомий формат: {fmt!r}. Доступні: {list(cls._registry)}")


# Реєстрація відбувається АВТОМАТИЧНО при оголошенні класу:
class JsonSerializer(Serializer, format_name="json"):
    def dumps(self, data) -> str:
        import json; return json.dumps(data, ensure_ascii=False)
    def loads(self, raw):
        import json; return json.loads(raw)

class YamlSerializer(Serializer, format_name="yaml"):
    def dumps(self, data) -> str:
        return "\n".join(f"{k}: {v}" for k, v in data.items())
    def loads(self, raw):
        return dict(line.split(": ", 1) for line in raw.splitlines() if ": " in line)


# Використання фабрики — жодного явного імпорту конкретних класів:
serializer = Serializer.for_format("json")
print(serializer.dumps({"name": "Alice", "age": 30}))
# {"name": "Alice", "age": 30}

print(Serializer._registry)
# {'json': <class 'JsonSerializer'>, 'yaml': <class 'YamlSerializer'>}
Ключова перевага __init_subclass__ над метакласами: метакласи змінюють сам процес створення класу і можуть вступати в конфлікт між собою (неможливо мати два різні метакласи в одній ієрархії без спеціального об'єднання). __init_subclass__ — це звичайний метод, що бере участь у MRO за стандартними правилами кооперативного наслідування через super().__init_subclass__(**kwargs).

Частина VIII: Практичні завдання

Рівень 1 (Базовий): Ієрархія геометричних фігур

Закріпіть синтаксис одиночного наслідування, super() та перевизначення методів.

Завдання: Реалізуйте ієрархію Shape → Polygon → Triangle/Rectangle → Square. Кожен клас повинен коректно передавати аргументи через super().__init__() та перевизначати метод area().

# shapes.py
import math


class Shape:
    """Абстрактний базовий клас для фігур."""

    def __init__(self, color: str = "black"):
        self.color = color

    def area(self) -> float:
        raise NotImplementedError(f"{type(self).__name__} має реалізувати area()")

    def perimeter(self) -> float:
        raise NotImplementedError(f"{type(self).__name__} має реалізувати perimeter()")

    def describe(self) -> str:
        return (
            f"{self.__class__.__name__} [{self.color}]: "
            f"площа={self.area():.2f}, периметр={self.perimeter():.2f}"
        )


class Polygon(Shape):
    """Багатокутник: знає кількість сторін."""

    def __init__(self, sides: int, color: str = "black"):
        super().__init__(color)
        self.sides = sides

    def describe(self) -> str:
        base = super().describe()
        return f"{base}, сторін={self.sides}"


class Rectangle(Polygon):
    def __init__(self, width: float, height: float, color: str = "black"):
        super().__init__(sides=4, color=color)
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height

    def perimeter(self) -> float:
        return 2 * (self.width + self.height)


class Square(Rectangle):
    def __init__(self, side: float, color: str = "black"):
        super().__init__(width=side, height=side, color=color)
        self.side = side

    # Не перевизначаємо area() та perimeter() — Rectangle вже правильно рахує


class Triangle(Polygon):
    def __init__(self, a: float, b: float, c: float, color: str = "black"):
        if a + b <= c or a + c <= b or b + c <= a:
            raise ValueError(f"Недопустимі сторони: {a}, {b}, {c}")
        super().__init__(sides=3, color=color)
        self.a, self.b, self.c = a, b, c

    def area(self) -> float:  # Формула Герона
        s = self.perimeter() / 2
        return math.sqrt(s * (s - self.a) * (s - self.b) * (s - self.c))

    def perimeter(self) -> float:
        return self.a + self.b + self.c


# Тест
shapes: list[Shape] = [
    Rectangle(4, 6, "blue"),
    Square(5, "red"),
    Triangle(3, 4, 5, "green"),
]

for shape in shapes:
    print(shape.describe())
    print(f"  isinstance(shape, Shape):   {isinstance(shape, Shape)}")
    print(f"  isinstance(shape, Polygon): {isinstance(shape, Polygon)}")
    print()

# Перевірка MRO
print("Square MRO:", [c.__name__ for c in Square.__mro__])
python shapes.py
$ python shapes.py
Rectangle [blue]: площа=24.00, периметр=20.00, сторін=4
isinstance(shape, Shape): True
isinstance(shape, Polygon): True
Square [red]: площа=25.00, периметр=20.00, сторін=4
isinstance(shape, Shape): True
isinstance(shape, Polygon): True
Triangle [green]: площа=6.00, периметр=12.00, сторін=3
isinstance(shape, Shape): True
isinstance(shape, Polygon): True
Square MRO: ['Square', 'Rectangle', 'Polygon', 'Shape', 'object']

Рівень 2 (Середній): Система банківських транзакцій

Реальний production-сценарій: комбінація лінійного наслідування та Mixins у системі фінансових транзакцій.

python main.py
$ python main.py
=== MRO SecureDepositTransaction ===
SecureDepositTransaction
JSONSerializableMixin
AuditLogMixin
ValidatedTransaction
Transaction
object
=== Успішна транзакція ===
→ SecureDepositTransaction.__init__(depositor='Олексій Коваленко')
→ ValidatedTransaction.__init__(max_limit=10000.0)
→ Transaction.__init__(amount=5000.0)
[2026-06-15T14:22:01.341] [AUDIT/SecureDepositTransaction] START: Депозит для 'Олексій Коваленко'
[2026-06-15T14:22:01.342] [AUDIT/SecureDepositTransaction] SUCCESS: Депозит $5000.00 виконано
JSON: {"tx_id": "f84e2a1b", "amount": 5000.0, "timestamp": "...", "max_limit": 10000.0, "depositor_name": "Олексій Коваленко"}
=== Перевищення ліміту ===
→ SecureDepositTransaction.__init__(depositor='Ірина Петренко')
→ ValidatedTransaction.__init__(max_limit=10000.0)
→ Transaction.__init__(amount=15000.0)
[2026-06-15T14:22:01.343] [AUDIT/SecureDepositTransaction] FAILED: Перевищено ліміт: $10,000.00
Зловлено: Перевищено ліміт: $10,000.00

Рівень 3 (Advanced): Міні-фреймворк реєстру плагінів через __init_subclass__

Реалізуйте систему плагінів для обробки даних. Кожен плагін автоматично реєструється при оголошенні класу — без явних викликів реєстрації. Це сучасна альтернатива метакласам для Python 3.6+.

# plugin_framework.py
from abc import ABC, abstractmethod
from typing import ClassVar


class DataProcessor(ABC):
    """
    Базовий клас для плагінів обробки даних.
    Автоматичний реєстр через __init_subclass__ (PEP 487).
    """

    # Реєстр: {ім'я_формату: клас_плагіна}
    _registry: ClassVar[dict[str, type["DataProcessor"]]] = {}

    def __init_subclass__(cls, format_name: str | None = None, **kwargs) -> None:
        """
        Викликається Python АВТОМАТИЧНО при оголошенні кожного підкласу.
        Це альтернатива метакласам — чиста та явна.
        """
        super().__init_subclass__(**kwargs)
        if format_name is not None:
            DataProcessor._registry[format_name.lower()] = cls
            print(f"[Registry] Зареєстровано: '{format_name}' → {cls.__name__}")

    # ── Абстрактний інтерфейс ─────────────────────────────────────────────────

    @abstractmethod
    def parse(self, raw: str) -> list[dict]:
        """Розбирає вхідний рядок у список записів."""
        ...

    @abstractmethod
    def serialize(self, records: list[dict]) -> str:
        """Серіалізує список записів у вихідний формат."""
        ...

    # ── Конкретні методи (шаблонний метод) ───────────────────────────────────

    def process(self, raw: str, transform=None) -> str:
        """
        Шаблонний метод: parse → transform → serialize.
        Підкласи реалізують parse/serialize, а transform — зовнішня функція.
        """
        records = self.parse(raw)
        if transform is not None:
            records = [transform(r) for r in records]
        return self.serialize(records)

    # ── Фабричний метод ───────────────────────────────────────────────────────

    @classmethod
    def for_format(cls, format_name: str) -> "DataProcessor":
        """Фабрика: повертає екземпляр плагіна для вказаного формату."""
        plugin_cls = cls._registry.get(format_name.lower())
        if plugin_cls is None:
            available = list(cls._registry.keys())
            raise ValueError(
                f"Невідомий формат: {format_name!r}. "
                f"Доступні: {available}"
            )
        return plugin_cls()

    @classmethod
    def available_formats(cls) -> list[str]:
        return sorted(cls._registry.keys())


# ── Конкретні плагіни: автоматично реєструються при оголошенні ────────────────

class CsvProcessor(DataProcessor, format_name="csv"):
    """CSV-плагін: format_name='csv' → автореєстрація."""

    def parse(self, raw: str) -> list[dict]:
        lines = [l.strip() for l in raw.strip().splitlines() if l.strip()]
        if not lines:
            return []
        headers = [h.strip() for h in lines[0].split(',')]
        result = []
        for line in lines[1:]:
            values = [v.strip() for v in line.split(',')]
            result.append(dict(zip(headers, values)))
        return result

    def serialize(self, records: list[dict]) -> str:
        if not records:
            return ""
        headers = list(records[0].keys())
        rows = [",".join(headers)]
        for record in records:
            rows.append(",".join(str(record.get(h, "")) for h in headers))
        return "\n".join(rows)


class JsonLinesProcessor(DataProcessor, format_name="jsonl"):
    """JSONL-плагін (один JSON-об'єкт на рядок)."""
    import json as _json

    def parse(self, raw: str) -> list[dict]:
        import json
        return [json.loads(line) for line in raw.strip().splitlines() if line.strip()]

    def serialize(self, records: list[dict]) -> str:
        import json
        return "\n".join(json.dumps(r, ensure_ascii=False) for r in records)


class TsvProcessor(DataProcessor, format_name="tsv"):
    """TSV-плагін (Tab-Separated Values)."""

    def parse(self, raw: str) -> list[dict]:
        lines = raw.strip().splitlines()
        if not lines:
            return []
        headers = lines[0].split('\t')
        return [dict(zip(headers, line.split('\t'))) for line in lines[1:]]

    def serialize(self, records: list[dict]) -> str:
        if not records:
            return ""
        headers = list(records[0].keys())
        rows = ["\t".join(headers)]
        for r in records:
            rows.append("\t".join(str(r.get(h, "")) for r in [r] for h in headers))
        return "\n".join(rows)


# ── Демонстрація ──────────────────────────────────────────────────────────────

def main() -> None:
    print(f"\nДоступні формати: {DataProcessor.available_formats()}")
    print(f"Реєстр: {list(DataProcessor._registry.keys())}")

    # CSV → обробка → JSONL (конвертація формату)
    csv_data = """name,age,city
Олена,28,Київ
Іван,35,Харків
Марія,22,Львів"""

    print("\n=== CSV → parse → JSONL serialize ===")
    csv_proc = DataProcessor.for_format("csv")
    records = csv_proc.parse(csv_data)
    print(f"Розібрано записів: {len(records)}")

    jsonl_proc = DataProcessor.for_format("jsonl")
    output = jsonl_proc.serialize(records)
    print("JSONL:")
    print(output)

    # Шаблонний метод з трансформацією
    print("\n=== CSV → process → uppercase names ===")
    def uppercase_name(r: dict) -> dict:
        return {**r, "name": r["name"].upper()}

    result = csv_proc.process(csv_data, transform=uppercase_name)
    print(result)

    # Невідомий формат
    try:
        DataProcessor.for_format("xml")
    except ValueError as e:
        print(f"\nОчікувана помилка: {e}")


if __name__ == "__main__":
    main()
python plugin_framework.py
$ python plugin_framework.py
[Registry] Зареєстровано: 'csv' → CsvProcessor
[Registry] Зареєстровано: 'jsonl' → JsonLinesProcessor
[Registry] Зареєстровано: 'tsv' → TsvProcessor
Доступні формати: ['csv', 'jsonl', 'tsv']
=== CSV → parse → JSONL serialize ===
Розібрано записів: 3
JSONL:
{"name": "Олена", "age": "28", "city": "Київ"}
{"name": "Іван", "age": "35", "city": "Харків"}
{"name": "Марія", "age": "22", "city": "Львів"}
=== CSV → process → uppercase names ===
name,age,city
ОЛЕНА,28,Київ
ІВАН,35,Харків
МАРІЯ,22,Львів
Очікувана помилка: Невідомий формат: 'xml'. Доступні: ['csv', 'jsonl', 'tsv']

Практична лабораторія: HTTP Middleware Pipeline від А до Я

Реалізуємо мінімалістичний фреймворк для обробки HTTP-запитів, що демонструє всі концепції статті в єдиній системі:

КонцепціяДе застосовано
Одиночне наслідуванняRequest, Response — базові типи даних
Кооперативний super().handle()Кожен Middleware передає запит далі по MRO
Множинне наслідування + diamondSecureLoggingHandler(LoggingMixin, AuthMixin, BaseHandler)
MixinsLoggingMixin, AuthMixin, RateLimitMixin, CORSMixin
super() у __init__ з **kwargsВсі Mixin-конструктори передають **kwargs далі
isinstance / issubclassТипізована маршрутизація запитів
__init_subclass__Автоматична реєстрація обробників маршрутів
LSPAuthMixin.handle() повністю виконує контракт BaseHandler

Архітектура системи

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam ArrowColor #6366f1
skinparam class {
    BackgroundColor #f3f4f6
    BorderColor #d1d5db
}

class Request {
    + method : str
    + path : str
    + headers : dict
    + body : str
    + user : str | None
}

class Response {
    + status : int
    + body : str
    + headers : dict
    + ok : bool <<property>>
}

class BaseHandler {
    + handle(request) : Response
    # _handle(request) : Response <<abstract>>
}

class LoggingMixin {
    + handle(request) : Response
    # _log(msg, level)
}

class AuthMixin {
    + __init__(required_roles, **kwargs)
    + handle(request) : Response
}

class RateLimitMixin {
    + __init__(max_rps, **kwargs)
    + handle(request) : Response
    - __request_log : dict
}

class CORSMixin {
    + __init__(allowed_origins, **kwargs)
    + handle(request) : Response
}

class SecureLoggingHandler {
    MRO: SecureLoggingHandler\n→ LoggingMixin → AuthMixin\n→ RateLimitMixin → CORSMixin\n→ BaseHandler
}

class RouteRegistry {
    + register(path, handler_cls)
    + dispatch(request) : Response
}

BaseHandler <|-- SecureLoggingHandler
LoggingMixin <|-- SecureLoggingHandler
AuthMixin <|-- SecureLoggingHandler
RateLimitMixin <|-- SecureLoggingHandler
CORSMixin <|-- SecureLoggingHandler

RouteRegistry --> BaseHandler : dispatches to
Request --> BaseHandler : processed by
BaseHandler --> Response : produces
@enduml

Реалізація

python main.py
$ python main.py
[Router] Зареєстровано: '/api/public' → PublicHandler
[Router] Зареєстровано: '/api/data' → ProtectedHandler
[Router] Зареєстровано: '/api/admin' → AdminHandler
MRO ProtectedHandler:
├─ ProtectedHandler
├─ LoggingMixin
├─ AuthMixin
├─ RateLimitMixin
├─ CORSMixin
├─ BaseHandler
└─ object
[1] Публічний ендпоінт:
[14:55:01] 📋 [INFO] [PublicHandler] → GET /api/public
[14:55:01] 📋 [INFO] [PublicHandler] ← ✅ 200 | GET /api/public
Відповідь: ✅ Response(200: '{"status": "public"...')
[2] Захищений ендпоінт без токена:
[14:55:01] 📋 [INFO] [ProtectedHandler] → GET /api/data
[14:55:01] ⚠️ [WARN] [ProtectedHandler] ← ❌ 401 | GET /api/data
Відповідь: ❌ Response(401: '401 Unauthorized: відсутній токен')
[3] Захищений ендпоінт з валідним токеном:
[14:55:01] 📋 [INFO] [ProtectedHandler] → GET /api/data
[14:55:01] 📋 [INFO] [ProtectedHandler] ← ✅ 200 | GET /api/data
Відповідь: ✅ Response(200: '{"status": "ok", "user": "alice"...')
CORS: https://kostyl.dev
[4] Admin ендпоінт з роллю 'user':
[14:55:01] 📋 [INFO] [AdminHandler] → GET /api/admin
[14:55:01] ⚠️ [WARN] [AdminHandler] ← ❌ 403 | GET /api/admin
Відповідь: ❌ Response(403: "403 Forbidden: потрібна роль ['admin']...")
[5] Admin ендпоінт з роллю 'admin':
[14:55:01] 📋 [INFO] [AdminHandler] → POST /api/admin
[14:55:01] 📋 [INFO] [AdminHandler] ← ✅ 200 | POST /api/admin
Відповідь: ✅ Response(200: '{"admin": true, "user": "admin"...')
[6] Rate Limiting (6 запитів при ліміті 5 req/s):
Запит #1: ✅ 200
Запит #2: ✅ 200
Запит #3: ✅ 200
Запит #4: ✅ 200
Запит #5: ✅ 200
Запит #6: ❌ 429 # Rate limit!
[7] Невідомий маршрут:
Відповідь: ❌ Response(404: '404 Not Found: ...')
[8] issubclass перевірки:
issubclass(PublicHandler, BaseHandler): True
issubclass(PublicHandler, LoggingMixin): True
issubclass(PublicHandler, AuthMixin): False
issubclass(ProtectedHandler, AuthMixin): True ← захист!
issubclass(AdminHandler, AuthMixin): True ← захист!

Підсумки та найкращі практики

IS-A vs HAS-A

Наслідуйте тільки при чіткому IS-A відношенні. Перевіряйте через LSP: чи можна замінити батька підкласом без зміни поведінки програми? Якщо ні — використовуйте композицію.

Кооперативний super()

При множинному або кооперативному наслідуванні обов'язково передавайте **kwargs через super().__init__(**kwargs). Якщо хоч один клас у ланцюжку пропустить це — object.__init__ отримає невідомі аргументи і впаде з TypeError.

Гігієна Mixins

Mixins — без стану, з суфіксом Mixin, ліворуч від основного класу. Одна домішка — одна відповідальність. Замість глибокої ієрархії Base → Loggable → Serializable → Product — плоска Product(LoggingMixin, JSONSerializableMixin).

Перевірка MRO

При будь-якій неочікуваній поведінці — негайно перевіряйте ClassName.mro(). Використовуйте find_method_owner(cls, 'method_name') щоб точно знайти, де визначено конкретний метод.

LSP як критерій

Перед кожним class Child(Parent) запитуйте: чи виконує підклас всі передумови та постумови батьківського класу? Класичний приклад порушення — Square(Rectangle).

**init_subclass** замість метакласів

Для автоматичної реєстрації підкласів у 90% випадків достатньо __init_subclass__ (PEP 487, Python 3.6+). Метакласи потрібні лише для найбільш складних сценаріїв модифікації самого процесу створення класу.
Copyright © 2026