handbookНаписать

principles/composition-over-inheritance/README.md · тема · 4 языка · 27 тестов

Композиция вместо наследования (Composition over Inheritance) ​

Поведение собирается из небольших частей, а не наследуется: новый набор каналов уведомлений - это новый список, а не новый класс.

Проблема ​

Магазин уведомляет покупателя об отправке заказа. Сначала была почта, потом добавили SMS, потом кто-то захотел и то и другое. Каждый вариант оформили наследником:

php
abstract class Notifier
{
    abstract public function notify(Recipient $to, string $text): void;
}

class EmailNotifier extends Notifier
{
    public function notify(Recipient $to, string $text): void { /* send email */ }
}

class SmsNotifier extends Notifier
{
    public function notify(Recipient $to, string $text): void { /* send sms */ }
}

class EmailAndSmsNotifier extends EmailNotifier
{
    public function notify(Recipient $to, string $text): void
    {
        parent::notify($to, $text);
        // sms code copied from SmsNotifier, a class can have only one parent
    }
}

class EmailAndPushNotifier extends EmailNotifier { /* ... */ }
class SmsAndPushNotifier extends SmsNotifier { /* ... */ }
class EmailSmsAndPushNotifier extends EmailAndSmsNotifier { /* ... */ }

Три канала дают семь классов, четвёртый канал - пятнадцать. Код отправки SMS скопирован во всех "и SMS"-наследниках, потому что родитель у класса один. Выбрать каналы по настройкам пользователя во время работы нельзя: набор зашит в имени класса, и где-то растёт switch от строки настроек к классу.

Решение ​

Разделить "что умеет отправлять" и "кто рассылает". Каждый способ доставки - отдельный объект с узким контрактом Channel: EmailChannel, SmsChannel. Notifier не является каналом, а содержит список каналов и проходит по нему. Нужен набор "SMS, потом почта" - передаём два объекта в этом порядке. Новый канал - один новый класс, и он сразу комбинируется со всеми остальными.

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

Структура ​

«interface»

Channel

+send(Recipient, text) : string

EmailChannel

SmsChannel

Recipient

+email: string

+phone: string

Notifier

-channels: Channel[]

+notify(Recipient, text) : string[]

Одна стрелка наследования на канал вместо дерева комбинаций. Notifier агрегирует сколько угодно каналов (o--) и не знает их типов. Каналы возвращают строку о том, что отправили: в приложении здесь был бы вызов SMTP или SMS-шлюза, а строка заменяет его в учебном примере.

Как это собирается ​

Каналы создаются один раз при старте и регистрируются в DI-контейнере под именами email и sms. Настройки пользователя лежат в таблице: Анна хочет только почту, Борис - SMS и почту. Обработчик события "заказ отправлен" берёт настройки получателя, достаёт каналы по именам, собирает из них Notifier и вызывает notify. Комбинация собирается из данных во время работы, класса SmsAndEmailNotifier нигде нет.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

ЯзыкОсобенность
PHPinterface Channel и final классы каналов: от них нельзя наследоваться, только комбинировать; Notifier принимает каналы variadic-параметром
Goнаследования нет вовсе: Channel - интерфейс, а ChannelFunc делает каналом обычную функцию, как http.HandlerFunc
PythonProtocol вместо базового класса: каналу не нужно ни от чего наследоваться, достаточно метода send
TypeScriptканалы - объектные литералы под структурный interface Channel, без классов; Notifier собирается spread-оператором

Запуск: make test T=principles/composition-over-inheritance

Плюсы и минусы ​

Тесты доказывают то, ради чего принцип. Первый перебирает пять наборов каналов, включая пустой и обратный порядок, через один и тот же Notifier: ни один набор не потребовал нового класса. Второй добавляет push-канал прямо в тесте и смешивает его с SMS: новый канал сразу сочетается со старыми.

  • ✅ Число классов растёт по числу каналов, а не по числу их сочетаний.
  • ✅ Набор и порядок каналов выбираются во время работы, из настроек или данных.
  • ✅ Каждый канал тестируется отдельно, Notifier - с любыми подставными каналами.
  • ❌ Сборка переезжает в вызывающий код: кто-то должен знать, какие каналы и в каком порядке передать.
  • ❌ Больше мелких объектов и косвенности: чтобы понять, что произойдёт, надо посмотреть, что собрали, а не только на класс.

Когда не применять ​

  • Настоящее отношение "является": ExpressOrder - это Order с теми же инвариантами, и подставить его вместо Order безопасно. Здесь наследование честное.
  • Каркас требует наследника: контроллер фреймворка, TestCase в PHPUnit, Exception. Бороться с этим композицией - против течения.
  • Вариант один и второго не предвидится: интерфейс и список из одного элемента - лишняя церемония (см. YAGNI).
  • Небольшая общая логика без сочетаний: шаблонный метод в абстрактном классе бывает проще набора стратегий.

Отличия между языками ​

  • PHP - одиночное наследование и есть причина копирования в "и SMS"-наследниках. Трейты похожи на выход, но это вставка кода при компиляции: набор всё равно фиксирован в классе. final на каналах явно говорит "расширяй составом".
  • Go - классов и наследования нет, композиция - единственный путь. Встраивание структур (embedding) похоже на наследование, но это делегирование полю, без виртуальных методов. ChannelFunc показывает ещё один шаг: каналом может быть просто функция.
  • Python - множественное наследование есть, и class EmailAndSmsNotifier(EmailNotifier, SmsNotifier) формально работает. Порядок вызовов тогда определяет MRO и super(), что легко сломать. Protocol позволяет обойтись без иерархии совсем.
  • TypeScript - структурная типизация делает каналом любой объект с методом send. Классы каналов не нужны, хватает литералов, а миксины - тот же трейт с теми же ограничениями.

Связанные темы ​

  • DRY, KISS, YAGNI - не заводить интерфейс раньше второго варианта
  • Закон Деметры - объект зависит от узких контрактов своих частей
  • Стратегия - композиция с одной заменяемой частью
  • Декоратор - композиция, при которой объекты оборачивают друг друга
  • Компоновщик - список частей за тем же контрактом, что и одна часть
© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS