Композиция вместо наследования (Composition over Inheritance)
Поведение собирается из небольших частей, а не наследуется: новый набор каналов уведомлений - это новый список, а не новый класс.
Проблема
Магазин уведомляет покупателя об отправке заказа. Сначала была почта, потом добавили SMS, потом кто-то захотел и то и другое. Каждый вариант оформили наследником:
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, потом почта" - передаём два объекта в этом порядке. Новый канал - один новый класс, и он сразу комбинируется со всеми остальными.
Наследование отвечает на вопрос "чем объект является", композиция - "из чего он состоит". Когда вариантов поведения несколько и они сочетаются, состав гибче родословной: он меняется во время работы и не требует нового типа под каждую комбинацию.
Структура
Одна стрелка наследования на канал вместо дерева комбинаций. Notifier агрегирует сколько угодно каналов (o--) и не знает их типов. Каналы возвращают строку о том, что отправили: в приложении здесь был бы вызов SMTP или SMS-шлюза, а строка заменяет его в учебном примере.
Как это собирается
Каналы создаются один раз при старте и регистрируются в DI-контейнере под именами email и sms. Настройки пользователя лежат в таблице: Анна хочет только почту, Борис - SMS и почту. Обработчик события "заказ отправлен" берёт настройки получателя, достаёт каналы по именам, собирает из них Notifier и вызывает notify. Комбинация собирается из данных во время работы, класса SmsAndEmailNotifier нигде нет.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
| Язык | Особенность |
|---|---|
| PHP | interface Channel и final классы каналов: от них нельзя наследоваться, только комбинировать; Notifier принимает каналы variadic-параметром |
| Go | наследования нет вовсе: Channel - интерфейс, а ChannelFunc делает каналом обычную функцию, как http.HandlerFunc |
| Python | Protocol вместо базового класса: каналу не нужно ни от чего наследоваться, достаточно метода 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 - не заводить интерфейс раньше второго варианта
- Закон Деметры - объект зависит от узких контрактов своих частей
- Стратегия - композиция с одной заменяемой частью
- Декоратор - композиция, при которой объекты оборачивают друг друга
- Компоновщик - список частей за тем же контрактом, что и одна часть