Компоновщик (Composite)
Дерево из простых и составных объектов с одним интерфейсом: клиент спрашивает узел, не выясняя, лист это или контейнер.
Проблема
Утилита показывает, сколько места занимает проект. В проекте файлы и папки, в папках снова файлы и папки. Без приёма код расчёта в каждом месте выясняет, кто перед ним: «если файл — взять размер, если папка — пройти по содержимому, а там снова проверить». Такие проверки расползаются по отчётам, квотам и очистке, и каждая новая разновидность узла (символическая ссылка, архив) требует править их все.
Решение
Лист и контейнер реализуют один Node с одной операцией size(). Файл отвечает сам, папка спрашивает детей тем же вопросом и складывает ответы. Рекурсия спрятана внутри контейнера: клиент вызывает size() у корня и получает размер всего дерева, а у файла тем же вызовом получает размер файла.
Главное в приёме — что контейнер хранит детей как Node, а не как файлы или папки. Поэтому вложенность произвольная, и новый вид узла встаёт в дерево, если умеет отвечать на size().
Структура
File — лист: размер хранится в нём, детей нет, и метода add() у него тоже нет. Это «безопасный» вариант компоновщика: операции управления детьми есть только у контейнера, и добавить файл в файл не даст компилятор. «Прозрачный» вариант кладёт add() в общий интерфейс, и тогда лист должен бросать ошибку во время выполнения.
Как это собирается
Дерево редко приходит готовым. Хранилище отдаёт плоский список путей с размерами, и дерево собирает тот, кто этот список читает: команда подсчёта места или фоновая задача проверки квот. В примере каждая строка списка раскладывается по /, недостающие Directory создаются по дороге и сразу добавляются к родителю, а файл в конце становится File. Файл, который хранилище не смогло прочитать (размер -1), пропускается с сообщением и в дерево не попадает.
После сборки клиент задаёт один и тот же вопрос size() любому узлу: папке src, вложенной src/lib, корню. Корень складывает всё дерево, и по его ответу решается, превышена ли квота. Ни в отчёте, ни в проверке квоты нет обхода дерева и нет проверки, папка перед нами или файл.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Directory хранит детей как Node и считает размер на лету, суммируя ответы детей. Ничего не кэшируется, поэтому файл, добавленный глубоко в дерево, сразу виден в размере корня. add() возвращает саму папку, чтобы дерево собиралось одним выражением.
| Язык | Особенность |
|---|---|
| PHP | interface Node, final readonly файл; папка изменяемая, add(Node ...$nodes) через вариадик |
| Go | Неявная реализация интерфейса; файл — значение, папка — указатель, иначе Add не дойдёт до родителя |
| Python | Protocol вместо базового класса: лист и папка не наследуют ничего, совпадает только size() |
| TypeScript | Структурный interface Node; add() возвращает this, сумма через reduce |
Запуск: make test T=patterns/structural/composite
Плюсы и минусы
Тесты прогоняют одну таблицу: файл, пустую папку, папку с файлами и вложенной папкой, весь проект. Вызов во всех строках один и тот же, size() у Node: так тест показывает, что клиенту всё равно, что перед ним. Второй тест добавляет файл в пустую папку после сборки дерева и проверяет, что размер корня вырос: контейнер хранит ссылки, а не копии.
- ✅ Клиент работает с деревом любой глубины через один интерфейс, без проверок типа.
- ✅ Новый вид узла добавляется без правки клиента и других узлов.
- ✅ Рекурсия живёт в одном месте, в контейнере.
- ❌ Общий интерфейс тянет к «прозрачности»: соблазн положить туда
add()и операции, которые листу не нужны. - ❌ Дерево не защищено от циклов и от узлов, которых в этом месте быть не должно: проверки пишутся отдельно (см. практику).
- ❌ Каждый запрос обходит всё поддерево; на больших деревьях нужен кэш, а с ним инвалидация.
Когда не применять
- Иерархии нет или она фиксированной глубины (заказ → строки): обычный список проще.
- Листья и контейнеры клиенту на самом деле нужно различать почти всегда: общий интерфейс будет фикцией с пустыми методами.
- Операций над деревом много и они часто меняются: их удобнее вынести в Посетителя, а узлы оставить данными.
Отличия между языками
- PHP — классический вид: интерфейс, два класса,
final readonlyдля листа. ВариадикNode ...$nodesпроверяет тип каждого ребёнка во время вызова. - Go — интерфейс реализуется неявно,
FileоNodeне знает. Важна семантика значений: папка в дереве должна быть указателем, иначе родитель хранит копию и не увидит добавленных позже детей. Тест на «изменение в глубине» ловит именно это. - Python —
Protocolдаёт статическую проверку без наследования; во время выполнения работает утиная типизация, и в папку можно положить что угодно с методомsize(). - TypeScript — структурная типизация: любой объект с
size(): numberужеNode, даже литерал{ size: () => 0 }. Классы здесь ради читаемости, а не ради системы типов.
Практика
Учебное дерево собирается в коде и считает одну сумму. В магазине дерево наборов приходит из каталога, у каждого набора своя скидка, а редактор каталога может сослаться набором на самого себя или оставить набор пустым. Практика: цена корзины с наборами считает цену вложенных наборов со скидкой на каждом уровне и проверяет каталог при загрузке.
Связанные темы
- Практика: цена корзины с наборами — скидки по дереву, циклы и пустые наборы из каталога
- Строитель (Builder) — удобный способ собрать сложное дерево по шагам.
- Прототип (Prototype) — копия дерева: глубину копии решает каждый узел.