
Каждый прогер наверняка использовал паттерн «Компоновщик», а большинство из нас также сталкивалось с необходимостью реализовать его в своем проекте. И часто так получается, что каждая его реализация налагает особые требования на определяемую бизнес-логику, при этом с точки зрения работы с иерархической структурой мы хотим иметь одинаково широкий набор возможностей: одних методов Add и Remove часто недостаточно, так почему бы не добавить Contains, Clear и с десяток других? А если еще нужны специальные методы обхода поддеревьев через итераторы? И вот такую функциональность хочется иметь для различных независимых иерархий, а также не обременять себя необходимостью определять реализацию таких методов в каждом из множества элементов Composite. Ну и листовые компоненты тоже не помешало бы упростить.
Чуть ниже я предложу свой вариант решения такой проблемы, применительно к возможностям C#.
Итак, мы имеем интерфейсный тип Component, который перегружен двумя областями ответственности: одна определяет бизнес-логику — то, ради чего и строилась иерархия; вторая обеспечивает прозрачное взаимодействие в иерархии и управляет потомками для композитных элементов. Попроб��ем одну их них вынести в отдельный интерфейс, к которому можно будет получать доступ по свойству компонента Children (или методу GetChildren). В объекте, который возвращается свойством, будут собраны все операции над коллекцией, включая перечисление, добавление и удаление дочерних элементов, а также все, что нам заблагорассудится.

Мы также определили свойство (метод) IsComposite, чтобы получить быструю и хорошо читаемую проверку на то, является ли элемент композитным или же листовым. Этим свойством можно и не пользоваться: тогда при попытке изменить коллекцию дочерних элементов для листового компонента будет выбрасываться исключение NotSupportedException. Таким образом, мы не теряем прозрачность интерфейса для всех компонентов — основное преимущества паттерна «Компоновщик», — и в то же время получаем простой способ определить, могут ли у любого выбранного компонента быть дочерние элементы.
Теперь попробуем определить реализацию интерфейса IComponent, которая была бы хорошо адаптируемой для возможных изменений, и поэтому применимой для построения различных иерархий.
using System.Collections.Generic;
using System.Diagnostics.Contracts;
namespace ComponentLibrary
{
[ContractClass(typeof(IComponentContract))]
public interface IComponent<out TComponent, out TChildrenCollection>
where TComponent : IComponent<TComponent, TChildrenCollection>
where TChildrenCollection : class, IEnumerable<TComponent>
{
TChildrenCollection Children { get; }
bool IsComposite { get; }
}
}
* This source code was highlighted with Source Code Highlighter.От чего интерфейс реализован как шаблонный и что за параметры-типы он принимает? На самом деле идея очень проста: привнести строгую типизацию туда, где действительные типы еще не известны.
TComponent — это всего лишь фактический тип того дочернего интерфейса (или класса в более тесно связанной архитектуре), который вы унаследуете от IComponent, чтобы добавить в него обязанности типа Operation( ).
TChildrenCollection — интерфейс коллекции, который вы реализуете, чтобы обращаться к дочерним элементам. В этом интерфейсе должен быть определен как минимум только GetEnumerator( ), через который можно получить итератор коллекции. Дело в том, что иногда не нужно предоставлять методы типа Add( ) и Remove( ), т.к. все элементы могут добавляться в конструкторе, а сама иерархическая структура не должна изменяться после создания. А если вам вдруг понадобятся уведомления об изменении коллекции — передайте ObservableCollection<TComponent> в качестве TChildrenCollection, и дело в шляпе!
В контракте типа мы укажем следующие ограничения на возвращаемое значение свойства Children: 1) возвращаемая коллекция не равна null; 2) ни один из ее элементов не равен null; 3) компонент либо указан как композитный, либо не содержит дочерних элементов. Для краткости код контракта здесь н�� приводится.
Помните, мы говорили, что хотели бы наделить каждый компонент дополнительным методом, возвращающим итератор, реализующий сложную логику обхода поддерева? Допустим, что мы написали такой итератор ComponentDescendantsEnumerator<TComponent> (его код можно скачать по ссылке в конце статьи), а затем обернули его в класс ComponentDescendantsEnumerable<TComponent>, определяющий IEnumerable<TComponent>. Теперь нужно решить, где разместить методы, возвращающие подобные итераторы? К счастью в C# есть очень полезный механизм — методы-расширения. Давайте попробуем его применить.
namespace ComponentLibrary.Extensions
{
public static class ComponentExtensions
{
public static IEnumerable<T> GetDescendants<T>(this T component)
where T : IComponent<T, IEnumerable<T>>
{
// здесь был контракт метода
return new ComponentDescendantsEnumerable<T>(component);
}
}
}
* This source code was highlighted with Source Code Highlighter.Мы реализуем метод-расширение в отдельном пространстве имен — так мы сможем его вызывать словно метод, принадлежащий интерфейсу IComponent< , >, лишь тогда, когда импортируем это пространство имен.
Далее перед нами еще задача: нужно реализовать способ получения коллекций, которые никогда не содержат элементов, а на все запросы изменения (вроде Add / Remove) выбрасывают NotSupportedException. Сначала сделаем одну такую коллекцию, реализующую ICollection<T>. Однако, если коллекция никогда не изменяется и создается всегда пустой, то нет смысла делать более одной такой коллекции на всю программу (вернее на AppDomain). Идеальный случай, чтобы воспользоваться паттерном «Синглтон»! (реализацию класса Singleton<T> можно узнать в прилагаемых исходниках)
sealed internal class ItemsNotSupportedCollection<T> :
Singleton<ItemsNotSupportedCollection<T>>,
ICollection<T>
{
private ItemsNotSupportedCollection() { }
public int Count { get { return 0; } }
public bool IsReadOnly { get { return true; } }
public bool Contains(T item) { return false; }
public void CopyTo(T[] array, int arrayIndex) { }
public void Add(T item) { throw new NotSupportedException(); }
public void Clear() { throw new NotSupportedException(); }
public bool Remove(T item) { throw new NotSupportedException(); }
public IEnumerator<T> GetEnumerator()
{
return ItemsNotSupportedEnumerator<T>.Instance;
}
IEnumerator IEnumerable.GetEnumerator()
{ return this.GetEnumerator(); }
}
* This source code was highlighted with Source Code Highlighter.Ради прозрачности используемый класс итератора представляет итератор пустой коллекции, поэтому тоже достаточно одного его экземпляра — вновь синглтон.
sealed internal class ItemsNotSupportedEnumerator<T> :
Singleton<ItemsNotSupportedEnumerator<T>>,
IEnumerator<T>
{
private ItemsNotSupportedEnumerator() { }
public T Current { get { return default(T); } }
public void Dispose() { }
object IEnumerator.Current { get { return null; } }
public bool MoveNext() { return false; }
public void Reset() { throw new NotSupportedException(); }
}
* This source code was highlighted with Source Code Highlighter.Осталось только создать статическое свойство, видимое извне сборки и возвращающее доступную только для чтения коллекцию элементов для интерфейса ICollection<T>.
public static class ComponentCollections<TComponent>
where TComponent : IComponent<TComponent, IEnumerable<TComponent>>
{
public static ICollection<TComponent> EmptyCollection
{
get
{
// здесь был контракт
return ItemsNotSupportedCollection<TComponent>.Instance;
}
}
}
* This source code was highlighted with Source Code Highlighter.Пришло время для самого интересного: применение нашей мини-библиотеки для создания конкретной иерархии классов. Допустим надо организовать систему меню, состоящую из: MenuCommand — конкретная команда, и Menu — подменю, которое может содержать другие команды и подменю. Все классы расположены в отдельной сборке. Шестое чувство подсказывает, что паттерн «Компоновщик» пришелся бы здесь кстати.

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

