Паттерн проектирования «Компоновщик» / «Composite»

Почитать описание других паттернов.

Проблема


Предоставить клиенту единообразный доступ к листовым и составным элементам древовидной структуры.

Описание


Существует большое количество программных систем, в которых так или иначе применяются древовидные структуры объектов. В большинстве случаев, это всевозможные конструкторы/редакторы, которые позволяют собрать что-то большое (составное) из чего-то более мелкого (листового). При этом, клиент трактует и большое и мелкое как одно и тоже, а система должна различать составные и листовые объекты соответственно.

Не только программы-конструкторы используют данный паттерн (а я надеюсь, что используют). Ярким примером подобной древовидной структуры является пользовательский интерфейс (GUI). Действительно, типичное окно пользовательского интерфейса — контейнер для более простых виджетов — панелей, кнопок, полей воода и т.д., причем, панель, в свою очередь тоже являются контейнерными объектами и так вплоть до элементарных листовых объектов. Ярким примером использования данного паттерна в этом контексте является библиотека отрисовки интерфейса Swing (я имею ввиду класс javax.swing.JComponent).

Итак, вернемся к поставленной проблеме — предоставить клиенту единообразный интерфейс к листовым и составным элементам древовидной структуры. Очевидно, что для решения данной проблемы необходимо завести общий интерфейс, который будет описывать и элементарные и составные объекты. Более того, т.к. интерфейс описывает и составные объекты, он должен содержать контейнерные методы — add, remove, get для добавления, удаления и получения объекта из контейнера. Причем, данные методы должны быть параметризованы тем самым общим интерфейсом. Таким образом, автоматически появляется возможность добавлять в контейнер не только элементарные объекты но и другие контейнеры.

Все объекты древовидной структуры (листовые и составные) должны реализовывать этот единообразный интерфейс, причем составные объекты переопределяют операции add, remove, get а листовые их попросту игнорируют.

Практическая задача


Напишем простейший сумматор выражений с применением паттерна «Компоновщик». Сумматор не должен заниматься разбором выражений, он должен лишь описывать и реализовывать древовидную структуру для более удобного вычисления выражений.

Я специально не стал брать классические примеры с конструкторами или пользовательским интерфейсом, чтобы у читателя не осталось впечатлений об узкой направленности паттерна.

Диаграмма классов


Для начала несколько комментариев. В данном примере я несколько иначе трактую понятие контейнера. Скажем так, я спроецировал классическое понятие контейнера на предметную область — вычисление выражений. Мой «контейнер» ведет себя несколько иначе, чем классический. Вместо метода remove(), у SubExpression есть метод sub(), который на самом деле и делает удаление из контейнера, но только по-своему. Ввиду того, что это все-таки сумматор, метод sub() аналогично add() добавляет подвыражение в контейнер, но с противоположным знаком, тем самым реализуя вычитание.



Рассмотрим диаграмму. Интерфейс подвыражения (SubExpression) описывает единообразный интерфейс для всех объектов древовидной структуры, которых, к слову сказать, не много — целые числа (IntegetValue), вещественные числа (FloatValue) и выражения (Expression). Очевидно, что все числа — листовые объекты и контейнерных методов они не реализуют, а выражение как раз наоборот — контейнер.

Реализация на Java


В реализации нет кода класса FloatValue.
// Единообразный интерфейс доступа к листовым и контейнерным объектам
public interface SubExpression {
  
  public Number value();
  
  public void add(SubExpression expr);
  public void sub(SubExpression expr);
  public SubExpression getSubExpression(int index);
}

// Лист - целое число
public class IntegerValue implements SubExpression {
  
  private Integer value;
  
  public IntegerValue(Integer value) {
    this.value = value;
  }

  @Override
  public void add(SubExpression expr) {
    throw new UnsupportedOperationException();    
  }

  @Override
  public SubExpression getSubExpression(int index) {
    throw new UnsupportedOperationException();
  }

  @Override
  public void sub(SubExpression expr) {
    throw new UnsupportedOperationException();    
  }

  @Override
  public Number value() {
    return value;
  }
}

import java.util.ArrayList;
import java.util.List;

// Выражение - контейнер
public class Expression implements SubExpression {
  
  private List<SubExpression> exprs;
  
  public Expression(SubExpression ... exprs) {
    this.exprs = new ArrayList<SubExpression>();
    for (SubExpression expr: exprs) {
      this.exprs.add(expr);
    }
  }
  
  @Override
  public void add(SubExpression expr) {
    exprs.add(expr);    
  }
  
