Перейти к содержимому
Фёдор Башинский

Декоратор

7 min read
Обложка статьи «Декоратор»

Декоратор — это структурный паттерн проектирования, который позволяет добавлять объекту новое поведение динамически, оборачивая его в объект-декоратор с тем же интерфейсом, вместо того чтобы создавать подкласс под каждую отдельную комбинацию поведений.

Проблема

Автосервис измеряет мощность двигателя на диагностическом стенде. В коде это выражено интерфейсом:

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);
    }
}

Теперь нужно добавить два новых требования, не связанных с самим измерением:

  1. Логировать каждый замер — модель и результат — для аудита.
  2. Кэшировать результат в рамках одной диагностической сессии, чтобы повторный вызов для той же модели не гонял стенд заново.

Первая мысль — унаследоваться от 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, перестанет работать, стоит обернуть стенд хотя бы в один декоратор.

Итог

Декоратор нужен, когда наследование грозит подклассом на каждую комбинацию независимых поведений — особенно если базовый класс и вовсе закрыт для наследования. Он добавляет обязанности поверх объекта, не трогая ни сам объект, ни клиентский код, который продолжает работать с тем же интерфейсом, — расплачиваясь за это более длинной цепочкой вызовов и менее очевидным порядком применения обёрток.