namespace MenuLibrary
{
public interface IMenuItem :
IComponent<IMenuItem, ICollection<IMenuItem>>
{
string Name { get; }
void Display(int indent = 0);
}
}
* This source code was highlighted with Source Code Highlighter.В качестве параметра-типа TComponent мы всегда передаем интерфейс компонентов (т.е. этот же IMenuItem), в качестве TChildrenCollection — интерфейс реализуемой коллекции. Мы могли бы для TChildrenCollection создать свой интерфейс, определяющий методы Add, Remove и GetChild (а также GetEnumerator), как и в классическом варианте паттерна. Можно передать например IList<IMenuItem>, но мы решили, что здесь нам подходит стандартный интерфейс ICollection<IMenuItem>.
Вот так мы определим листовой компонент MenuCommand:
public class MenuCommand : IMenuItem
{
private readonly string name;
public MenuCommand(string name)
{
// здесь был контракт
this.name = name;
}
public string Name { get { return this.name; } }
public void Display(int indent = 0)
{
string indentString = MenuHelper.GetIndentString(indent);
Console.WriteLine("{1}{0} [Command]", this.name, indentString);
}
public ICollection<IMenuItem> Children
{
get { return ComponentCollections<IMenuItem>.EmptyCollection; }
}
public bool IsComposite { get { return false; } }
}
* This source code was highlighted with Source Code Highlighter.Свойство Children возвращает ранее объявленную «синглтоновую» коллекцию для листовых элементов. Теперь объявим композитный компонент Menu.
public class Menu : IMenuItem
{
private readonly ICollection<IMenuItem> children =
new List<IMenuItem>();
private readonly string name;
public Menu(string name)
{
// здесь должен быть контракт
this.name = name;
}
public string Name { get { return this.name; } }
public void Display(int indent = 0)
{
string indentString = MenuHelper.GetIndentString(indent);
Console.WriteLine("{1}{0} [Menu]", this.name, indentString);
int childrenIndent = indent + 1;
foreach (IMenuItem child in this.children)
{
child.Display(childrenIndent);
}
}
public ICollection<IMenuItem> Children
{ get { return this.children; } }
public bool IsComposite { get { return true; } }
}
* This source code was highlighted with Source Code Highlighter.Children неожиданно возвращает использованную стандартную коллекцию List<T>. Это хороший ход, если пользователю нашей иерархии разрешено привести тип объекта Children к List<T> и использовать все дополнительные возможности этого класса. Но если такой расклад недопустим, то надо обернуть List<T> в некий внутренний класс, реализующий только ICollection<T> и недоступный другим сборкам (или даже классам).
Теперь протестируем написанный нами код.
using System;
using System.Linq;
using ComponentLibrary.Extensions;
using MenuLibrary;
namespace MenuTest
{
public static class MenuTest
{
public static void Perform()
{
// создаем структуру меню
IMenuItem rootMenu = new Menu("Root");
// ... меню File
IMenuItem fileMenu = new Menu("File");
fileMenu.Children.Add(new MenuCommand("New"));
fileMenu.Children.Add(new MenuCommand("Open"));
// ... меню File->Export
IMenuItem fileExportMenu = new Menu("Export");
fileExportMenu.Children.Add(new MenuCommand("Text Document"));
fileExportMenu.Children.Add(new MenuCommand("Binary Format"));
fileMenu.Children.Add(fileExportMenu);
// ... меню File
fileMenu.Children.Add(new MenuCommand("Exit"));
rootMenu.Children.Add(fileMenu);
// ... меню Edit
IMenuItem editMenu = new Menu("Edit");
editMenu.Children.Add(new MenuCommand("Cut"));
editMenu.Children.Add(new MenuCommand("Copy"));
editMenu.Children.Add(new MenuCommand("Paste"));
rootMenu.Children.Add(editMenu);
// выводим меню на экран
rootMenu.Display();
Console.WriteLine();
// выводим на консоль имена всех составных меню,
// вложенных в Root, начинающихся на буквы "E" или "R"
var compositeMenuNames =
from menu in rootMenu.GetDescendants()
where menu.IsComposite
&& (menu.Name.StartsWith("E") || menu.Name.StartsWith("R"))
select menu.Name;
foreach (string menuName in compositeMenuNames)
{
Console.WriteLine(menuName);
}
}
}
}
* This source code was highlighted with Source Code Highlighter.Обратите внимание на LINQ-запрос к перечислению, возвращаемому методом-расширением GetDescendants(). Взглянем на результат работы.

