Декоратор

Декоратор — это структурный паттерн проектирования, который позволяет добавлять объекту новое поведение динамически, оборачивая его в объект-декоратор с тем же интерфейсом, вместо того чтобы создавать подкласс под каждую отдельную комбинацию поведений.
Проблема
Автосервис измеряет мощность двигателя на диагностическом стенде. В коде это выражено интерфейсом:
interface DynoStand
{
public function measure(string $model): int; // лошадиные силы
}Есть две готовые реализации. Первая — прямое подключение к собственному оборудованию:
final class DynoStandConnection implements DynoStand
{
public function measure(string $model): int
{
// физический замер через собственное оборудование
}
}Класс объявлен final: соединение с физическим стендом должно быть ровно одно на всё приложение, наследоваться от него нельзя.
Вторая реализация — адаптер к стенду стороннего производителя, у которого другое API и другие единицы измерения:
class LegacyDynoAdapter implements DynoStand
{
public function __construct(
private readonly LegacyDynoDevice $legacyDevice,
) {}
public function measure(string $model): int
{
$kilowatts = $this->legacyDevice->readPowerKw($model);
return (int) round($kilowatts * 1.35962); // кВт → л.с.
}
}Клиентский код работает с обеими реализациями одинаково, через интерфейс DynoStand, не зная, какая из них подставлена:
class DynoService
{
public function __construct(
private readonly DynoStand $stand,
) {}
public function measure(string $model): int
{
return $this->stand->measure($model);
}
}Теперь нужно добавить два новых требования, не связанных с самим измерением:
- Логировать каждый замер — модель и результат — для аудита.
- Кэшировать результат в рамках одной диагностической сессии, чтобы повторный вызов для той же модели не гонял стенд заново.
Первая мысль — унаследоваться от DynoStandConnection и добавить логирование в переопределённом методе:
class LoggingDynoStandConnection extends DynoStandConnection
{
public function measure(string $model): int
{
$power = parent::measure($model);
error_log(sprintf('[dyno] %s: %d л.с.', $model, $power));
return $power;
}
}Это не скомпилируется — класс объявлен final, наследоваться от него нельзя в принципе. Но даже без этого ограничения проблема глубже. Логирование и кэширование нужны не только для DynoStandConnection, но и для LegacyDynoAdapter — а значит, унаследоваться придётся от обоих. И оба поведения должны уметь сочетаться: понадобится ещё и класс «логирующий и кэширующий» стенд, для каждой реализации отдельно. Два независимых поведения на два стенда — уже четыре подкласса, и это не считая их сочетаний.
Решение
Декоратор реализует тот же интерфейс DynoStand, что и оборачиваемый объект, и хранит его внутри себя — вызовы делегируются дальше, обрастая дополнительным поведением до или после делегирования.
Сначала — базовый декоратор, от которого удобно наследовать конкретные варианты:
abstract class DynoStandDecorator implements DynoStand
{
public function __construct(
protected readonly DynoStand $stand,
) {}
}Класс абстрактный и не реализует measure() — это остаётся на совести конкретных декораторов, каждый добавляет своё поведение вокруг вызова обёрнутого стенда:
class LoggingDynoStandDecorator extends DynoStandDecorator
{
public function measure(string $model): int
{
$power = $this->stand->measure($model);
error_log(sprintf('[dyno] %s: %d л.с.', $model, $power));
return $power;
}
}
class CachingDynoStandDecorator extends DynoStandDecorator
{
private array $cache = [];
public function measure(string $model): int
{
if (array_key_exists($model, $this->cache)) {
return $this->cache[$model];
}
return $this->cache[$model] = $this->stand->measure($model);
}
}Оба декоратора реализуют тот же DynoStand, поэтому DynoService продолжает работать с ними как с обычным стендом, ничего не подозревая об обёртках:
$stand = new CachingDynoStandDecorator(
new LoggingDynoStandDecorator(
new DynoStandConnection(),
),
);
$service = new DynoService($stand);
$service->measure('Model X'); // стенд измеряет, лог пишется, результат кэшируется
$service->measure('Model X'); // берётся из кэша, стенд и лог не трогаютсяНи DynoStandConnection, ни LegacyDynoAdapter, ни DynoService при этом не менялись ни строчкой. Появится третье поведение — например, повторный замер при подозрительном разбросе показаний, — оно оформляется одним новым декоратором и сочетается с уже существующими в любом порядке, без единого нового подкласса под комбинацию.
Из чего состоит паттерн
- Компонент (
DynoStand) — общий интерфейс для оборачиваемых объектов и декораторов. - Конкретный компонент (
DynoStandConnection,LegacyDynoAdapter) — базовый объект, к которому добавляется поведение. - Базовый декоратор (
DynoStandDecorator) — реализует интерфейс компонента и хранит ссылку на обёрнутый объект. - Конкретный декоратор (
LoggingDynoStandDecorator,CachingDynoStandDecorator) — добавляет одну конкретную обязанность, делегируя остальное обёрнутому объекту.
Порядок оборачивания имеет значение
В примере выше кэширующий декоратор снаружи, логирующий — внутри: CachingDynoStandDecorator(LoggingDynoStandDecorator($stand)). При повторном вызове для той же модели кэш отрабатывает раньше, чем управление доходит до логирующего декоратора, — а значит, повторные обращения в лог не попадают.
Поменяем декораторы местами — LoggingDynoStandDecorator(CachingDynoStandDecorator($stand)):
$stand = new LoggingDynoStandDecorator(
new CachingDynoStandDecorator(
new DynoStandConnection(),
),
);Теперь логирующий декоратор снаружи и видит каждый вызов measure(), включая те, что внутри обслужил кэш, — в лог попадёт и первый замер, и все последующие обращения к кэшу, хотя стенд физически измерял только один раз. Оба варианта корректны — какой нужен, зависит от того, что именно требуется получить: журнал реальных измерений или журнал всех обращений к стенду.
Декоратор vs Адаптер
Оба паттерна оборачивают один объект другим, поэтому их легко перепутать по структуре кода — но у них разные задачи. Адаптер меняет интерфейс объекта на другой, несовместимый с исходным: LegacyDynoAdapter из примера выше приводит чужое API (readPowerKw(), киловатты) к интерфейсу DynoStand (measure(), лошадиные силы) — без адаптера эти два интерфейса вообще не могли бы работать вместе. Декоратор же сохраняет тот же интерфейс, что и у оборачиваемого объекта, и лишь добавляет поведение поверх него — LoggingDynoStandDecorator как был, так и остаётся DynoStand с методом measure(), просто с побочным эффектом в виде записи в лог.
Если после оборачивания объект получил интерфейс, которого у него не было, — это Адаптер. Если интерфейс остался прежним, а изменилось только поведение за ним, — это Декоратор.
Декоратор vs Прокси
Структурно эти два паттерна тоже выглядят одинаково — обёртка с тем же интерфейсом, что и у оборачиваемого объекта, — и разница снова в намерении, а не в форме кода:
| Декоратор | Прокси | |
|---|---|---|
| Цель | Добавить новое поведение поверх исходного | Контролировать доступ к исходному объекту (отложенная инициализация, права, единая точка кэша) |
| Количество обёрток | Любое, свободно комбинируются клиентом | Обычно одна, фиксированная на этапе проектирования |
| Кто знает об обёртке | Клиент сознательно собирает цепочку декораторов | Клиент обычно не подозревает, что работает через прокси |
CachingDynoStandDecorator в примере выше формально решает ту же задачу, что мог бы решать кэширующий прокси, — но это Декоратор, а не Прокси, потому что клиент сам осознанно составляет цепочку из нескольких обёрток. Прокси, в отличие от декоратора, почти никогда не встречается в цепочке с другими прокси — его роль в системе одна и заранее известна.
Когда применять
- Нужно добавить объекту дополнительные обязанности без изменения его класса — особенно если класс закрыт для наследования, как
DynoStandConnection. - Комбинаций поведений много и они независимы — наследование потребовало бы подкласса на каждое сочетание.
- Поведение нужно собирать во время выполнения, а не фиксировать один раз при объявлении класса.
Плюсы и минусы
Декоратор соблюдает принцип открытости/закрытости — новое поведение добавляется новым декоратором, без изменения существующего кода — и позволяет свободно комбинировать независимые обязанности в любом порядке и количестве, чего наследование в принципе предложить не может.
Минус — цепочка из нескольких декораторов усложняет отладку: чтобы понять, что в итоге происходит при вызове measure(), нужно пройти по всей цепочке обёрток, а не заглянуть в один класс. Порядок оборачивания при этом не всегда очевиден и может незаметно поменять поведение, как показано выше. Ещё один подводный камень — декоратор скрывает исходный тип объекта: код, который полагается на $stand instanceof DynoStandConnection, перестанет работать, стоит обернуть стенд хотя бы в один декоратор.
Итог
Декоратор нужен, когда наследование грозит подклассом на каждую комбинацию независимых поведений — особенно если базовый класс и вовсе закрыт для наследования. Он добавляет обязанности поверх объекта, не трогая ни сам объект, ни клиентский код, который продолжает работать с тем же интерфейсом, — расплачиваясь за это более длинной цепочкой вызовов и менее очевидным порядком применения обёрток.