Компоновщик

Компоновщик — это структурный паттерн проектирования, который позволяет собирать объекты в древовидные структуры «часть-целое» и работать с отдельным объектом и с группой объектов одинаковым образом.
Проблема
Автосервис составляет чек-листы диагностики: есть отдельные проверки («уровень масла», «аккумулятор») и есть группы проверок («Двигатель», «Электрика»), которые тоже можно вкладывать друг в друга. Нужно посчитать суммарное время диагностики по всему дереву.
Первая попытка — обойтись без общего интерфейса и различать проверку и группу вручную:
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, сводя работу с деревом «часть-целое» к одному интерфейсу, который лист и контейнер реализуют по-своему, но клиент вызывает одинаково.