Pull to refresh

Как усовершенствовать реализацию Компоновщика в .NET

Reading time11 min
Views2K

Каждый прогер наверняка использовал паттерн «Компоновщик», а большинство из нас также сталкивалось с необходимостью реализовать его в своем проекте. И часто так получается, что каждая его реализация налагает особые требования на определяемую бизнес-логику, при этом с точки зрения работы с иерархической структурой мы хотим иметь одинаково широкий набор возможностей: одних методов 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 интерфейсов структур достаточно (иначе определить свои реализации).
Tags:
Hubs:
+24
Comments15

Articles