BackendBit

К списку паттернов

Composite позволяет строить древовидные структуры объектов. В такой иерархии может быть два вида объектов:

  • Объект, содержащий в себе другие объекты – узел дерева.
  • Объект, не содержащий в себе другие объекты – лист дерева.

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

UML диаграмма классов паттерна КомпоновщикПоказывает интерфейс CourseComponent с методом duration(), реализованный пятью классами: Course, Module и Lesson (узлы дерева), Assignment и Quiz (листья).<<interface>>CourseComponent+duration(): DurationCourseModuleLessonAssignmentQuizУзлы – содержатдочерние элементыЛистья

Пример

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

Перенесём эту иерархию в код. Пока что это структуры без поведения, поэтому используем records.

package naive;

import java.util.List;

public record Course(String name, List<Module> modules) {}
java
package naive;

import java.util.List;

// Модуль может состоять из уроков, квизов и заданий
public record Module(String name, List<Object> components) {}
java
package naive;

import java.time.Duration;
import java.util.List;

// Урок может содержать квизы и задания
public record Lesson(String name, String text, List<Object> components, Duration estimatedReadingTime) {}
java
package naive;

import java.time.Duration;

// Задание для закрепления пройденного материала
public record Assignment(String text, Duration estimatedCompletionTime) {}
java
package naive;

import java.time.Duration;
import java.util.List;

// Квиз для закрепления пройденного материала
public record Quiz(String name, List<String> questions, Duration estimatedCompletionTime) {}
java

А вот так выглядит создание такой иерархии в коде:

var arraysLesson = new Lesson(
    "Массивы",
    "Здесь длинный текст урока",
    List.of(new Assignment("Написать программу с использованием массивов", Duration.ofHours(1))),
    Duration.ofHours(2)
);

var linkedListsLesson = new Lesson(
    "Связные списки",
    "Здесь длинный текст урока",
    List.of(
        new Quiz("Квиз по связным спискам", List.of("Вопрос 1", "Вопрос 2"), Duration.ofMinutes(10)),
        new Assignment("Написать программу с использованием связных списков", Duration.ofHours(1))
    ),
    Duration.ofHours(3)
);

var basicDataStructuresModule = new Module(
    "Базовые структуры данных",
    List.of(
        arraysLesson,
        linkedListsLesson,
        new Quiz("Квиз по базовым структурам данных", List.of("Вопрос 1", "Вопрос 2"), Duration.ofMinutes(30))
    )
);

var binaryTreesLesson = new Lesson(
    "Двоичные деревья",
    "Здесь длинный текст урока",
    List.of(), // урок без заданий и квизов
    Duration.ofHours(2)
);

var treesOperationsLesson = new Lesson(
    "Операции с деревьями",
    "Здесь длинный текст урока",
    List.of(new Quiz("Квиз по операциям с деревьями", List.of("Вопрос 1", "Вопрос 2"), Duration.ofMinutes(15))),
    Duration.ofHours(3)
);

var treesModule = new Module(
    "Деревья",
    List.of(
        binaryTreesLesson,
        treesOperationsLesson,
        new Assignment("Написать программу с использованием деревьев", Duration.ofHours(1))
    )
);

var course = new Course("Структуры данных", List.of(basicDataStructuresModule, treesModule));
java

Теперь представим, что нам пришло требование – показывать пользователю длительность каждого элемента курса. Мы должны уметь выводить длительность как квиза или задания, так и урока, модуля или всего курса.

Начнём с квиза и задания – у них уже есть поле estimatedCompletionTime. Records в Java могут содержать методы, поэтому достаточно добавить метод для получения длительности:

public record Assignment(String text, Duration estimatedCompletionTime) {
    public Duration duration() {
        return estimatedCompletionTime;
    }
}

public record Quiz(String name, List<String> questions, Duration estimatedCompletionTime) {
    public Duration duration() {
        return estimatedCompletionTime;
    }
}
java

Теперь мы умеем получать длительность квиза и задания:

quiz.duration();
assignment.duration();
java

Но как быть с остальными элементами? Например, чтобы получить длительность урока, нужно сложить его ожидаемое время прочтения с длительностью всех элементов (квизов и заданий) внутри урока.

Duration lessonDuration = lesson.estimatedReadingTime();

for (Object component : lesson.components()) {
    // Как вызвать duration() и прибавить его к lessonDuration? У Object нет такого метода
}
java

Но должен ли клиентский код самостоятельно высчитывать длительность урока? Нет, это нарушение инкапсуляции. К тому же List<Object> не даёт никакой типобезопасности – компилятор не помешает положить туда объект любого типа.

Вместо этого мы можем в каждом классе реализовать метод duration() и делегировать подсчёт длительности самому классу.

Для этого введём общий интерфейс.

package course;

import java.time.Duration;

public interface CourseComponent {
    Duration duration();
}
java

Теперь имплементируем новый интерфейс в наших компонентах.

package course;

import java.time.Duration;
import java.util.List;

public final class Lesson implements CourseComponent {
    private final String name;
    private final String text;
    private final List<CourseComponent> components;
    private final Duration estimatedReadingTime;

    public Lesson(String name, String text, List<CourseComponent> components, Duration estimatedReadingTime) {
        this.name = name;
        this.text = text;
        this.components = List.copyOf(components);
        this.estimatedReadingTime = estimatedReadingTime;
    }

    // Длительность урока = время прочтения + сумма длительностей всех компонентов
    @Override
    public Duration duration() {
        Duration total = estimatedReadingTime;
        for (CourseComponent component : components) {
            total = total.plus(component.duration());
        }
        return total;
    }
}
java
package course;

import java.time.Duration;
import java.util.List;

public final class Module implements CourseComponent {
    private final String name;
    private final List<CourseComponent> components;

    public Module(String name, List<CourseComponent> components) {
        this.name = name;
        this.components = List.copyOf(components);
    }

    // Длительность модуля = сумма длительностей всех компонентов
    @Override
    public Duration duration() {
        Duration total = Duration.ZERO;
        for (CourseComponent component : components) {
            total = total.plus(component.duration());
        }
        return total;
    }
}
java
package course;

import java.time.Duration;
import java.util.List;

public final class Course implements CourseComponent {
    private final String name;
    private final List<CourseComponent> components;

    public Course(String name, List<CourseComponent> components) {
        this.name = name;
        this.components = List.copyOf(components);
    }

    // Длительность курса = сумма длительностей всех компонентов
    @Override
    public Duration duration() {
        Duration total = Duration.ZERO;
        for (CourseComponent component : components) {
            total = total.plus(component.duration());
        }
        return total;
    }
}
java
package course;

import java.time.Duration;

public record Assignment(String text, Duration estimatedCompletionTime) implements CourseComponent {
    @Override
    public Duration duration() {
        return estimatedCompletionTime;
    }
}
java
package course;

import java.time.Duration;
import java.util.List;

public record Quiz(String name, List<String> questions, Duration estimatedCompletionTime) implements CourseComponent {
    @Override
    public Duration duration() {
        return estimatedCompletionTime;
    }
}
java

Теперь у каждого элемента курса (и даже у самого курса) есть метод расчёта длительности. Это позволяет легко узнать длительность любого элемента, не залезая в его внутренности:

module.duration();
java

Но самое главное нововведение – интерфейс CourseComponent. Теперь клиентский код может вместо конкретного класса зависеть от этого интерфейса. Это позволяет писать обобщённый код, потому что клиентскому коду неважно, с каким элементом иерархии он общается. В этом и состоит суть паттерна.

Реальные примеры

Component и Container

java.awt.Component и java.awt.Container – классический пример Компоновщика в стандартной библиотеке Java.

Component – это базовый класс для всех элементов графического интерфейса. Container наследует Component и может содержать в себе другие компоненты. При этом Container сам является Component, поэтому контейнеры можно вкладывать друг в друга.

import javax.swing.*;
import java.awt.*;

// JPanel – контейнер (узел дерева)
JPanel mainPanel = new JPanel();

// JButton и JLabel – компоненты (листья дерева)
mainPanel.add(new JButton("OK"));
mainPanel.add(new JLabel("Статус: готово"));

// Контейнер можно вложить в другой контейнер
JPanel nestedPanel = new JPanel();
nestedPanel.add(new JTextField(20));
mainPanel.add(nestedPanel);

// Клиентский код работает единообразно с любым компонентом
for (Component component : mainPanel.getComponents()) {
    component.setVisible(true);
}
java

Методы setVisible(), paint(), getPreferredSize() и другие определены в Component. Клиентский код вызывает их одинаково, не задумываясь, является ли компонент листом или контейнером с десятками вложенных элементов.

io.MultiReader

io.MultiReader из стандартной библиотеки Go объединяет несколько io.Reader в один. Возвращённый объект реализует тот же интерфейс io.Reader, поэтому клиентский код не различает, читает ли он из одного источника или из нескольких.

package main

import (
    "fmt"
    "io"
    "log"
    "strings"
)

func main() {
    header := strings.NewReader("=== Отчёт ===\n")
    body := strings.NewReader("Содержимое отчёта\n")
    footer := strings.NewReader("=== Конец ===\n")

    // io.MultiReader объединяет три io.Reader в один io.Reader
    combined := io.MultiReader(header, body, footer)

    // Клиентский код работает с единым io.Reader
    data, err := io.ReadAll(combined)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(data))
}
go

Функция io.MultiReader принимает несколько io.Reader и возвращает один io.Reader, который последовательно читает из каждого источника. Клиентский код вызывает Read() или передаёт его в io.ReadAll() как обычно – ему не нужно знать, что за этим интерфейсом скрывается несколько источников данных.

По тому же принципу работает io.MultiWriter – он объединяет несколько io.Writer в один, дублируя записи во все получатели.