  @Override
  public void sub(SubExpression expr) {
    if (expr instanceof IntegerValue) {
      exprs.add(new IntegerValue(-1*expr.value().intValue()));
    } else {
      exprs.add(new FloatValue(-1*expr.value().floatValue()));
    }
    
  }

  @Override
  public SubExpression getSubExpression(int index) {
    return exprs.get(index);
  }

  @Override
  public Number value() {
    Number result = new Float(0);
    
    for (SubExpression expr: exprs) {
      result = result.floatValue() + expr.value().floatValue();
    }
    
    return result;
  }
}

// Использование
public class Main {

  public static void main(String[] args) {
    // Вычислим выражение - 20 - (5-2) - (11+6)
    // Приведем к следующему виду 20 - a - b
    SubExpression expr = new Expression();

    SubExpression a = new Expression(new IntegerValue(5), new IntegerValue(-2));
    SubExpression b = new Expression(new IntegerValue(11), new IntegerValue(6));
    
    expr.add(new IntegerValue(20));
    expr.sub(a);
    expr.sub(b);
    
    System.out.println(expr.value());
  }
}

* This source code was highlighted with Source Code Highlighter.


Надеюсь, у меня получилось донести до Вас идею паттерна.

_________
Текст подготовлен в ХабраРедакторе
+27
27 февраля 2010, 15:25
64
spiff 94,7

комментарии (32)

+3
ponemetski #
спасибо за серию статей.
кстати, новичкам бы помогли пару примеров использования в реальных проектах (например ссылки на файлы в SVN какого-нибудь проекта на sf.net или code.google.com) в конце статьи.
+1
spiff #
Я думаю это будет затруднительно, ввиду того, что я не часто зачитываюсь исходными кодами различных проектов :)

На самом деле большое количество паттернов можно найти в Java API, туда я и буду периодически редиректить народ.

А по поводу проектов, тут, я могу только сделать предположения о том, в каком проекте какой паттерн используется. Более того, возможно это не конкретный проект а класс систем (как редакторы/конструкторы в данной статье)
0
ponemetski #
будет очень хорошо, потому что при начальном изучении паттернов трудно понять где конкретно их использовать
0
ksn #
Примеры использования для всех паттернов проектирования есть в книге от GoF.
0
karabara #
Возможно стоит представить обобщенную схему паттерна
0
spiff #
В принципе, с помощью комментариев на данной диаграмме я попытался отобразить обобщенную схему паттерна.
0
Dair_Targ #
Вообще это не правильно предостовлять неподдерживаемые методы (например, IntegerValue#add()). В итоге появляются проверки исключений на пустом месте. В том же JComponent это артефакты из-за наследования от java.awt.Container, тогда как в java.awt.Component таких методов нет. Так что дерево из примера не самое лучшее.
На мой взгляд в интерфейсе Number стоит оставить только один метод — value().
0
spiff #
Ну этого сделать нельзя, по той простой причине, что реализуется интерфейс, а значит и все методы интерфейса должны быть переопределены.

Вопрос с другом. В интерфейсе SubExpression должны ли быть методы контейнера или нет? Возможно и не должны, при этом контейнерные реализации должны просто расширять этот интерфейс методами add, remove, get. Это на самом деле второй вариант развития событий. Можно и так и так. Смысл паттерна при этом не теряется. Однако в банде четырех, рекомендуется использовать именно тот вариант который я привел в посте. Все по-честному :)
0
Dair_Targ #
> Ну этого сделать нельзя, по той простой причине, что реализуется интерфейс, а значит и все методы интерфейса должны быть переопределены.
Я и предлагаю интерфейс поменять.

Вообще согласен, просто бесит, когда из-за небольшой непродуманности в интерфейсе приходится потом проверять множество таких исключений, которых могло бы и не быть. Всё-ж таки блог называется «Совершенный Код».
0
licvidator #
Результатом выполнения кода будет UnsupportedOperationException в строке
> SubExpression a = new Expression(new IntegerValue(5), new IntegerValue(-2));

:-)
0
licvidator #
Хотя нет, туплю. Код — нечитабельный.

За каким нужно было IntegerValue и FloatValue унаследовать от SubExpression, это только запутывает, ведь реально эти методы не используются.
0
spiff #
Это идея паттерна: единый интерфейс для листовых и составных объектов, что позволяет клиенту их трактовать единым образом. А про контейнерные методы в этом интерфейсе я уже пояснил — habrahabr.ru/blogs/complete_code/85166/#comment_2567703
+1
licvidator #
В коде на продакшене НЕ ДОЛЖНО БЫТЬ throw new UnsupportedOperationException()
Если реализация паттерна требует их наличия — значит что-то с паттерном не так.
0
spiff #
Мне кажется, что Вы не совсем поняли предназначение и смысл паттерна.

