$ sudo teach IT
МОДУЛЬ 6 · УРОК 2

Композиция vs наследование: когда что использовать

Мы знаем, что такое наследование и что такое композиция. Но на практике самый частый вопрос звучит иначе: «Вот мои два класса — что выбрать?» В этом уроке мы разберём, как принять правильное решение, какие проблемы создаёт наследование при неправильном применении, и почему в мире ООП существует знаменитый принцип «Предпочитай композицию наследованию».

⏱ ~40 минут 🎓 Для новичков 🐍 Python 3

🧪 Простой тест: задайте себе один вопрос

Прежде чем писать код, подставьте ваши классы в одну из двух фраз. Это занимает буквально три секунды — и сразу станет ясно, что выбрать.

🔗

«X — это Y»

is-a: X является разновидностью Y

Собака — это животное ✅
Круг — это фигура ✅
Студент — это человек ✅

→ Используй наследование

🧩

«X имеет Y»

has-a: X содержит Y как часть

Машина имеет двигатель ✅
Сотрудник имеет адрес ✅
Заказ имеет список товаров ✅

→ Используй композицию

💡 Примеры из реальных проектов:
Dog → Animal: «Собака — это животное» → наследование ✅
Car → Engine: «Машина — это двигатель»? Нет! «Машина имеет двигатель» → композиция ✅
Employee → Address: «Сотрудник имеет адрес» → композиция ✅
Circle → Shape: «Круг — это фигура» → наследование ✅

Звучит просто — и это действительно просто, когда вы привыкнете задавать этот вопрос. Но в практике встречаются случаи, когда разработчики игнорируют этот тест и применяют наследование там, где его не должно быть. Посмотрим, что из этого получается.

⚠️ Проблемы наследования: хрупкие иерархии

Наследование — мощный инструмент, но у него есть серьёзные недостатки, которые проявляются, когда его используют неправильно или слишком глубоко. Давайте разберём три главные проблемы.

🔗 Жёсткая связь

Дочерний класс намертво привязан к родителю. Изменение родителя может сломать дочерний класс даже без ошибок компилятора.

📦 Наследование всего лишнего

Вместе с нужным поведением дочерний класс получает все атрибуты и методы родителя, в том числе те, что ему не нужны.

🏗 Глубокие иерархии

Цепочки A → B → C → D → E становятся нечитаемыми. При глубине 4+ уже очень сложно понять, откуда берётся то или иное поведение.

Разберём классический и наглядный пример из реальной жизни — попытку сделать стек на основе встроенного списка Python через наследование.

🤔 Задача: написать класс Stack (стек) — структуру данных «последний вошёл — первый вышел» (LIFO). Метод push добавляет элемент, метод pop забирает последний. Больше ничего.

Казалось бы, список Python уже умеет append и pop — почему бы просто не унаследоваться от list?

# ❌ ПРОБЛЕМНЫЙ ВАРИАНТ: Stack наследует list
class Stack(list):
    def push(self, item):
        self.append(item)  # append — это метод list

    # pop() уже есть у list, дополнительно ничего не нужно


s = Stack()
s.push(1)
s.push(2)
s.push(3)
print(s.pop())   # 3  — работает, LIFO ✓

# Но посмотрите, что ещё «получил» наш стек в подарок от list:
s.insert(0, 999)      # Вставка в произвольное место — стек так не работает!
s.reverse()           # Разворот — зачем стеку это?
s.sort()              # Сортировка — это полностью нарушает идею стека!
print(s[0])           # Доступ по индексу — в стеке такого нет!
s.remove(2)           # Удаление произвольного элемента — нарушение LIFO!

# Хуже всего: если внутренний метод list изменится в новой версии Python,
# наш Stack тоже изменится — хотя мы этого не ожидали.
# Stack — это НЕ «разновидность списка». Это отдельная структура данных.

