Декоратор (Decorator)
Добавляет объекту поведение обёрткой с тем же интерфейсом: обёртки складываются в любом количестве и порядке, а исходный класс не меняется.
Проблема
Кофейня продаёт эспрессо, к нему добавки: молоко, сироп, второе молоко. Если каждая комбинация — подкласс (EspressoWithMilk, EspressoWithMilkAndVanilla…), классов становится столько, сколько сочетаний, а новая добавка удваивает их число. Флаги в одном классе (hasMilk, syrupFlavor) не лучше: цену считает растущий if, «двойное молоко» не выражается без счётчиков, и каждая добавка правит один и тот же класс.
Решение
Ввести общий интерфейс — компонент Beverage с ценой и описанием. Клиент знает только его. Конкретный компонент Espresso — то, что оборачивают.
Декораторы Milk и Syrup тоже реализуют Beverage и держат внутри другой Beverage. Каждый вызов они передают внутрь и дописывают своё: + 50 к цене, + milk к описанию. Поскольку обёртка — тоже напиток, её можно обернуть ещё раз: Syrup(Milk(Espresso)), Milk(Milk(Espresso)). Новая добавка — один новый класс, остальные не меняются.
Декоратор нужен, когда поведение добавляется слоями и сочетания заранее неизвестны. Если вариантов два-три и они не комбинируются, проще подкласс или параметр.
Структура
Компонент — весь контракт, который видят клиент и декораторы. Чем он уже, тем дешевле декоратор: каждый метод интерфейса обёртка обязана либо изменить, либо передать внутрь. Два метода — две строки делегирования; двадцать методов — повод задуматься.
Как это собирается
Слои собирает тот, кто знает заказ: обработчик кассы. Экран кассы присылает список кодов добавок, обработчик начинает с Espresso и оборачивает напиток по одному коду за раз: milk даёт Milk, syrup:vanilla даёт Syrup со вкусом. Порядок обёрток - порядок в заказе, а количество ничем не ограничено: два молока - просто два слоя Milk.
Незнакомый код добавки останавливает сборку этого заказа: функция выбора обёртки бросает ошибку, обработчик отклоняет заказ и переходит к следующему. Для готового напитка цена и описание - один вызов price() и description() у внешнего слоя, остальное каждый слой спрашивает у своего inner.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Декораторы не знают, что лежит внутри: эспрессо или другая обёртка. Поэтому их можно вкладывать друг в друга в любом порядке, а конкретный компонент не знает, что его обернули. Состояние обёртки — только ссылка на внутренний объект и собственные параметры (flavor у сиропа); всё неизменяемо, одну и ту же основу можно положить в два разных напитка.
| Язык | Особенность |
|---|---|
| PHP | Интерфейс и final readonly классы; внутренний объект — private свойство конструктора |
| Go | Обёртка встраивает интерфейс (type Milk struct{ Beverage }): непереопределённые методы делегируются сами |
| Python | Protocol вместо интерфейса, обёртки — frozen dataclass; синтаксис @decorator — это другое (см. ниже) |
| TypeScript | Интерфейс и классы с private readonly параметрами конструктора; структурная типизация, implements — для проверки |
Запуск: make test T=patterns/structural/decorator
Плюсы и минусы
Тесты одинаковы во всех языках и проверяют четыре случая: голый эспрессо стоит 200; молоко и ванильный сироп дают 290 и описание по слоям; одна обёртка дважды — двойное молоко за 300; при смене порядка обёрток цена та же, а описание меняется. Последний случай — первая подсказка: порядок слоёв виден снаружи, и в реальных задачах от него зависит уже не только текст.
- ✅ Поведение добавляется без правки исходного класса и без взрыва подклассов.
- ✅ Сочетания собираются во время выполнения, в том числе из конфига.
- ✅ Каждая обёртка маленькая, одна ответственность, тестируется отдельно.
- ❌ Много мелких объектов; отладчик показывает матрёшку, стек вызовов глубокий.
- ❌ Порядок слоёв важен, а из кода сборки это не всегда очевидно: см. практику.
- ❌ Обёртка скрывает тип: проверка «это эспрессо?» на обёрнутом объекте не сработает.
Когда не применять
- Вариантов мало и они не сочетаются: подкласс или параметр проще.
- Интерфейс широкий, а меняется один метод из двадцати: обёртка превратится в простыню делегирования. Лучше сузить интерфейс или вынести меняющуюся часть в стратегию.
- Нужно заменить алгоритм целиком, а не добавить слой: это Стратегия.
- Нужно не добавить поведение, а решить, пускать ли вызов к объекту (ленивая загрузка, права): это Заместитель. Устроены они одинаково, различаются намерением.
Отличия между языками
- PHP — классический вариант: интерфейс плюс
final readonlyобёртки. Базовый класс-декоратор из GoF (абстрактный, с делегированием по умолчанию) здесь не нужен: методов два, и оба переопределяются. Он оправдан при широком интерфейсе. - Go — встраивание интерфейса в структуру делает базовый декоратор ненужным:
Milk{Beverage}уже реализует всё, что не переопределено. Самый известный декоратор в Go —io.Reader:bufio.NewReader,gzip.NewReader,io.LimitReaderоборачивают друг друга. Для функций — middlewarefunc(next Handler) Handler, как вnet/http. - Python —
@decoratorв языке — это функция высшего порядка над функцией или классом, а не обёртка объекта. Идея та же (вернуть вызываемое с тем же контрактом и добавленным поведением), и для поведения вокруг одного вызова синтаксис удобнее классов: это показывает практика. Для объектов с несколькими методами — классы, как здесь;Protocolпроверяет совместимость без наследования. - TypeScript — структурная типизация: любой объект с
price()иdescription()ужеBeverage, поэтому обёртку можно написать литералом{ price: () => inner.price() + 50, ... }. Синтаксис@decoratorв TS (стандарт TC39) тоже про функции и классы при объявлении, а не про обёртку объекта во время выполнения.
Практика
- Учебные добавки к кофе складываются как угодно: цена от порядка не зависит. В жизни декораторами оборачивают клиентов внешних систем — ретраи, кэш, журнал — и там порядок слоёв меняет поведение: кэш снаружи ретрая экономит запросы, а ретрай снаружи кэша повторяет закэшированный отказ. Практика: клиент курсов валют показывает слои поверх одного интерфейса без сети, с фейковым транспортом, ненастоящими паузами и тестом на порядок.
- Отчёты в админке оборачивают медленный запрос к базе кэшем, замером времени, журналом и лимитом строк, а набор слоёв для каждого экрана задаёт конфиг. Слой, который меняет данные, над общим кэшем опасен: практика: слои над медленным отчётом показывает сборку стека по кодам из конфига и то, как кэш не с той стороны лимита отдаёт выгрузке обрезанный отчёт.
Связанные темы
- Практика: клиент курсов валют — ретраи, кэш и журнал слоями; порядок решает
- Практика: слои над медленным отчётом - кэш, лимит, замер и журнал из конфига; общий кэш и порядок слоёв
- Заместитель (Proxy) — та же форма обёртки, но управляет доступом, а не добавляет поведение
- Компоновщик (Composite) — тоже рекурсивная структура с одним интерфейсом, но держит много детей, а не одного
- Адаптер (Adapter) — меняет интерфейс, декоратор сохраняет его
- Стратегия (Strategy) — меняет алгоритм изнутри, декоратор добавляет поведение снаружи