Вот так все просто. Причем в логике разработанных компонентов все внимание проектировщика сосредоточено на построении бизнес-логики, а не иерархической структуры или классов-контейнеров.
Ссылка на исходники: http://www.fileden.com/files/2011/10/7/3205975/ComponentLibrary.zip
P.S. Если вам что-то показалось не очевидным, либо вы хотели бы посмотреть на вариант реализации иерархии, доступной только для чтения, то можете обратиться к расширенному варианту этого же поста.
P.P.S. Если честно, я надеюсь, что в комментах кто-нибудь предложит лучший вариант реализации Компоновщика, чем у меня.
UPD Как правильно заметил avalter, здесь я всего лишь применил к Компоновщику Extract Interface, Extract Class и использовал NullObject (желательно закончить Extract Class, вынести используемую коллекцию из композитных компонентов и инкапсулировать ее в отдельный класс). В итоге получается не просто Компоновщик, а более гибкая структура.
Реализовывать каждый раз паттерн таким образом «с нуля» ни в коем случае не советую! Но можно взять код сборки ComponentLibrary, скопировать ее в свой проект и автоматически получить некоторые преимущества для своей иерархии: готовые Null-Object коллекции для листовых элементов, дополнительные итераторы для обхода структуры, а также контракт на интерфейс IComponent, о котором тут упоминалось вскользь. Т.о. при реализации очередной иерархии, подобной IMenuItem, можно задумываться лишь над логикой методов Display(), если реализованных в ComponentLibrary интерфейсов структур достаточно (иначе определить свои реализации).