🚨 В чём проблема? Стек — это не разновидность списка. «Стек является списком» — ложь с точки зрения логики. Стек использует список внутри себя как хранилище. Разница принципиальная: мы получили класс, который нарушает своё же предназначение — наш «стек» позволяет делать всё что угодно с внутренними данными, хотя должен был ограничить доступ.

Это и есть хрупкая иерархия — кажется, что работает, но при малейшем изменении или неправильном использовании всё ломается. Теперь посмотрим на правильное решение.

✅ Решение: Stack через композицию

Правильный подход: Stack использует список как внутреннее хранилище, но не является им. Список скрыт внутри — пользователь видит только методы стека.

# ✅ ПРАВИЛЬНЫЙ ВАРИАНТ: Stack через композицию
class Stack:
    def __init__(self):
        # Список скрыт внутри — это деталь реализации
        # Называем с _ чтобы показать: это внутренний атрибут
        self._data = []

    def push(self, item):
        """Добавить элемент на вершину стека."""
        self._data.append(item)

    def pop(self):
        """Забрать элемент с вершины стека."""
        if self.is_empty():
            raise IndexError("pop из пустого стека")
        return self._data.pop()

    def peek(self):
        """Посмотреть на вершину стека, не забирая элемент."""
        if self.is_empty():
            raise IndexError("стек пуст")
        return self._data[-1]

    def is_empty(self):
        """Проверить, пуст ли стек."""
        return len(self._data) == 0

    def __len__(self):
        return len(self._data)

    def __repr__(self):
        return f"Stack({self._data})"


s = Stack()
s.push(1)
s.push(2)
s.push(3)
print(s)         # Stack([1, 2, 3])
print(s.peek())  # 3  — смотрим без удаления
print(s.pop())   # 3  — забираем
print(s.pop())   # 2
print(len(s))    # 1

# Теперь стек — это ТОЛЬКО стек.
# Нельзя вызвать s.sort(), s.insert(0, x), s[i] — этих методов нет.
# Интерфейс ограничен ровно тем, что должен уметь стек.

🎯 Что изменилось:
1. Список self._data — скрытая деталь реализации. Снаружи никто о нём не знает.
2. Пользователь видит строго ограниченный интерфейс: push, pop, peek, is_empty.
3. Если завтра мы захотим заменить список на deque (более быстрое хранилище) — просто изменим self._data = collections.deque() внутри. Внешний код даже не заметит.
4. Невозможно случайно «сломать» стек снаружи, вызвав лишний метод.

Вот в этом и состоит гибкость композиции: компонент можно заменить, не трогая внешний интерфейс. Наследование такой замены не даёт — вы намертво привязаны к родителю.

🔧 Гибкость композиции: лёгкая замена компонента

Один из главных плюсов композиции — компонент можно заменить во время выполнения программы. Например, уведомления можно отправлять по почте или через Telegram — в зависимости от настроек.

# Разные «движки» уведомлений
class EmailNotifier:
    def send(self, message):
        print(f"[Email] Отправляем: {message}")


class TelegramNotifier:
    def send(self, message):
        print(f"[Telegram] Отправляем: {message}")


class SMSNotifier:
    def send(self, message):
        print(f"[SMS] Отправляем: {message}")


# Сервис заказов использует уведомитель через композицию
class OrderService:
    def __init__(self, notifier):
        # Принимаем любой объект с методом send()
        self.notifier = notifier

    def place_order(self, product):
        # ... логика оформления заказа ...
        self.notifier.send(f"Заказ на '{product}' оформлен!")

    def change_notifier(self, new_notifier):
        """Заменяем способ уведомлений без изменения логики заказов."""
        self.notifier = new_notifier


# Используем Email
service = OrderService(EmailNotifier())
service.place_order("Ноутбук")
# [Email] Отправляем: Заказ на 'Ноутбук' оформлен!

# Легко переключаемся на Telegram
service.change_notifier(TelegramNotifier())
service.place_order("Мышь")
# [Telegram] Отправляем: Заказ на 'Мышь' оформлен!