И да, с паттерном все так :)
+1
lair #
Вообще, идея паттерна, конечно, в том, чтобы предоставить единый интерфейс… но это не повод бросать экспешны.

Не говоря уже о том, что придумайте мне реальный сценарий, где надо, чтобы у leaf-nodes обязательно был тот же интерфейс?

Опыт показывает, что чем обрабатывать эксепшны, намного дешевле делать несколько интерфейсов и проверять, чем именно является узел.

А вообще, конечно, Composite лучше всего подходит для иерархий, где просто нет заведомо терминальных узлов.
0
spiff #
Эксепшены — это всего лишь особенность реализации. Я мог просто оставить тела данных методов пустыми. Просто так, программист хотя-бы будет знать, что в его системе что-то не так.

Реальных сценариев я думаю предостаточно в GoF они обозначены.

Я согласен с тем, что обработка исключений достаточно дорогостоящая операция, но не на столько чтобы жертвовать ради этого прозрачной архитектурой системы. К тому-же, на С++, например обработка исключений нет так уж и дорога (недавно был на мастер-классе по оптимизирующим компиляторам от Intel).

0
lair #
Реальных сценариев я думаю предостаточно в GoF они обозначены.
Можете привести хотя бы один?

Я согласен с тем, что обработка исключений достаточно дорогостоящая операция, но не на столько чтобы жертвовать ради этого прозрачной архитектурой системы.
Пойнт ровно в обратном. В правильной архитектуре не должно быть обработки эксепшна в штатной ситуации — эксепшн на то и эксепшн, что ситуация нештатная. Архитектура, в которой мы ждем, что нам кинут эксепшн, и в зависимости от этого предпринимаем действия — непрозрачная и неудобная.
0
spiff #
Да почти все современные фраемверки отрисковки интерфейса построены на данном паттерне. Более того, явный пример про Swing (JComponent) я Вам привел.

В GoF приведены примеры систем (пользовательский интерфейс, каркас для построения компиляторов RTL) на Smaltalk, которые, я более чем уверен не будут для Вас авторитетны :)

И да. То-есть по Вашему, в правильной архитектуре нет места обработке исключений? Вы слышали про методологию защитного программирования?
0
lair #
Да почти все современные фраемверки отрисковки интерфейса построены на данном паттерне. Более того, явный пример про Swing (JComponent) я Вам привел.
Вы путаете. Я просил пример, где это нужно, а не где это реализовано. Желательно, с объяснением, зачем.

Потому как прекрасно существует Windows Forms, в котором есть IComponent и IContainer.

То-есть по Вашему, в правильной архитектуре нет места обработке исключений?
Ровно наоборот. В правильной архитектуре максимум исключительных ситуаций должен быть обработан. Но это не значит, что штатные операции должны порождать исключительные ситуации.

Понимаете, в чем проблема — «ожидаемый exception» приводит к тому, что обработка исключений усложняется. Мы не можем просто перехватить все исключения и обработать их единообразно, даже если это позволяет сценарий, мы вынуждены делать частный случай для того исключения, которое «кидается штатно», и проверять в нем набор дополнительных условий. Это просто неудобно. Это порождает лишний нечитаемый код.

У меня right now есть внешняя система, единственный способ проверить наличие элемента с заданным ключом в которой — это обратиться по этому ключу и получить exception, если элемента нет. Никаких Contains или TryGet.
0
spiff #
Но это не значит, что штатные операции должны порождать исключительные ситуации
То-есть по-вашему, исключения, которые выбрасывают реализации FloatValue и IntValue — это штатные исключения? Отнюдь, они сигнализируют программисту о том, что его система интерпретирует древовидную структуру как-то не так :) Иными словами, она пытается обратиться к листу как к составному объекту. И это не ошибка описания структуры. Это ошибка ее использования, ошибка интерпретации контекста, называйте это как хотите. Но виноват в этом — клиент (по отношению к паттерну).

Если Вы заметили в примере, который я привел, никаких исключений не выбрасывается. Все потому, что клиент (класс Main) правильно работает со структурой. Если-бы он работал не правильно — безусловно возбудилось бы исключение. Но паттерн (структура) тут не причем. Вина — клиента.
0
lair #
Но виноват в этом — клиент (по отношению к паттерну).
Вот в этом и состоит фундаментальное различие между нашими подходами. Я считаю хорошей архитектурой ту, где минимизирована возможность ошибки.

Все потому, что клиент (класс Main) правильно работает со структурой.
0
spiff #
Я и не спорю об этом. Действительно, надо стремиться к такой «идеальной» архитектуре. Но это не всегда возможно. Точнее сказать, всегда невозможно, по ряду причин. Это и сложность интеграции, сложно коллективной разработки, сложность предметной области наконец. Вы прекрасно понимаете, что все эти патерны (со всеми их недостатками), придумали именно для того, что-бы избежать этих проблем.

Да, я верю, что может быть кто-то придумает (да скорее всего уже придумал) более рациональный механизм работы с древовидной структурой, но об этом знает только он и может быть небольшая группа разработчиков. А Composite знают много людей и понимание между ними (знающими) наступает заведомо быстрее, чем в ситуации, когда этот программист будет пытаться объяснить архитектуру своей методологии.

О чем мы спорили вообще? :)
0
lair #
Да, я верю, что может быть кто-то придумает (да скорее всего уже придумал) более рациональный механизм работы с древовидной структурой, но об этом знает только он и может быть небольшая группа разработчиков.
Угу. Windows.Forms — черезвычайно малоизвестная реализация. И очень маленькая группа разработчиков.

Спорим мы о том, что ваш пример нифига не показывает, что такое паттерн Composite, зачем его применять, и в чем его выгода.
0
spiff #
Во-первых, можно ссылку на документацию по интерфейсу IComponent и что там еще.

Во-вторых, пример, очень даже показывает как именно клиент единообразно трактует разные вещи (вызывая value()).

Да причем здесь выгода? Да, есть паттерн. Да, работает с древовидной структурой. Да, работает удобно и прозрачно. Ваше личное дело, будете ли Вы его использовать или будете придумывать свои гомыры.
0
lair #
Во-первых, можно ссылку на документацию по интерфейсу IComponent и что там еще.
IComponent
IContainer

Во-вторых, пример, очень даже показывает как именно клиент единообразно трактует разные вещи (вызывая value()).
Само по себе это не является свойством Composite, это свойство любого интерфейса.

Основное определяющее свойство Composite — возможность работать с группой объектов так же, как с одним объектом. То есть, если я могу сказать graph.Paint(), то я могу сказать и graphComposite.Paint() (вместо foreach(graph in graphComposite) graph.Paint;). По сути, подмена коллекции композитом. И все.

Отсюда никак не вытекает, что у graph должны быть методы graphComposite.

Да причем здесь выгода?
Да при том, что любое архитектурное решение — всегда баланс плюсов и минусов. Для того, чтобы успешно их применять, надо понимать, что ты приобретаешь, а что теряешь.

Я просто слишком часто вынужден объяснять разработчикам, почему нельзя бездумно копировать то, что написано в Великой Библии Четырех.
0
spiff #
На самом деле, симбиоз интерфейсов IComponent и IContainer и есть паттерн Composite. Это фактически равносильно тому, чтобы вынести контейнерные методы из единого интерфейса.

Более того, такой вариант я нахожу более приемлемым. С одной стороны мы имеем общий интерфейс — точнее интерфейсы. С другой, избавляемся от двусмысленности в трактовании узлов.

Тут Microsoft поступили пожалуй даже лучше чем проектировщики Swing :)
0
lair #
Ну вот видите? И никаких исключений на пустом месте. Хотя, казалось бы, реализация того же паттерна.

Отсюда и вывод — голову надо применять.
0
spiff #
Согласен, но первый вариант тоже имеет право на жизнь :)
0
lair #
Вот только я никогда не видел ситуации, где он был бы оправдан.
0
lair #
Все потому, что клиент (класс Main) правильно работает со структурой.
И он же прекрасно показывает, что терминальным узлам структуры методы удаления и добавления не нужны. Прекрасно бы обошлось Expression {Number value}, при этом полностью сохранив логику.
0
spiff #
Как тогда прикажете? Удалить контейнерные методы из SubExpression и написать их только в Expression? Да, это выход. Причем, это второй вариант, предлагаемый в GoF. Однако, как я уже отмечал, там рекомендуется все-таки пользоваться первым, ввиду того, что во втором случае интерфейс таки теряет свою универсальность.
0
lair #
А в чем польза универсального интерфейса? Вот реальная, в конкретном примере?

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.