Composite (Компоновщик)
Composite позволяет строить древовидные структуры объектов. В такой иерархии может быть два вида объектов:
- Объект, содержащий в себе другие объекты – узел дерева.
- Объект, не содержащий в себе другие объекты – лист дерева.
Компоновщик позволяет клиентскому коду работать с любым объектом из такой иерархии одинаково. Клиентский код не узнает, «общается» ли он с узлом или с листом дерева.
Пример
Представим, что у нас есть приложение для работы с образовательными курсами. Курс состоит из модулей. Модуль может состоять из уроков, заданий и квизов. Урок опционально может включать в себя задания и квизы.
Перенесём эту иерархию в код. Пока что это структуры без поведения, поэтому используем records.
package naive;
import java.util.List;
public record Course(String name, List<Module> modules) {}javapackage naive;
import java.util.List;
// Модуль может состоять из уроков, квизов и заданий
public record Module(String name, List<Object> components) {}javapackage naive;
import java.time.Duration;
import java.util.List;
// Урок может содержать квизы и задания
public record Lesson(String name, String text, List<Object> components, Duration estimatedReadingTime) {}javapackage naive;
import java.time.Duration;
// Задание для закрепления пройденного материала
public record Assignment(String text, Duration estimatedCompletionTime) {}javapackage 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;
}
}javapackage 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;
}
}javapackage 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;
}
}javapackage course;
import java.time.Duration;
public record Assignment(String text, Duration estimatedCompletionTime) implements CourseComponent {
@Override
public Duration duration() {
return estimatedCompletionTime;
}
}javapackage 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 в один, дублируя записи во все получатели.