# И на SMS
service.change_notifier(SMSNotifier())
service.place_order("Клавиатура")
# [SMS] Отправляем: Заказ на 'Клавиатура' оформлен!

💡 Представьте это на наследовании. Если бы OrderService наследовал EmailNotifier, то при смене на Telegram пришлось бы переписывать класс или создавать новый подкласс TelegramOrderService. А для SMS — ещё один: SMSOrderService. Иерархия разрасталась бы бесконтрольно. Через композицию один класс OrderService легко работает с любым уведомителем.

📚 Принцип «Favor composition over inheritance»

В 1994 году вышла легендарная книга «Паттерны проектирования» (Design Patterns) — её написали четыре автора, которых называют «Банда четырёх» (Gang of Four, GoF). Это Эрих Гамма, Ричард Хелм, Ральф Джонсон и Джон Влиссидес.

В самом начале книги, в разделе принципов ООП, они сформулировали правило, которое до сих пор считается одним из важнейших в разработке программ:

«Favor composition over inheritance»

«Предпочитай композицию наследованию»

— Design Patterns: Elements of Reusable Object-Oriented Software, 1994

Почему этот принцип стал таким популярным? Давайте разберём три конкретные причины, почему авторы книги его сформулировали:

🔄 Гибкость в рантайме

Наследование фиксируется на этапе написания кода. Композицию можно менять во время работы программы: подменять компоненты, переключать стратегии.

🧪 Простота тестирования

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

📐 Меньше связанность

При глубоком наследовании классы становятся намертво связаны. Изменение родителя на 5-м уровне иерархии непредсказуемо влияет на всех потомков.

⚠️ Важно понять правильно. Этот принцип не говорит «никогда не используй наследование». Он говорит: «При сомнении — выбирай композицию». Наследование остаётся мощным инструментом в правильных ситуациях. Просто таких ситуаций меньше, чем кажется начинающим разработчикам.

🏆 Когда наследование всё-таки лучше

Наследование уместно в трёх конкретных ситуациях. Посмотрим на каждую с примером кода.

1. Явная иерархия типов с отношением is-a

Когда объекты действительно являются разновидностями более общего типа — наследование идеально. Самый классический пример: геометрические фигуры.

import math

class Shape:
    """Базовый класс для всех геометрических фигур."""

    def area(self):
        """Площадь фигуры. Каждый подкласс переопределяет этот метод."""
        raise NotImplementedError("Подкласс должен реализовать area()")

    def describe(self):
        """Общее поведение для ВСЕХ фигур — определяется один раз здесь."""
        print(f"Я {self.__class__.__name__}, моя площадь: {self.area():.2f}")


class Circle(Shape):
    """Круг — это фигура. Circle is-a Shape ✅"""
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return math.pi * self.radius ** 2


class Rectangle(Shape):
    """Прямоугольник — это фигура. Rectangle is-a Shape ✅"""
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height


class Triangle(Shape):
    """Треугольник — это фигура. Triangle is-a Shape ✅"""
    def __init__(self, base, height):
        self.base = base
        self.height = height

    def area(self):
        return 0.5 * self.base * self.height


# Создаём разные фигуры
shapes = [Circle(5), Rectangle(4, 6), Triangle(3, 8)]

# Полиморфизм: один и тот же код работает со всеми фигурами
for shape in shapes:
    shape.describe()
# Я Circle, моя площадь: 78.54
# Я Rectangle, моя площадь: 24.00
# Я Triangle, моя площадь: 12.00

✅ Здесь наследование идеально по трём причинам:
1. Явная иерархия: Circle/Rectangle/Triangle действительно являются разновидностями Shape.
2. Общее поведение: метод describe() работает для всех — пишем один раз в родителе.
3. Полиморфизм: мы можем перебрать список из разных фигур и вызвать area() у каждой, не зная конкретный тип.

2. Общее поведение, которое нужно всем подклассам

Если у группы классов есть одинаковая логика — наследование позволяет написать её один раз в базовом классе. Например, все пользователи системы умеют входить в систему и менять пароль.

class User:
    """Базовый класс для всех типов пользователей."""
    def __init__(self, username, email):
        self.username = username
        self.email = email

    def login(self):
        print(f"{self.username} вошёл в систему.")

    def change_password(self, new_password):
        print(f"Пароль для {self.username} изменён.")


class Student(User):
    """Студент — это пользователь. Student is-a User ✅"""
    def __init__(self, username, email, course):
        super().__init__(username, email)
        self.course = course

    def view_lessons(self):
        print(f"{self.username} смотрит уроки курса '{self.course}'.")


class Teacher(User):
    """Преподаватель — это пользователь. Teacher is-a User ✅"""
    def __init__(self, username, email, subject):
        super().__init__(username, email)
        self.subject = subject

    def create_lesson(self, title):
        print(f"{self.username} создаёт урок: {title}")


student = Student("alice", "alice@example.com", "Python ООП")
teacher = Teacher("bob", "bob@example.com", "Python")

# Общее поведение от родителя — не дублируется
student.login()          # alice вошёл в систему.
teacher.login()          # bob вошёл в систему.

# Собственное поведение каждого класса
student.view_lessons()   # alice смотрит уроки курса 'Python ООП'.
teacher.create_lesson("Полиморфизм")  # bob создаёт урок: Полиморфизм

3. Полиморфизм: единый интерфейс для разных типов

Наследование — главный способ достичь классического полиморфизма в ООП. Когда вам нужно обработать список объектов разных типов единым способом — наследование незаменимо.

💡 Именно это мы уже видели в примере с фигурами: цикл for shape in shapes: shape.describe() работает с Circle, Rectangle и Triangle одинаково. Python смотрит на фактический тип объекта и вызывает нужный метод area(). Это и есть полиморфизм через наследование.

📊 Шпаргалка: когда что выбирать

Сведём всё, что мы изучили, в одну сравнительную таблицу. Распечатайте и держите рядом с монитором.

Критерий
Наследование
Композиция
Тест
«X — это Y» (is-a)
«X имеет Y» (has-a)
Гибкость
Низкая — зафиксировано при написании кода
Высокая — можно менять в рантайме
Связанность
Сильная — дочерний зависит от родителя
Слабая — компоненты независимы
Полиморфизм
Встроен — переопределение методов
Через интерфейс — любой объект с нужным методом
Переиспользование
Через наследование иерархий
Один компонент во многих классах
Когда применять
Явная иерархия типов, общее поведение, полиморфизм
Отношение «часть-целое», нужна замена компонента, переиспользование

⚠️ Золотое правило новичка: если вы не уверены — выбирайте композицию. Наследование сложнее переделать потом. Композицию проще расширить и проще понять читающему ваш код человеку. Именно поэтому принцип GoF звучит как «предпочитай», а не «всегда используй».

✅ Итоги урока

  • Тест выбора: «X — это Y»? → наследование. «X имеет Y»? → композиция.
  • Хрупкие иерархии: неправильное наследование создаёт жёсткую связь, лишние методы и «ломкий» код — как Stack(list).
  • Stack через композицию (self._data = []) — правильно: список скрыт, интерфейс ограничен только нужными методами.
  • Гибкость композиции: компонент можно заменить во время работы программы — EmailNotifier на TelegramNotifier без переписывания OrderService.
  • Принцип GoF «Favor composition over inheritance» — при сомнении выбирай композицию.
  • Наследование уместно при явной иерархии is-a, общем поведении и полиморфизме — как Shape → Circle / Rectangle / Triangle.
  • Глубина иерархии 4+ уровней — сигнал тревоги: скорее всего, здесь нужна композиция.

👉 В следующем уроке мы изучим агрегацию — ещё один способ связывать объекты. В отличие от «жёсткой» композиции, при агрегации объект-часть может существовать независимо от контейнера и одновременно входить в состав нескольких объектов. Это важное различие в проектировании реальных систем.