handbookНаписать

patterns/structural/composite/README.md · тема · 4 языка · 23 теста

+ практика: цена корзины с наборами

Компоновщик (Composite) ​

Дерево из простых и составных объектов с одним интерфейсом: клиент спрашивает узел, не выясняя, лист это или контейнер.

Проблема ​

Утилита показывает, сколько места занимает проект. В проекте файлы и папки, в папках снова файлы и папки. Без приёма код расчёта в каждом месте выясняет, кто перед ним: «если файл — взять размер, если папка — пройти по содержимому, а там снова проверить». Такие проверки расползаются по отчётам, квотам и очистке, и каждая новая разновидность узла (символическая ссылка, архив) требует править их все.

Решение ​

Лист и контейнер реализуют один Node с одной операцией size(). Файл отвечает сам, папка спрашивает детей тем же вопросом и складывает ответы. Рекурсия спрятана внутри контейнера: клиент вызывает size() у корня и получает размер всего дерева, а у файла тем же вызовом получает размер файла.

Главное в приёме — что контейнер хранит детей как Node, а не как файлы или папки. Поэтому вложенность произвольная, и новый вид узла встаёт в дерево, если умеет отвечать на size().

Структура ​

children

«interface»

Node

+size() : int

File

+name: string

-bytes: int

+size() : int

Directory

+name: string

-children: Node[]

+add(nodes...) : Directory

+size() : int

File — лист: размер хранится в нём, детей нет, и метода add() у него тоже нет. Это «безопасный» вариант компоновщика: операции управления детьми есть только у контейнера, и добавить файл в файл не даст компилятор. «Прозрачный» вариант кладёт add() в общий интерфейс, и тогда лист должен бросать ошибку во время выполнения.

Как это собирается ​

Дерево редко приходит готовым. Хранилище отдаёт плоский список путей с размерами, и дерево собирает тот, кто этот список читает: команда подсчёта места или фоновая задача проверки квот. В примере каждая строка списка раскладывается по /, недостающие Directory создаются по дороге и сразу добавляются к родителю, а файл в конце становится File. Файл, который хранилище не смогло прочитать (размер -1), пропускается с сообщением и в дерево не попадает.

После сборки клиент задаёт один и тот же вопрос size() любому узлу: папке src, вложенной src/lib, корню. Корень складывает всё дерево, и по его ответу решается, превышена ли квота. Ни в отчёте, ни в проверке квоты нет обхода дерева и нет проверки, папка перед нами или файл.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

Directory хранит детей как Node и считает размер на лету, суммируя ответы детей. Ничего не кэшируется, поэтому файл, добавленный глубоко в дерево, сразу виден в размере корня. add() возвращает саму папку, чтобы дерево собиралось одним выражением.

ЯзыкОсобенность
PHPinterface Node, final readonly файл; папка изменяемая, add(Node ...$nodes) через вариадик
GoНеявная реализация интерфейса; файл — значение, папка — указатель, иначе Add не дойдёт до родителя
PythonProtocol вместо базового класса: лист и папка не наследуют ничего, совпадает только 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 }. Классы здесь ради читаемости, а не ради системы типов.

Практика ​

Учебное дерево собирается в коде и считает одну сумму. В магазине дерево наборов приходит из каталога, у каждого набора своя скидка, а редактор каталога может сослаться набором на самого себя или оставить набор пустым. Практика: цена корзины с наборами считает цену вложенных наборов со скидкой на каждом уровне и проверяет каталог при загрузке.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS