Компоновщик (Composite) — шаблон проектирования

22 января 2020
Шаблон предлагает схему создания составных объектов. Данный подход используется при моделировании иерархий, когда ряд объектов необходимо скомпоновать древовидной структурой в одном объединённом (комплексном) объекте. 

Пример задачи: сделать модель коробки, в которую упакован заказ.

Ниже приводится реализация паттерна на языке PHP. Скачать исходники (ZIP, 2 Кб)

Шаблон состоит из следующих элементов:
  • Базовый интерфейс — интерфейс или абстрактный класс, который описывает общее поведение для всех объектов, входящих в комплексный объект. Базовый интерфейс используется для выноса общих методов и свойств, которые есть и у Записи, и у Категории;
  • Запись — объект, который не может иметь потомков (не может иметь дочерних записей и категорий);
  • Категория — объект, который может иметь потомков. У объекта есть свойство-массив (в примере ниже это свойство content), хранящее ссылки на дочерние объекты, а так же есть методы по добавлению и удалению элементов из массива. Именно это свойство обеспечивает реализацию шаблона Компоновщик, компонуя и выстраивая древовидные связи между объектами.

В литературе, описывающей паттерн, Базовый интерфейс называют Компонентом (Component), Запись называют Листом (Leaf), а Категорию именуют Составным объектом (Composite). Но так как перевод нечётко передаёт суть понятий, поэтому я эти обозначения не использую. 
 
В нашем случае, где мы моделируем коробку с заказом, роль Записей будут выполнять товары заказа, а роль Категорий — коробки и другие упаковочные материалы (пакеты, например), в которые поставщики кладут поставляемую продукцию.



box_elements.classes.php 

Файл содержит следующие классы:
  • BoxElement — абстрактный класс и он же Базовый интерфейс, задающий общие свойства и методы для Товаров и Коробок;
  • Box — класс коробки заказа. В свойстве content находится массив для хранения дочерних элементов, а именно товаров и других, более мелких, коробок. Также у класса есть метод add($element, $key) для добавления объектов в массив дочерних элементов коробки и метод delete($key) для удаления дочерних элементов.
  • Product — класс товара заказа.

У классов Box (коробки) и Product (товары) есть метод getPrice() для получения стоимости объекта. В случае с товаром метод вернёт стоимость товара, а в случае с коробкой метод вернёт сумму стоимости её содержимого.
<?php

/**
 * Абстрактный класс общих свойств и методов для Товаров и Коробок
 */
abstract class BoxElement
{
    /**
     * Название элемента
     * @var string
     */
    protected $name;

    /**
     * Возвращает стоимость элемента или его содержимого. Если это товар - вернёт стоимость товара, 
     * а если это коробка - вернёт сумму товаров, находящихся в коробке
     * @return int
     */
    abstract public function getPrice();

    /**
     * Возвращает список содержимого в формате HTML
     */
    abstract public function getContentList();
}

/**
 * Коробка заказа
 */
class Box extends BoxElement
{
    /**
     * Массив содержимого коробки (массив дочерних элементов)
     * @var string
     */
    private $content = array();

    /**
     * Конструктор коробки
     * @param string $name - название коробки
     */
    public function __construct($name)
    {
        $this->name = $name;
    }

    /**
     * Добавляет элемент в содержимое коробки
     * @param OrdersBoxElement $element - объект коробки или товара заказа
     * @param int|string|bool - ключ или индекс элемента, под котороым  он будет добавлен в содержимое коробки. 
     *                     Если указать значение false, то элемент будет добавлен в конец массива с идексом по умолчанию.
     *                     Если элемент с указанным ключом уже существует, то новый элемент его перезапишет.
     * @return void
     */
    public function add($element, $key = false)
    {
        if ($key !== false) {
            $this->content[$key] = $element;
        } else {
            $this->content[] = $element;
        }
    }

    /**
     * Удаляет элемент из содержимого коробки
     * @param int|string|bool - ключ или индекс элемента, под котороым  он будет добавлен в содержимое коробки. 
     * @return void
     */
    public function delete($key)
    {
        if (isset($this->content[$key])) {
            unset($this->content[$key]);
        }
    }

