Перейти к содержанию
Innovialabs

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

Продвинутое4 мин

Коротко

По умолчанию выбирайте композицию: собирайте объекты из маленьких заменяемых частей. Наследование оставьте для настоящего и стабильного отношения «является» с неглубокой иерархией.

Короткий ответ

Композиция — объект содержит другие объекты и делегирует им работу. Наследование — класс является частным случаем другого класса и переиспользует его код.

Правило «предпочитайте композицию наследованию» появилось не случайно: наследование привязывает подкласс к внутреннему поведению родителя, а композиция связывает объекты только через небольшие интерфейсы. Наследование остаётся полезным инструментом, но для более узкого круга задач, чем его обычно применяют.

Проблема хрупкого базового класса

Подкласс зависит не только от публичного API родителя, но часто и от того, как родитель его реализует. Меняете родителя — ломаются наследники, хотя их код никто не трогал.

class Collection:
    def __init__(self):
        self.items = []

    def add(self, item):
        self.items.append(item)

    def add_all(self, items):
        for item in items:
            self.add(item)


class CountingCollection(Collection):
    def __init__(self):
        super().__init__()
        self.count = 0

    def add(self, item):
        self.count += 1
        super().add(item)

    def add_all(self, items):
        self.count += len(items)
        super().add_all(items)

add_all у родителя вызывает add, который переопределён, поэтому каждый элемент считается дважды. Хуже того: если автор родителя потом перепишет add_all на прямое добавление, ошибка исчезнет — или появится другая. Подкласс завязан на деталь реализации, которую он не видит.

Глубокие иерархии и взрыв комбинаций

Вторая типичная беда — иерархия, которая пытается описать несколько независимых измерений:

  • Notification → EmailNotification, SmsNotification
  • затем UrgentEmailNotification, UrgentSmsNotification
  • затем ScheduledUrgentEmailNotification...

Каждое новое измерение (канал, приоритет, расписание) умножает число классов. Поведение размазано по пяти уровням, и чтобы понять один метод, приходится читать всю цепочку вызовов super().

Рефакторинг в композицию

Выделите каждое измерение в отдельный компонент и соберите их вместе:

interface Channel {
  send(to: string, text: string): Promise<void>;
}

interface Formatter {
  format(text: string): string;
}

class EmailChannel implements Channel {
  async send(to: string, text: string) { /* отправка через SMTP */ }
}

class UrgentFormatter implements Formatter {
  format(text: string) { return `[URGENT] ${text}`; }
}

class Notifier {
  constructor(private channel: Channel, private formatter: Formatter) {}

  notify(to: string, text: string) {
    return this.channel.send(to, this.formatter.format(text));
  }
}

const notifier = new Notifier(new EmailChannel(), new UrgentFormatter());

Что вы получаете:

  • N + M классов вместо N × M: новый канал не требует новых вариантов приоритета.
  • Замена в рантайме: канал можно выбрать из конфига.
  • Простые тесты: передаёте фейковый Channel вместо мока родительского класса.
  • Понятные контракты: Notifier зависит только от двух маленьких интерфейсов.

Считающая коллекция из прошлого раздела чинится так же: оберните коллекцию и делегируйте ей вызовы, а не наследуйтесь. Тогда ваш add_all не зависит от того, как внутренняя коллекция реализует свои методы.

План рефакторинга по шагам

  1. Найдите меняющиеся измерения в иерархии (канал, хранилище, формат, политика).
  2. Выделите интерфейс для каждого измерения с минимумом методов.
  3. Перенесите поведение из подклассов в маленькие реализации этих интерфейсов.
  4. Передавайте части через конструктор одного класса.
  5. Удалите пустые подклассы, которые только комбинировали части.
  6. Покройте тестами до и после, чтобы поведение не изменилось.

Когда наследование всё ещё уместно

СитуацияПодходит наследование?
Настоящее отношение «является», которое не изменитсяДа
Фреймворк требует расширить базовый класс (UI-компоненты, исключения, тест-кейсы)Да
Шаблонный метод: родитель задаёт алгоритм, наследник заполняет один-два шагаЧасто да
Нужно переиспользовать пару вспомогательных методовНет — композиция или функции
Несколько независимых вариацийНет — композиция
Родитель из сторонней библиотеки и не рассчитан на расширениеНет — оберните его

Практические правила:

  • Держите иерархии мелкими — обычно хватает одного-двух уровней.
  • Проектируйте класс для наследования или запрещайте его: помечайте классы final (Java; в Kotlin классы закрыты по умолчанию; sealed в C#), если они не документированы для расширения.
  • Не переопределяйте методы, которые родитель вызывает внутри себя, если это явно не разрешено.
  • Проверка по Лисков: подкласс должен работать везде, где работает родитель. Если он бросает «не поддерживается» в унаследованном методе — иерархия построена неверно.

Частые ошибки

  • Наследование только ради переиспользования кода, а не для выражения типа.
  • Классы BaseService, BaseController, BaseManager, куда сваливают несвязанные хелперы.
  • Обратная крайность: слои делегирования там, где простой подкласс был бы понятнее.
  • Внутреннее состояние в protected-полях, которые наследники меняют напрямую.

Частые вопросы

Нет. Это инструмент сильной связанности, который часто применяют не к месту. Для стабильного отношения «является», точки расширения фреймворка или шаблонного метода наследование просто и читаемо.

Поможем с этим

Если хотите не разбираться самим, а получить результат — этим мы занимаемся каждый день.

Обсудить проект

Читайте также

Есть идея или задача? Расскажите нам

Ответим в течение рабочего дня и предложим решение. Консультация бесплатная.