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

Компоновщик

5 min read
Обложка статьи «Компоновщик»

Компоновщик — это структурный паттерн проектирования, который позволяет собирать объекты в древовидные структуры «часть-целое» и работать с отдельным объектом и с группой объектов одинаковым образом.

Проблема

Автосервис составляет чек-листы диагностики: есть отдельные проверки («уровень масла», «аккумулятор») и есть группы проверок («Двигатель», «Электрика»), которые тоже можно вкладывать друг в друга. Нужно посчитать суммарное время диагностики по всему дереву.

Первая попытка — обойтись без общего интерфейса и различать проверку и группу вручную:

class DiagnosticCheck
{
    public function __construct(
        public readonly string $name,
        public readonly int $minutes,
    ) {}
}
 
function totalDuration(array $items): int
{
    $total = 0;
 
    foreach ($items as $item) {
        if ($item instanceof DiagnosticCheck) {
            $total += $item->minutes;
        } elseif (is_array($item)) {
            $total += totalDuration($item); // группа — просто вложенный массив
        }
    }
 
    return $total;
}

Это работает, но каждая новая операция над деревом — запуск проверок, печать отчёта, подсчёт стоимости — потребует своей копии такой же рекурсивной функции с тем же instanceof/is_array. Стоит добавить третий вид узла дерева, и придётся находить и переписывать все подобные функции по всему проекту.

Решение

Компоновщик вводит общий интерфейс для «листьев» (одиночных проверок) и «контейнеров» (групп проверок), чтобы клиентский код обращался к дереву единообразно, не выясняя на каждом шаге, с чем он имеет дело:

interface DiagnosticItem
{
    public function getDurationMinutes(): int;
    public function run(): array;
}

Лист реализует интерфейс напрямую, без каких-либо потомков:

class DiagnosticCheck implements DiagnosticItem
{
    public function __construct(
        private readonly string $name,
        private readonly int $minutes,
    ) {}
 
    public function getDurationMinutes(): int
    {
        return $this->minutes;
    }
 
    public function run(): array
    {
        // ...реальная проверка...
        return [$this->name];
    }
}

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

class DiagnosticGroup implements DiagnosticItem
{
    /** @var DiagnosticItem[] */
    private array $items = [];
 
    public function __construct(
        private readonly string $name,
    ) {}
 
    public function add(DiagnosticItem $item): static
    {
        $this->items[] = $item;
        return $this;
    }
 
    public function getDurationMinutes(): int
    {
        $total = 0;
 
        foreach ($this->items as $item) {
            $total += $item->getDurationMinutes();
        }
 
        return $total;
    }
 
    public function run(): array
    {
        $results = [];
 
        foreach ($this->items as $item) {
            $results = [...$results, ...$item->run()];
        }
 
        return $results;
    }
}

Ключевое в DiagnosticGroup — она вызывает getDurationMinutes() и run() у своих детей, не проверяя, лист это или ещё одна группа. Раз у обоих один интерфейс, дочерним элементом может быть как DiagnosticCheck, так и другая DiagnosticGroup, и рекурсия происходит сама собой:

$engineGroup = (new DiagnosticGroup('Двигатель'))
    ->add(new DiagnosticCheck('Уровень масла', 5))
    ->add(new DiagnosticCheck('Компрессия', 15));
 
$electricalGroup = (new DiagnosticGroup('Электрика'))
    ->add(new DiagnosticCheck('Аккумулятор', 5))
    ->add(new DiagnosticCheck('Генератор', 10));
 
$fullDiagnostic = (new DiagnosticGroup('Полная диагностика'))
    ->add($engineGroup)
    ->add($electricalGroup)
    ->add(new DiagnosticCheck('Тормозные колодки', 10));
 
echo $fullDiagnostic->getDurationMinutes(); // 45
$fullDiagnostic->run();

Клиентский код работает с $fullDiagnostic как с единым DiagnosticItem, хотя за ним стоит дерево из пяти проверок и трёх уровней вложенности. Появится новая операция — например, подсчёт стоимости — достаточно добавить один метод в интерфейс и обе его реализации, а не искать по проекту все места, где вручную обходили дерево.

Из чего состоит паттерн

  • Компонент (DiagnosticItem) — общий интерфейс для листьев и контейнеров.
  • Лист (DiagnosticCheck) — конечный объект без потомков, реализует операции напрямую.
  • Контейнер (DiagnosticGroup) — хранит дочерние компоненты (листья и/или другие контейнеры) и реализует операции через делегирование им.
  • Клиент — работает с деревом через интерфейс компонента, не различая лист и контейнер.

Прозрачный vs безопасный компоновщик

У DiagnosticGroup есть метод add(), которого нет и не может быть у DiagnosticCheck — присоединять проверки некуда, у листа нет детей. Здесь есть развилка, которую стоит решить осознанно:

  • Прозрачный компоновщик — методы add()/remove() объявлены прямо в интерфейсе DiagnosticItem. Клиентский код может вызвать $check->add(...) у листа, даже если это лишено смысла — такой вызов приходится либо игнорировать, либо выбрасывать исключение в рантайме.
  • Безопасный компоновщик — как в примере выше: add() объявлен только в DiagnosticGroup, а не в общем интерфейсе. Ошибка «добавить проверку к проверке» отлавливается на этапе анализа типов, а не в рантайме, но клиентскому коду иногда придётся делать instanceof DiagnosticGroup, если он явно строит дерево.

Однозначно правильного варианта нет — прозрачный вариант упрощает работу с деревом единообразно, безопасный — не даёт написать бессмысленный код. В примере выше выбран безопасный вариант: операции чтения (getDurationMinutes(), run()) осмысленны для всех и лежат в общем интерфейсе, а управление структурой дерева (add()) — только там, где это имеет смысл.

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

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

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

Компоновщик упрощает клиентский код — он работает с единым интерфейсом вместо ветвления по типу узла — и позволяет добавлять новые виды листьев и контейнеров, не трогая существующий код обхода дерева.

Минус — общий интерфейс иногда вынуждает либо слишком обобщать контракт (прозрачный вариант, с риском бессмысленных вызовов), либо смириться с точечными проверками типа для операций, которые есть не у всех узлов дерева (безопасный вариант). Для плоских, не вложенных структур компоновщик просто не нужен — вся его польза в наличии реальной рекурсивной вложенности.

Итог

Компоновщик избавляет от рассыпанных по кодовой базе рекурсивных обходов с проверками instanceof/is_array, сводя работу с деревом «часть-целое» к одному интерфейсу, который лист и контейнер реализуют по-своему, но клиент вызывает одинаково.