    /**
     * Возвращает стоимость содержимого коробки
     * @return int
     */
    public function getPrice()
    {
        $sum = 0;

        foreach ($this->content as $element) {
            $sum = $sum + $element->getPrice();
        }
        
        return $sum;
    }

    /**
     * Возвращает список содержимого в формате HTML
     * @return string
     */
    public function getContentList()
    {
        //Получаем содержимое из дочерних элементов
        $childContent = '';
        foreach ($this->content as $element) {
            $childContent .= $element->getContentList();
        }

        if ($childContent == '') {
            $childContent = '<div style="color: #999;"><i>Пусто</i></div>';
        }

        return '
        <div style="margin: 0 0 0 0;">
            <div><b>– '.htmlspecialchars($this->name).'</b></div>
            <div style="padding: 0 0 0 30px;">'.$childContent.'</b></div>
        </div>
        ';
    }
}

/**
 * Товар заказа
 */
class Product extends BoxElement
{
    /**
     * Стоимость товара
     * @var int
     */
    private $price = 0;

    /**
     * Конструктор коробки
     * @param string $name - название товара
     * @param int $price - стоимость товара
     */
    public function __construct($name, $price)
    {
        $this->name = $name;
        $this->price = $price;
    }

    /**
     * Возвращает стоимость товара
     * @return int
     */
    public function getPrice()
    {
        return $this->price;
    }

    /**
     * Возвращает список содержимого в формате HTML
     * @return string
     */
    public function getContentList()
    {
        return '<div>– '.htmlspecialchars($this->name).' <b>('.($this->getPrice()).' руб)</b></div>';
    }
}

?>

test.php

Тестовый скрипт, в котором создаются коробки заказа, а затем с помощью метода add($element, $key) в них добавляются товары и другие мелкие коробки (пакеты).

В конце скрипта, используя метод getPrice(), подсчитывается и выводится стоимость упакованного заказа, а затем через метод getContentList() выводится структура содержимого заказа.
<?php

//Подключение классов для работы с коробками заказов
require_once(__DIR__.'/box_elements.classes.php');

//Коробка заказа
$boxOrder = new Box('Коробка заказа №333');
$boxOrder->add(new Product('Ключ 10х12', 80));
$boxOrder->add(new Product('Ключ 12х14', 110));
$boxOrder->add(new Product('Ключ 17х19', 180));

//Пакет с товарами №1
$package1 = new Box('Пакет с электроникой');
$package1->add(new Product('Набор выключателей', 200));
$package1->add(new Product('Набор термоусадочных трубок', 720));
$package1->add(new Product('Кабель КГ-ХЛ 3х1,5 (2 метра)', 160));
$boxOrder->add($package1);

//Пакет с товарами №2
$package2 = new Box('Пакет с напильниками');
$package2->add(new Product('Напильник плоский, 200 мм', 155));
$package2->add(new Product('Набор надфилей, 6 штук', 194));
$package2->add(new Product('Набор напильников, 8 штук', 1800));
$boxOrder->add($package2);

//Коробка с подарком от поставщика напильников
$giftBox = new Box('Коробка с подарком');
$giftBox->add(new Product('Пинцет для точных работ', 0));
$giftBox->add(new Product('Открытка с промо-кодом', 0));
$package2->add($giftBox);

//Получаем и выводимо стоимость коробки с заказом
$sumBox = $boxOrder->getPrice();
echo 'Стоимость содержимого коробки с заказом = '.$sumBox.' руб.<br><br>';

//Выводим содержимое коробки с заказом
echo $boxOrder->getContentList();

?>

Результат работы скрипта test.php
Стоимость содержимого коробки с заказом = 3599 руб.

– Коробка заказа №333
    – Ключ 10х12 (80 руб)
    – Ключ 12х14 (110 руб)
    – Ключ 17х19 (180 руб)
    – Пакет с электроникой
        – Набор выключателей (200 руб)
        – Набор термоусадочных трубок (720 руб)
        – Кабель КГ-ХЛ 3х1,5 (2 метра) (160 руб)
    – Пакет с напильниками
        – Напильник плоский, 200 мм (155 руб)
        – Набор надфилей, 6 штук (194 руб)
        – Набор напильников, 8 штук (1800 руб)
        – Коробка с подарком
            – Пинцет для точных работ (0 руб)
            – Открытка с промо-кодом (0 руб)