$ sudo teach IT
🐍 Урок 3.4 · Наследование

Множественное наследование и MRO

До этого урока класс всегда имел одного родителя. Но Python позволяет наследоваться сразу от нескольких классов. Это мощная возможность — и одновременно источник одной из самых известных проблем в ООП. Разберёмся, как с этим жить.

📐 Синтаксис: class C(A, B)

Множественное наследование записывается точно так же, как обычное, только в скобках перечисляем несколько классов через запятую. Дочерний класс получает атрибуты и методы от каждого из родителей.

class A:
    def hello(self):
        return "Привет от A"

    def info(self):
        return "Я — класс A"


class B:
    def bye(self):
        return "Пока от B"

    def info(self):
        return "Я — класс B"


class C(A, B):   # ← наследуем от A и B сразу
    pass


obj = C()
print(obj.hello())   # Привет от A  (метод из A)
print(obj.bye())     # Пока от B   (метод из B)
print(obj.info())    # Я — класс A (из A, потому что A стоит первым!)
Важно: если метод с одним и тем же именем есть у нескольких родителей, Python берёт его у того, кто стоит левее в списке. В примере C(A, B) — победит A. Но это только вершина айсберга — под ней скрывается MRO, о котором поговорим чуть позже.

🧩 Когда это полезно: паттерн «Миксин»

На практике множественное наследование используется в основном для миксинов (Mixin). Слово происходит от английского «mix in» — примешивать, добавлять.

Миксин — это небольшой вспомогательный класс, который выполняет одну конкретную задачу и предназначен для «добавки» к другим классам. Он не используется самостоятельно — только как второй (или третий) родитель.

🍕 Аналогия из жизни: представь, что ты заказываешь пиццу. Основа — сам класс. Начинки — миксины. Ты можешь взять любую комбинацию начинок, не переделывая саму пиццу. Хочешь «логирование» — добавь LoggerMixin. Хочешь «экспорт в JSON» — добавь JsonMixin. Каждая начинка делает одно дело и делает его хорошо.

Давай посмотрим на реальный пример. Создадим LoggerMixin — миксин, который добавляет любому классу метод log():

class LoggerMixin:
    """Миксин: добавляет метод log() к любому классу."""

    def log(self, message):
        class_name = self.__class__.__name__
        print(f"[LOG] {class_name}: {message}")


# Используем миксин в разных классах
class User(LoggerMixin):
    def __init__(self, name):
        self.name = name

    def login(self):
        self.log(f"Пользователь {self.name} вошёл в систему")


class Order(LoggerMixin):
    def __init__(self, order_id):
        self.order_id = order_id

    def create(self):
        self.log(f"Создан заказ #{self.order_id}")


user = User("Алиса")
user.login()      # [LOG] User: Пользователь Алиса вошла в систему

order = Order(42)
order.create()    # [LOG] Order: Создан заказ #42
Красота миксина в том, что метод log() написан один раз, а используется в десятках классов. Изменил логику в LoggerMixin — изменение автоматически применилось везде. Это называется принципом «не повторяй себя» (DRY — Don't Repeat Yourself).

💎 Проблема ромба (Diamond Problem)

А теперь — та самая знаменитая проблема. Представь такую иерархию классов:

        A
       / \
      B   C
       \ /
        D

Класс A — прадед. Классы B и C оба наследуются от A. Класс D наследуется от B и C. Получается форма ромба — отсюда и название.

Теперь вопрос: если у A, B и C есть одинаковый метод who() — чей метод получит D? В некоторых языках (например, в C++) это была настоящая головная боль. Смотрим на код:

class A:
    def who(self):
        return "Я — A"


class B(A):
    def who(self):
        return "Я — B"


class C(A):
    def who(self):
        return "Я — C"


class D(B, C):   # ← наследуем от B и C (оба от A — ромб!)
    pass


d = D()
print(d.who())   # Кто победит: A, B или C?

В Python ответ чёткий и предсказуемый: победит B. Почему именно B, а не C? Потому что Python использует специальный алгоритм для определения порядка — MRO.

🗺️ MRO — порядок поиска методов

MRO расшифровывается как Method Resolution Order — «порядок разрешения методов». Это заранее составленный список: в каком порядке Python обходит классы в поисках нужного метода.

Когда ты вызываешь метод у объекта, Python не угадывает — он идёт по этому списку строго слева направо и берёт первый класс, у которого такой метод есть.

Посмотреть MRO конкретного класса можно двумя способами:

class A:
    pass

class B(A):
    pass

class C(A):
    pass

class D(B, C):
    pass


# Способ 1: атрибут __mro__ — возвращает кортеж классов
print(D.__mro__)
# (, , ,
#  , )

# Способ 2: метод mro() — возвращает список
print(D.mro())
# [, , ,
#  , ]
Читаем MRO для D: сначала ищем в D → потом в B → потом в C → потом в A → потом в базовом object. Вот почему для ромба победил B: он стоит раньше C в порядке обхода. И заметь — A встречается только один раз, в самом конце. Это ключевое свойство MRO.

⚙️ Алгоритм C3 — как Python строит MRO

Python использует алгоритм C3 Linearization (C3 линеаризация) для вычисления MRO. Не нужно знать математику за ним — важно понять три правила, которым он следует:

1️⃣
Сначала сам класс

Поиск всегда начинается с самого класса, у которого вызван метод.

2️⃣
Порядок родителей

Родители обходятся в том порядке, в котором ты их написал в скобках: class D(B, C) → сначала B, потом C.

3️⃣
Класс после потомков

Общий предок (A) появляется в MRO только после всех его потомков. Никаких дублей.

⚠️ Если MRO невозможно построить — Python выбросит исключение TypeError: Cannot create a consistent method resolution order (MRO). Это сигнал: ты запутал иерархию классов и нужно её переосмыслить.

🔗 super() идёт по MRO

В предыдущем уроке мы познакомились с super(). В контексте множественного наследования super() — это не просто «вызов родителя». Точнее сказать: super() вызывает следующий класс в MRO.

Это значит, что при правильном использовании super() все классы в цепочке получат возможность выполнить свой код. Смотрим:

class A:
    def __init__(self):
        print("A.__init__ вызван")
        super().__init__()   # идёт дальше по MRO → object


class B(A):
    def __init__(self):
        print("B.__init__ вызван")
        super().__init__()   # по MRO следующий — C (не A!)


class C(A):
    def __init__(self):
        print("C.__init__ вызван")
        super().__init__()   # по MRO следующий — A


class D(B, C):
    def __init__(self):
        print("D.__init__ вызван")
        super().__init__()   # по MRO следующий — B


# MRO для D: D → B → C → A → object
d = D()
# D.__init__ вызван
# B.__init__ вызван
# C.__init__ вызван
# A.__init__ вызван

print(D.mro())
# [D, B, C, A, object]
Обрати внимание: когда B.__init__ вызвал super(), следующим оказался не A (прямой родитель B), а C — потому что MRO для D такой: D → B → C → A → object. super() всегда смотрит на MRO текущего объекта, а не на иерархию текущего класса. Это неочевидно, но именно поэтому цепочка работает правильно.

🚫 Когда НЕ стоит использовать множественное наследование

Короткий ответ: почти всегда. Множественное наследование — мощный инструмент, но он легко превращается в запутанный код, в котором сложно понять, откуда пришёл тот или иной метод.

❌ Не делай так

Наследование от двух «полноценных» классов с похожими методами — class Duck(Bird, Fish). MRO запутает тебя и коллег.

❌ Не делай так

Глубокие ромбовидные иерархии из 4+ уровней. Никто не сможет понять, чей метод вызывается.

✅ Делай так

Миксины с одной ответственностью: class User(LoggerMixin, JsonMixin, Base). Миксины маленькие, не пересекаются, их роль очевидна.

Золотое правило: если тебе нужно взять поведение из двух мест — сначала подумай о композиции (один объект хранит другой внутри) вместо наследования. Часто это чище и понятнее. Множественное наследование оправдано в основном для лёгких миксинов.

🎯 Полный демо: несколько миксинов

Посмотрим на реалистичный пример: добавляем к классам сразу два миксина — логирование и сериализацию в словарь.

import datetime


class LoggerMixin:
    """Миксин: добавляет метод log() для вывода сообщений."""

    def log(self, message):
        timestamp = datetime.datetime.now().strftime("%H:%M:%S")
        class_name = self.__class__.__name__
        print(f"[{timestamp}] {class_name}: {message}")


class DictMixin:
    """Миксин: добавляет метод to_dict() — конвертация в словарь."""

    def to_dict(self):
        return {
            key: value
            for key, value in self.__dict__.items()
            if not key.startswith("_")
        }


# Основные бизнес-классы используют миксины
class Product(LoggerMixin, DictMixin):
    def __init__(self, name, price):
        self.name = name
        self.price = price

    def apply_discount(self, percent):
        old_price = self.price
        self.price = self.price * (1 - percent / 100)
        self.log(f"Скидка {percent}%: {old_price} → {self.price:.2f}")


class Customer(LoggerMixin, DictMixin):
    def __init__(self, username, email):
        self.username = username
        self.email = email

    def update_email(self, new_email):
        self.email = new_email
        self.log(f"Email обновлён: {new_email}")


# Демонстрация
product = Product("Ноутбук", 80000)
product.apply_discount(15)
# [14:32:10] Product: Скидка 15%: 80000 → 68000.00

print(product.to_dict())
# {'name': 'Ноутбук', 'price': 68000.0}

customer = Customer("alice", "alice@example.com")
customer.update_email("newalice@example.com")
# [14:32:10] Customer: Email обновлён: newalice@example.com

print(customer.to_dict())
# {'username': 'alice', 'email': 'newalice@example.com'}

# Смотрим MRO
print(Product.mro())
# [Product, LoggerMixin, DictMixin, object]
Что здесь хорошо: LoggerMixin и DictMixin — независимые небольшие классы. Они не знают друг о друге и не знают о бизнес-классах. Их можно добавлять к любому классу одной строкой. MRO простой и линейный — никаких ромбов.

📌 Итоги урока

  • Множественное наследование — class C(A, B) — дочерний класс получает методы от всех родителей.
  • Миксин — небольшой класс с одной задачей, предназначенный для «добавки» к другим классам.
  • Diamond Problem — когда два родителя имеют общего предка, возникает ромбовидная иерархия. Python решает это через MRO.
  • MRO (Method Resolution Order) — заранее вычисленный порядок обхода классов. Смотреть через ClassName.__mro__ или ClassName.mro().
  • Алгоритм C3 гарантирует: каждый класс встречается в MRO ровно один раз, порядок родителей соблюдается, предки идут после потомков.
  • super() в множественном наследовании идёт по MRO, а не «к прямому родителю».
  • Множественное наследование стоит использовать только для миксинов. В остальных случаях предпочти композицию.