В структурных паттернах рассматривается вопрос о том, как из классов и объектов образуются более крупные структуры. Структурные паттерны уровня класса используют наследование для составления композиций из интерфейсов и реализаций. Простой пример – использование множественного наследования для объединения нескольких классов в один. В результате получается класс, обладающий свойствами всех своих родителей. Особенно полезен этот паттерн, когда нужно организовать совместную работу нескольких независимо разработанных библиотек. Другой пример паттерна уровня класса – адаптер. В общем случае адаптер делает интерфейс одного класса (адаптируемого) совместимым с интерфейсом другого, обеспечивая тем самым унифицированную абстракцию разнородных интерфейсов. Это достигается за счет закрытого наследования адаптируемому классу. После этого адаптер выражает свой интерфейс в терминах операций адаптируемого класса.
Вместо композиции интерфейсов или реализаций структурные паттерны уровня объекта компонуют объекты для получения новой функциональности. Дополнительная гибкость в этом случае связана с возможностью изменить композицию объектов во время выполнения, что недопустимо для статической композиции классов.
Примером структурного паттерна уровня объектов является компоновщик. Он описывает построение иерархии классов для двух видов объектов: примитивных и составных. Последние позволяют создавать произвольно сложные структуры из примитивных и других составных объектов. В паттерне заместитель объект берет на себя функции другого объекта. У заместителя есть много применений. Он может действовать как локальный представитель объекта, находящегося в удаленном адресном пространстве. Или представлять большой объект, загружаемый по требованию. Или ограничивать доступ к критически важному объекту. Заместитель вводит дополнительный косвенный уровень доступа к отдельным свойствам объекта. Поэтому он может ограничивать, расширять или изменять эти свойства.
Паттерн приспособленец определяет структуру для совместного использования объектов. Владельцы разделяют объекты, по меньшей мере, по двум причинам: для достижения эффективности и непротиворечивости. Приспособленец акцентирует внимание на эффективности использования памяти. В приложениях, в которых участвует очень много объектов, должны снижаться накладные расходы на хранение. Значительной экономии можно добиться за счет разделения объектов вместо их дублирования. Но объект может быть разделяемым, только если его состояние не зависит от контекста. У объектов-приспособленцев такой зависимости нет. Любая дополнительная информация передается им по мере необходимости. В отсутствие контекстных зависимостей объекты-приспособленцы могут легко разделяться.
Если паттерн приспособленец дает способ работы с большим числом мелких объектов, то фасад показывает, как один объект может представлять целую подсистему. Фасад представляет набор объектов и выполняет свои функции, перенаправляя сообщения объектам, которых он представляет. Паттерн мост отделяет абстракцию объекта от его реализации, так что их можно изменять независимо.
Паттерн декоратор описывает динамическое добавление объектам новых обязанностей. Это структурный паттерн, который рекурсивно компонует объекты с целью реализации заранее неизвестного числа дополнительных функций. Например, объект-декоратор, содержащий некоторый элемент пользовательского интерфейса, может добавить к нему оформление в виде рамки или тени либо новую функциональность, например возможность прокрутки или изменения масштаба. Два разных оформления прибавляются путем простого вкладывания одного декоратора в другой. Для достижения этой цели каждый объект-декоратор должен соблюдать интерфейс своего компонента и перенаправлять ему сообщения. Свои функции (скажем, рисование рамки вокруг компонента) декоратор может выполнять как до, так и после перенаправления сообщения.
Многие структурные паттерны в той или иной мере связаны друг с другом. Эти отношения обсуждаются в конце главы.
Адаптер – паттерн, структурирующий классы и объекты.
Преобразует интерфейс одного класса в интерфейс другого, который ожидают клиенты. Адаптер обеспечивает совместную работу классов с несовместимыми интерфейсами, которая без него была бы невозможна.
Wrapper (обертка).
Иногда класс из инструментальной библиотеки, спроектированный для повторного использования, не удается использовать только потому, что его интерфейс не соответствует тому, который нужен конкретному приложению.
Рассмотрим, например, графический редактор, благодаря которому пользователи могут рисовать на экране графические элементы (линии, многоугольники, текст и т.д.) и организовывать их в виде картинок и диаграмм. Основной абстракцией графического редактора является графический объект, который имеет изменяемую форму и изображает сам себя. Интерфейс графических объектов определен абстрактным классом Shape. Редактор определяет подкласс класса Shape для каждого вида графических объектов: LineShape для прямых, PolygonShape для многоугольников и т.д.
Классы для элементарных геометрических фигур, например LineShape и PolygonShape, реализовать сравнительно просто, поскольку заложенные в них возможности рисования и редактирования крайне ограничены. Но подкласс TextShape, умеющий отображать и редактировать текст, уже значительно сложнее, поскольку даже для простейших операций редактирования текста нужно нетривиальным образом обновлять экран и управлять буферами. В то же время, возможно, существует уже готовая библиотека для разработки пользовательских интерфейсов, которая предоставляет развитый класс TextView, позволяющий отображать и редактировать текст. В идеале мы хотели бы повторно использовать TextView для реализации TextShape, но библиотека разрабатывалась без учета классов Shape, поэтому заставить объекты TextView и Shape работать совместно не удается.
Так каким же образом существующие и независимо разработанные классы вроде TextView могут работать в приложении, которое спроектировано под другой, несовместимый интерфейс? Можно было бы так изменить интерфейс класса TextView, чтобы он соответствовал интерфейсу Shape, только для этого нужен исходный код. Но даже если он доступен, то вряд ли разумно изменять TextView; библиотека не должна приспосабливаться к интерфейсам каждого конкретного приложения.
Вместо этого мы могли бы определить класс TextShape так, что он будет адаптировать интерфейс TextView к интерфейсу Shape. Это допустимо сделать двумя способами: наследуя интерфейс от Shape, а реализацию от TextView; включив экземпляр TextView в TextShape и реализовав TextShape в терминах интерфейса TextView. Два данных подхода соответствуют вариантам паттерна адаптер в его классовой и объектной ипостасях. Класс TextShape мы будем называть адаптером.
На этой диаграмме показан адаптер объекта. Видно, как запрос BoundingBox, объявленный в классе Shape, преобразуется в запрос GetExtent, определенный в классе TextView. Поскольку класс TextShape адаптирует TextView к интерфейсу Shape, графический редактор может воспользоваться классом TextView, хотя тот и имеет несовместимый интерфейс.
Часто адаптер отвечает за функциональность, которую не может предоставить адаптируемый класс. На диаграмме показано, как адаптер выполняет такого рода функции. У пользователя должна быть возможность перемещать любой объект класса Shape в другое место, но в классе TextView такая операция не предусмотрена. TextShape может добавить недостающую функциональность, самостоятельно реализовав операцию CreateManipulator класса Shape, которая возвращает экземпляр подходящего подкласса Manipulator.
Manipulator – это абстрактный класс объектов, которым известно, как анимировать Shape в ответ на такие действия пользователя, как перетаскивание фигуры в другое место. У класса Manipulator имеются подклассы для различных фигур. Например, TextManipulator – подкласс для TextShape. Возвращая экземпляр TextManipulator, объект класса TextShape добавляет новую функциональность, которой в классе TextView нет, а классу Shape требуется.
Применяйте паттерн адаптер, когда:
• хотите использовать существующий класс, но его интерфейс не соответствует вашим потребностям;
• собираетесь создать повторно используемый класс, который должен взаимодействовать с заранее неизвестными или не связанными с ним классами, имеющими несовместимые интерфейсы;
• (только для адаптера объектов!) нужно использовать несколько существующих подклассов, но непрактично адаптировать их интерфейсы путем порождения новых подклассов от каждого. В этом случае адаптер объектов может приспосабливать интерфейс их общего родительского класса.
Адаптер класса использует множественное наследование для адаптации одного интерфейса к другому.
Адаптер объекта применяет композицию объектов.
• Target (Shape) – целевой:
– определяет зависящий от предметной области интерфейс, которым пользуется Client;
• Client (DrawingEditor) – клиент:
– вступает во взаимоотношения с объектами, удовлетворяющими интерфейсу Target;
• Adaptee (TextView) – адаптируемый:
– определяет существующий интерфейс, который нуждается в адаптации;
• Adapter (TextShape) – адаптер:
– адаптирует интерфейс Adaptee к интерфейсу Target.
Клиенты вызывают операции экземпляра адаптера Adapter. В свою очередь адаптер вызывает операции адаптируемого объекта или класса Adaptee, который и выполняет запрос.
Результаты применения адаптеров объектов и классов различны. Адаптер класса:
• адаптирует Adaptee к Target, перепоручая действия конкретному классу Adaptee. Поэтому данный паттерн не будет работать, если мы захотим одновременно адаптировать класс и его подклассы;
• позволяет адаптеру Adapter заместить некоторые операции адаптируемого класса Adaptee, так как Adapter есть не что иное, как подкласс Adaptee;
• вводит только один новый объект. Чтобы добраться до адаптируемого класса, не нужно никакого дополнительного обращения по указателю.
Адаптер объектов:
• позволяет одному адаптеру Adapter работать со многим адаптируемыми объектами Adaptee, то есть с самим Adaptee и его подклассами (если таковые имеются). Адаптер может добавить новую функциональность сразу всем адаптируемым объектам;
• затрудняет замещение операций класса Adaptee. Для этого потребуется породить от Adaptee подкласс и заставить Adapter ссылаться на этот подкласс, а не на сам Adaptee.
Ниже приведены вопросы, которые следует рассмотреть, когда вы решаете применить паттерн адаптер:
• объем работы по адаптации. Адаптеры сильно отличаются по тому объему работы, который необходим для адаптации интерфейса Adaptee к интерфейсу Target. Это может быть как простейшее преобразование, например изменение имен операций, так и поддержка совершенно другого набора операций. Объем работы зависит от того, насколько сильно отличаются друг от друга интерфейсы целевого и адаптируемого классов;
• сменные адаптеры. Степень повторной используемости класса тем выше, чем меньше предположений делается о тех классах, которые будут его применять. Встраивая адаптацию интерфейса в класс, вы отказываетесь от предположения, что другим классам станет доступен тот же самый интерфейс. Другими словами, адаптация интерфейса позволяет включить ваш класс в существующие системы, которые спроектированы для класса с другим интерфейсом. В системе ObjectWorks\Smalltalk [Par90] используется термин сменный адаптер (pluggable adapter) для обозначения классов со встроенной адаптацией интерфейса.
Рассмотрим виджет TreeDisplay, позволяющий графически отображать древовидные структуры. Если бы это был специализированный виджет, предназначенный только для одного приложения, то мы могли бы потребовать специального интерфейса от объектов, которые он отображает. Но если мы хотим сделать его повторно используемым (например, частью библиотеки полезных виджетов), то предъявлять такое требование неразумно. Разные приложения, скорей всего, будут определять собственные классы для представления древовидных структур, и не следует заставлять их пользоваться именно нашим абстрактным классом Tree. А у разных структур деревьев будут и разные интерфейсы.
Например, в иерархии каталогов добраться до потомков удастся с помощью операции GetSubdirectories, тогда как для иерархии наследования соответствующая операция может называться GetSubclasses. Повторно используемый виджет TreeDisplay должен «уметь» отображать иерархии обоих видов, даже если у них разные интерфейсы. Другими словами,
в TreeDisplay должна быть встроена возможность адаптации интерфейсов.
О способах встраивания адаптации интерфейсов в классы говорится в разделе «Реализация»;
• использование двусторонних адаптеров для обеспечения прозрачности. Адаптеры непрозрачны для всех клиентов. Адаптированный объект уже не обладает интерфейсом Adaptee, так что его нельзя использовать там, где Adaptee был применим. Двусторонние адаптеры способны обеспечить такую прозрачность. Точнее, они полезны в тех случаях, когда клиент должен видеть объект по-разному.
Рассмотрим двусторонний адаптер, который интегрирует каркас графических редакторов Unidraw [VL90] и библиотеку для разрешения ограничений QOCA [HHMV92]. В обеих системах есть классы, явно представляющие переменные:
в Unidraw это StateVariable, а в QOCA – ConstraintVariable. Чтобы заставить Unidraw работать совместно с QOCA, ConstraintVariable нужно адаптировать к StateVariable. А для того чтобы решения QOCA распространялись на Unidraw, StateVariable следует адаптировать
к ConstraintVariable.
Здесь применен двусторонний адаптер класса ConstraintStateVariable, который является подклассом одновременно StateVariable и ConstraintVariable и адаптирует оба интерфейса друг к другу. Множественное наследование в данном случае вполне приемлемо, поскольку интерфейсы адаптированных классов существенно различаются. Двусторонний адаптер класса соответствует интерфейсам каждого из адаптируемых классов и может работать в любой системе.
Хотя реализация адаптера обычно не вызывает затруднений, кое о чем все же стоит помнить:
• реализация адаптеров классов в C++. В C++ реализация адаптера класса Adapter открыто наследует от класса Target и закрыто – от Adaptee. Таким образом, Adapter должен быть подтипом Target, но не Adaptee;
• сменные адаптеры. Рассмотрим три способа реализации сменных адаптеров для описанного выше виджета TreeDisplay, который может автоматически отображать иерархические структуры.
Первый шаг, общий для всех трех реализаций, – найти «узкий» интерфейс для Adaptee, то есть наименьшее подмножество операций, позволяющее выполнить адаптацию. «Узкий» интерфейс, состоящий всего из пары итераций, легче адаптировать, чем интерфейс из нескольких десятков операций. Для TreeDisplay адаптации подлежит любая иерархическая структура. Минимальный интерфейс мог бы включать всего две операции: одна определяет графическое представление узла в иерархической структуре, другая – доступ к потомкам узла.
«Узкий» интерфейс приводит к трем подходам к реализации:
– использование абстрактных операций. Определим в классе TreeDisplay абстрактные операции, которые соответствуют «узкому» интерфейсу класса Adaptee. Подклассы должны реализовывать эти абстрактные операции и адаптировать иерархически структурированный объект. Например, подкласс DirectoryTreeDisplay при их реализации будет осуществлять доступ к структуре каталогов файловой системы.
DirectoryTreeDisplay специализирует узкий интерфейс таким образом, чтобы он мог отображать структуру каталогов, составленную из объектов FileSystemEntity;
– использование объектов-уполномоченных. При таком подходе TreeDisplay переадресует запросы на доступ к иерархической структуре объекту-уполномоченному. TreeDisplay может реализовывать различные стратегии адаптации, подставляя разных уполномоченных.
Например, предположим, что существует класс DirectoryBrowser, который использует TreeDisplay. DirectoryBrowser может быть уполномоченным для адаптации TreeDisplay к иерархической структуре каталогов. В динамически типизированных языках вроде Smalltalk или Objective C такой подход требует интерфейса для регистрации уполномоченного в адаптере. Тогда TreeDisplay просто переадресует запросы уполномоченному. В системе NEXTSTEP [Add94] этот подход активно используется для уменьшения числа подклассов.
В статически типизированных языках вроде C++ требуется явно определять интерфейс для уполномоченного. Специфицировать такой интерфейс можно, поместив «узкий» интерфейс, который необходим классу TreeDisplay, в абстрактный класс TreeAccessorDelegate. После этого допустимо добавить этот интерфейс к выбранному уполномоченному – в данном случае DirectoryBrowser – с помощью наследования. Если у DirectoryBrowser еще нет существующего родительского класса, то воспользуемся одиночным наследованием, если есть – множественным. Подобное смешивание классов проще, чем добавление нового подкласса и реализация его операций по отдельности;
– параметризованные адаптеры. Обычно в Smalltalk для поддержки сменных адаптеров параметризуют адаптер одним или несколькими блоками. Конструкция блока поддерживает адаптацию без порождения подклассов. Блок может адаптировать запрос, а адаптер может хранить блок для каждого отдельного запроса. В нашем примере это означает, что TreeDisplay хранит один блок для преобразования узла в GraphicNode, а другой – для доступа к потомкам узла.
Например, чтобы создать класс TreeDisplay для отображения иерархии каталогов, мы пишем:
directoryDisplay :=
(TreeDisplay on: treeRoot)
getChildrenBlock:
[:node | node getSubdirectories]
createGraphicNodeBlock:
[:node | node createGraphicNode].
Если вы встраиваете интерфейс адаптации в класс, то этот способ дает удобную альтернативу подклассам.
Приведем краткий обзор реализации адаптеров класса и объекта для примера, обсуждавшегося в разделе «Мотивация», при этом начнем с классов Shape
и TextView:
class Shape {
public:
Shape();
virtual void BoundingBox(
Point& bottomLeft, Point& topRight
) const;
virtual Manipulator* CreateManipulator() const;
};
class TextView {
public:
TextView();
void GetOrigin(Coord& x, Coord& y) const;
void GetExtent(Coord& width, Coord& height) const;
virtual bool IsEmpty() const;
};
В классе Shape предполагается, что ограничивающий фигуру прямоугольник определяется двумя противоположными углами. Напротив, в классе TextView он характеризуется начальной точкой, высотой и шириной. В классе Shape определена также операция CreateManipulator для создания объекта-манипулятора класса Manipulator, который знает, как анимировать фигуру в ответ на действия пользователя.1 В TextView эквивалентной операции нет. Класс TextShape является адаптером между двумя этими интерфейсами.
Для адаптации интерфейса адаптер класса использует множественное наследование. Принцип адаптера класса состоит в наследовании интерфейса по одной ветви и реализации – по другой. В C++ интерфейс обычно наследуется открыто, а реализация – закрыто. Мы будем придерживаться этого соглашения при определении адаптера TextShape:
class TextShape : public Shape, private TextView {
public:
TextShape();
virtual void BoundingBox(
Point& bottomLeft, Point& topRight
) const;
virtual bool IsEmpty() const;
virtual Manipulator* CreateManipulator() const;
};
Операция BoundingBox преобразует интерфейс TextView к интерфейсу Shape:
void TextShape::BoundingBox (
Point& bottomLeft, Point& topRight
) const {
Coord bottom, left, width, height;
GetOrigin(bottom, left);
GetExtent(width, height);
bottomLeft = Point(bottom, left);
topRight = Point(bottom + height, left + width);
}
На примере операции IsEmpty демонстрируется прямая переадресация запросов, общих для обоих классов:
bool TextShape::IsEmpty () const {
return TextView::IsEmpty();
}
Наконец, мы определим операцию CreateManipulator (отсутствующую
в классе TextView) с нуля. Предположим, класс TextManipulator, который поддерживает манипуляции с TextShape, уже реализован:
Manipulator* TextShape::CreateManipulator () const {
return new TextManipulator(this);
}
Адаптер объектов применяет композицию объектов для объединения классов с разными интерфейсами. При таком подходе адаптер TextShape содержит указатель на TextView:
class TextShape : public Shape {
public:
TextShape(TextView*);
virtual void BoundingBox(
Point& bottomLeft, Point& topRight
) const;
virtual bool IsEmpty() const;
virtual Manipulator* CreateManipulator() const;
private:
TextView* _text;
};
Объект TextShape должен инициализировать указатель на экземпляр TextView. Делается это в конструкторе. Кроме того, он должен вызывать операции объекта TextView всякий раз, как вызываются его собственные операции.
В этом примере мы предположим, что клиент создает объект TextView и передает его конструктору класса TextShape:
TextShape::TextShape (TextView* t) {
_text = t;
}
void TextShape::BoundingBox (
Point& bottomLeft, Point& topRight
) const {
Coord bottom, left, width, height;
_text->GetOrigin(bottom, left);
_text->GetExtent(width, height);
bottomLeft = Point(bottom, left);
topRight = Point(bottom + height, left + width);
}
bool TextShape::IsEmpty () const {
return _text->IsEmpty();
}
Реализация CreateManipulator не зависит от версии адаптера класса, поскольку реализована с нуля и не использует повторно никакой функциональности TextView:
Manipulator* TextShape::CreateManipulator () const {
return new TextManipulator(this);
}
Сравним этот код с кодом адаптера класса. Для написания адаптера объекта нужно потратить чуть больше усилий, но зато он оказывается более гибким. Например, вариант адаптера объекта TextShape будет прекрасно работать и с подклассами TextView: клиент просто передает экземпляр подкласса TextView конструктору TextShape.
Пример, приведенный в разделе «Мотивация», заимствован из графического приложения ET++Draw, основанного на каркасе ET++ [WGM88]. ET++Draw повторно использует классы ET++ для редактирования текста, применяя для адаптации класс TextShape.
В библиотеке InterViews 2.6 определен абстрактный класс Interactor для таких элементов пользовательского интерфейса, как полосы прокрутки, кнопки
и меню [VL88]. Есть также абстрактный класс Graphic для структурированных графических объектов: прямых, окружностей, многоугольников и сплайнов.
И Interactor, и Graphic имеют графическое представление, но у них разные интерфейсы и реализации (общих родительских классов нет), и значит, они несовместимы: нельзя непосредственно вложить структурированный графический объект, скажем, в диалоговое окно.
Вместо этого InterViews 2.6 определяет адаптер объектов GraphicBlock – подкласс Interactor, который содержит экземпляр Graphic. GraphicBlock адаптирует интерфейс класса Graphic к интерфейсу Interactor, позволяет отображать, прокручивать и изменять масштаб экземпляра Graphic внутри структуры класса Interactor.
Сменные адаптеры широко применяются в системе ObjectWorks\Smalltalk [Par90]. В стандартном Smalltalk определен класс ValueModel для видов, которые отображают единственное значение. ValueModel определяет интерфейс value, value: для доступа к значению. Это абстрактные методы. Авторы приложений обращаются к значению по имени, более соответствующему предметной области, например width и width:, но они не обязаны порождать от ValueModel подклассы для адаптации таких зависящих от приложения имен к интерфейсу ValueModel.
Вместо этого ObjectWorks\Smalltalk включает подкласс ValueModel, называющийся PluggableAdaptor. Объект этого класса адаптирует другие объекты к интерфейсу ValueModel (value, value:). Его можно параметризовать блоками для получения и установки нужного значения. Внутри PluggableAdaptor эти блоки используются для реализации интерфейса value, value:. Этот класс позволяет также передавать имена селекторов (например, width, width:) непосредственно, обеспечивая тем самым некоторое синтаксическое удобство. Данные селекторы преобразуются в соответствующие блоки автоматически.
Еще один пример из ObjectWorks\Smalltalk – это класс TableAdaptor. Он может адаптировать последовательность объектов к табличному представлению. В таблице отображается по одному объекту в строке. Клиент параметризует TableAdaptor множеством сообщений, которые используются таблицей для получения от объекта значения в колонках.
В некоторых классах библиотеки NeXT AppKit [Add94] используются объекты-уполномоченные для реализации интерфейса адаптации. В качестве примера можно привести класс NXBrowser, который способен отображать иерархические списки данных. NXBrowser пользуется объектом-уполномоченным для доступа и адаптации данных.
Придуманная Скоттом Мейером (Scott Meyer) конструкция «брак по расчету» (Marriage of Convenience) [Mey88] это разновидность адаптера класса. Мейер описывает, как класс FixedStack адаптирует реализацию класса Array к интерфейсу класса Stack. Результатом является стек, содержащий фиксированное число элементов.
Структура паттерна мост аналогична структуре адаптера, но у моста иное назначение. Он отделяет интерфейс от реализации, чтобы то и другое можно было изменять независимо. Адаптер же призван изменить интерфейс существующего объекта.
Паттерн декоратор расширяет функциональность объекта, изменяя его интерфейс. Таким образом, декоратор более прозрачен для приложения, чем адаптер. Как следствие, декоратор поддерживает рекурсивную композицию, что для «чистых» адаптеров невозможно.
Заместитель определяет представителя или суррогат другого объекта, но не изменяет его интерфейс.
Мост – паттерн, структурирующий объекты.
Отделить абстракцию от ее реализации так, чтобы то и другое можно было изменять независимо.
Handle/Body (описатель/тело).
Если для некоторой абстракции возможно несколько реализаций, то обычно применяют наследование. Абстрактный класс определяет интерфейс абстракции, а его конкретные подклассы по-разному реализуют его. Но такой подход не всегда обладает достаточной гибкостью. Наследование жестко привязывает реализацию к абстракции, что затрудняет независимую модификацию, расширение и повторное использование абстракции и ее реализации.
Рассмотрим реализацию переносимой абстракции окна в библиотеке для разработки пользовательских интерфейсов. Написанные с ее помощью приложения должны работать в разных средах, например под X Window System и Presentation Manager (PM) от компании IBM. С помощью наследования мы могли бы определить абстрактный класс Window и его подклассы XWindow и PMWindow, реализующие интерфейс окна для разных платформ. Но у такого решения есть два недостатка:
• неудобно распространять абстракцию Window на другие виды окон или новые платформы. Представьте себе подкласс IconWindow, который специализирует абстракцию окна для пиктограмм. Чтобы поддержать пиктограммы на обеих платформах, нам придется реализовать два новых подкласса XIconWindow и PMIconWindow. Более того, по два подкласса необходимо определять для каждого вида окон. А для поддержки третьей платформы придется определять для всех видов окон новый подкласс Window;
• клиентский код становится платформенно-зависимым. При создании окна клиент инстанцирует конкретный класс, имеющий вполне определенную реализацию. Например, создавая объект XWindow, мы привязываем абстракцию окна к ее реализации для системы X Window и, следовательно, делаем код клиента ориентированным именно на эту оконную систему. Таким образом усложняется перенос клиента на другие платформы.
Клиенты должны иметь возможность создавать окно, не привязываясь к конкретной реализации. Только сама реализация окна должна зависеть от платформы, на которой работает приложение. Поэтому в клиентском коде не может быть никаких упоминаний о платформах.
С помощью паттерна мост эти проблемы решаются. Абстракция окна и ее реализация помещаются в раздельные иерархии классов. Таким образом, существует одна иерархия для интерфейсов окон (Window, IconWindow, TransientWindow) и другая (с корнем WindowImp) – для платформенно-зависимых реализаций. Так, подкласс XWindowImp предоставляет реализацию в системе X Window System.
Все операции подклассов Window реализованы в терминах абстрактных операций из интерфейса WindowImp. Это отделяет абстракцию окна от различных ее платформенно-зависимых реализаций. Отношение между классами Window и WindowImp мы будем называть мостом, поскольку между абстракцией и реализацией строится мост, и они могут изменяться независимо.
Используйте паттерн мост, когда:
• хотите избежать постоянной привязки абстракции к реализации. Так, например, бывает, когда реализацию необходимо выбирать во время выполнения программы;
• и абстракции, и реализации должны расширяться новыми подклассами. В таком случае паттерн мост позволяет комбинировать разные абстракции и реализации и изменять их независимо;
• изменения в реализации абстракции не должны сказываться на клиентах, то есть клиентский код не должен перекомпилироваться;
• (только для C++!) вы хотите полностью скрыть от клиентов реализацию абстракции. В C++ представление класса видимо через его интерфейс;
• число классов начинает быстро расти, как мы видели на первой диаграмме из раздела «Мотивация». Это признак того, что иерархию следует разделить на две части. Для таких иерархий классов Рамбо (Rumbaugh) использует термин «вложенные обобщения» [RBP+91];
• вы хотите разделить одну реализацию между несколькими объектами (быть может, применяя подсчет ссылок), и этот факт необходимо скрыть от клиента. Простой пример – это разработанный Джеймсом Коплиеном класс String [Cop92], в котором разные объекты могут разделять одно и то же представление строки (StringRep).
• Abstraction (Window) – абстракция:
– определяет интерфейс абстракции;
– хранит ссылку на объект типа Implementor;
• RefinedAbstraction (IconWindow) – уточненная абстракция:
– расширяет интерфейс, определенный абстракцией Abstraction;
• Implementor (WindowImp) – реализатор:
– определяет интерфейс для классов реализации. Он не обязан точно соответствовать интерфейсу класса Abstraction. На самом деле оба интерфейса могут быть совершенно различны. Обычно интерфейс класса Implementor предоставляет только примитивные операции, а класс Abstraction определяет операции более высокого уровня, базирующиеся на этих примитивах;
• ConcreteImplementor (XWindowImp, PMWindowImp) – конкретный реализатор:
– содержит конкретную реализацию интерфейса класса Implementor.
Объект Abstraction перенаправляет своему объекту Implementor запросы клиента.
Результаты применения паттерна мост таковы:
• отделение реализации от интерфейса. Реализация больше не имеет постоянной привязки к интерфейсу. Реализацию абстракции можно конфигурировать во время выполнения. Объект может даже динамически изменять свою реализацию.
Разделение классов Abstraction и Implementor устраняет также зависимости от реализации, устанавливаемые на этапе компиляции. Чтобы изменить класс реализации, вовсе не обязательно перекомпилировать класс Abstraction и его клиентов. Это свойство особенно важно, если необходимо обеспечить двоичную совместимость между разными версиями библиотеки классов.
Кроме того, такое разделение облегчает разбиение системы на слои и тем самым позволяет улучшить ее структуру. Высокоуровневые части системы должны знать только о классах Abstraction и Implementor;
• повышение степени расширяемости. Можно расширять независимо иерархии классов Abstraction и Implementor;
• сокрытие деталей реализации от клиентов. Клиентов можно изолировать от таких деталей реализации, как разделение объектов класса Implementor и сопутствующего механизма подсчета ссылок.
Если вы предполагаете применить паттерн мост, то подумайте о таких вопросах реализации:
• только один класс Implementor. В ситуациях, когда есть только одна реализация, создавать абстрактный класс Implementor необязательно. Это вырожденный случай паттерна мост – между классами Abstraction и Implementor существует взаимно-однозначное соответствие. Тем не менее разделение все же полезно, если нужно, чтобы изменение реализации класса не отражалось на существующих клиентах (должно быть достаточно заново скомпоновать программу, не перекомпилируя клиентский код).
Для описания такого разделения Каролан (Carolan) [Car89] употребляет сочетание «чеширский кот». В C++ интерфейс класса Implementor можно определить в закрытом заголовочном файле, который не передается клиентам. Это позволяет полностью скрыть реализацию класса от клиентов;
• создание правильного объекта Implementor. Как, когда и где принимается решение о том, какой из нескольких классов Implementor инстанцировать?
Если у класса Abstraction есть информация о конкретных классах ConcreteImplementor, то он может инстанцировать один из них в своем конструкторе; какой именно – зависит от переданных конструктору параметров. Так, если класс коллекции поддерживает несколько реализаций, то решение можно принять в зависимости от размера коллекции. Для небольших коллекций применяется реализация в виде связанного списка, для больших – в виде хэшированных таблиц.
Другой подход – заранее выбрать реализацию по умолчанию, а позже изменять ее в соответствии с тем, как она используется. Например, если число элементов в коллекции становится больше некоторой условной величины, то мы переключаемся с одной реализации на другую, более эффективную.
Можно также делегировать решение другому объекту. В примере с иерархиями Window/WindowImp уместно было бы ввести фабричный объект (см. паттерн абстрактная фабрика), единственная задача которого – инкапсулировать платформенную специфику. Фабрика обладает информацией, объекты WindowImp какого вида надо создавать для данной платформы, а объект Window просто обращается к ней с запросом о предоставлении какого-нибудь объекта WindowImp, при этом понятно, что объект получит то, что нужно. Преимущество описанного подхода: класс Abstraction напрямую не привязан ни к одному из классов Implementor;
• разделение реализаторов. Джеймс Коплиен показал, как в C++ можно применить идиому описатель/тело, чтобы несколькими объектами могла совместно использоваться одна и та же реализация [Cop92]. В теле хранится счетчик ссылок, который увеличивается и уменьшается в классе описателя. Код для присваивания значений описателям, разделяющим одно тело, в общем виде выглядит так:
Handle& Handle::operator= (const Handle& other) {
other._body->Ref();
_body->Unref();
if (_body->RefCount() == 0) {
delete _body;
}
_body = other._body;
return *this;
}
• использование множественного наследования. В C++ для объединения интерфейса с его реализацией можно воспользоваться множественным наследованием [Mar91]. Например, класс может открыто наследовать классу Abstraction и закрыто – классу ConcreteImplementor. Но такое решение зависит от статического наследования и жестко привязывает реализацию к ее интерфейсу. Поэтому реализовать настоящий мост с помощью множественного наследования невозможно, по крайней мере в C++.
В следующем коде на C++ реализован пример Window/WindowImp, который обсуждался в разделе «Мотивация». Класс Window определяет абстракцию окна для клиентских приложений:
class Window {
public:
Window(View* contents);
// запросы, обрабатываемые окном
virtual void DrawContents();
virtual void Open();
virtual void Close();
virtual void Iconify();
virtual void Deiconify();
// запросы, перенаправляемые реализации
virtual void SetOrigin(const Point& at);
virtual void SetExtent(const Point& extent);
virtual void Raise();
virtual void Lower();
virtual void DrawLine(const Point&, const Point&);
virtual void DrawRect(const Point&, const Point&);
virtual void DrawPolygon(const Point[], int n);
virtual void DrawText(const char*, const Point&);
protected:
WindowImp* GetWindowImp();
View* GetView();
private:
WindowImp* _imp;
View* _contents; // содержимое окна
};
В классе Window хранится ссылка на WindowImp – абстрактный класс, в котором объявлен интерфейс к данной оконной системе:
class WindowImp {
public:
virtual void ImpTop() = 0;
virtual void ImpBottom() = 0;
virtual void ImpSetExtent(const Point&) = 0;
virtual void ImpSetOrigin(const Point&) = 0;
virtual void DeviceRect(Coord, Coord, Coord, Coord) = 0;
virtual void DeviceText(const char*, Coord, Coord) = 0;
virtual void DeviceBitmap(const char*, Coord, Coord) = 0;
// множество других функций для рисования в окне...
protected:
WindowImp();
};
Подклассы Window определяют различные виды окон, как то: окно приложения, пиктограмма, временное диалоговое окно, плавающая палитра инструментов и т.д.
Например, класс ApplicationWindow реализует операцию DrawContents для отрисовки содержимого экземпляра класса View, который в нем хранится:
class ApplicationWindow : public Window {
public:
// ...
virtual void DrawContents();
};
void ApplicationWindow::DrawContents () {
GetView()->DrawOn(this);
}
А в классе IconWindow содержится имя растрового изображения для пиктограммы
class IconWindow : public Window {
public:
// ...
virtual void DrawContents();
private:
const char* _bitmapName;
};
и реализация операции DrawContents для рисования этого изображения в окне:
void IconWindow::DrawContents() {
WindowImp* imp = GetWindowImp();
if (imp != 0) {
imp->DeviceBitmap(_bitmapName, 0.0, 0.0);
}
}
Могут существовать и другие разновидности класса Window. Окну класса TransientWindow иногда необходимо как-то сообщаться с создавшим его окном во время диалога, поэтому в объекте класса хранится ссылка на создателя. Окно класса PaletteWindow всегда располагается поверх других. Окно класса IconDockWindow (контейнер пиктограмм) хранит окна класса IconWindow и располагает их в ряд.
Операции класса Window определены в терминах интерфейса WindowImp. Например, DrawRect вычисляет координаты по двум своим параметрам Point перед тем, как вызвать операцию WindowImp, которая рисует в окне прямоугольник:
void Window::DrawRect (const Point& p1, const Point& p2) {
WindowImp* imp = GetWindowImp();
imp->DeviceRect(p1.X(), p1.Y(), p2.X(), p2.Y());
}
Конкретные подклассы WindowImp поддерживают разные оконные системы. Так, класс XWindowImp ориентирован на систему X Window:
class XWindowImp : public WindowImp {
public:
XWindowImp();
virtual void DeviceRect(Coord, Coord, Coord, Coord);
// прочие операции открытого интерфейса...
private:
// переменные, описывающие специфичное для X Window состояние,
// в том числе:
Display* _dpy;
Drawable _winid; // идентификатор окна
GC _gc; // графический контекст окна
};
Для Presentation Manager (PM) мы определяем класс PMWindowImp:
class PMWindowImp : public WindowImp {
public:
PMWindowImp();
virtual void DeviceRect(Coord, Coord, Coord, Coord);
// прочие операции открытого интерфейса...
private:
// переменные, описывающие специфичное для PM Window состояние,
// в том числе:
HPS _hps;
};
Эти подклассы реализуют операции WindowImp в терминах примитивов оконной системы. Например, DeviceRect для X Window реализуется так:
void XWindowImp::DeviceRect (
Coord x0, Coord y0, Coord x1, Coord y1
) {
int x = round(min(x0, x1));
int y = round(min(y0, y1));
int w = round(abs(x0 – x1));
int h = round(abs(y0 – y1));
XDrawRectangle(_dpy, _winid, _gc, x, y, w, h);
}
А для PM – так:
void PMWindowImp::DeviceRect (
Coord x0, Coord y0, Coord x1, Coord y1
) {
Coord left = min(x0, x1);
Coord right = max(x0, x1);
Coord bottom = min(y0, y1);
Coord top = max(y0, y1);
PPOINTL point[4];
point[0].x = left; point[0].y = top;
point[1].x = right; point[1].y = top;
point[2].x = right; point[2].y = bottom;
point[3].x = left; point[3].y = bottom;
if (
(GpiBeginPath(_hps, 1L) == false) ||
(GpiSetCurrentPosition(_hps, &point[3]) == false) ||
(GpiPolyLine(_hps, 4L, point) == GPI_ERROR) ||
(GpiEndPath(_hps) == false)
) {
// сообщить об ошибке
} else {
GpiStrokePath(_hps, 1L, 0L);
}
}
Как окно получает экземпляр нужного подкласса WindowImp? В данном примере мы предположим, что за это отвечает класс Window. Его операция GetWindowImp получает подходящий экземпляр от абстрактной фабрики (см. описание паттерна абстрактная фабрика), которая инкапсулирует все зависимости от оконной системы.
WindowImp* Window::GetWindowImp () {
if (_imp == 0) {
_imp = WindowSystemFactory::Instance()->MakeWindowImp();
}
return _imp;
}
WindowSystemFactory::Instance() возвращает абстрактную фабрику, которая изготавливает все системно-зависимые объекты. Для простоты мы сделали эту фабрику одиночкой и позволили классу Window обращаться к ней напрямую.
Пример класса Window позаимствован из ET++ [WGM88]. В ET++ класс WindowImp называется WindowPort и имеет такие подклассы, как XWindowPort
и SunWindowPort. Объект Window создает соответствующего себе реализатора Implementor, запрашивая его у абстрактной фабрики, которая называется WindowSystem. Эта фабрика предоставляет интерфейс для создания платформенно-зависимых объектов: шрифтов, курсоров, растровых изображений и т.д.
Дизайн классов Window/WindowPort в ET++ обобщает паттерн мост в том отношении, что WindowPort сохраняет также обратную ссылку на Window. Класс-реализатор WindowPort использует эту ссылку для извещения Window о событиях, специфичных для WindowPort: поступлений событий ввода, изменениях размера окна и т.д.
В работах Джеймса Коплиена [Cop92] и Бьерна Страуструпа [Str91] упоминаются классы описателей и приводятся некоторые примеры. Основной акцент
в этих примерах сделан на вопросах управления памятью, например разделении представления строк и поддержке объектов переменного размера. Нас же в первую очередь интересует поддержка независимых расширений абстракции и ее реализации.
В библиотеке libg++ [Lea88] определены классы, которые реализуют универсальные структуры данных: Set (множество), LinkedSet (множество как связанный список), HashSet (множество как хэш-таблица), LinkedList (связанный список) и HashTable (хэш-таблица). Set – это абстрактный класс, определяющий абстракцию множества, а LinkedList и HashTable – конкретные реализации связанного списка и хэш-таблицы. LinkedSet и HashSet – реализаторы абстракции Set, перекидывающие мост между Set и LinkedList и HashTable соответственно. Перед вами пример вырожденного моста, поскольку абстрактного класса Implementor здесь нет.
В библиотеке NeXT AppKit [Add94] паттерн мост используется при реализации и отображении графических изображений. Рисунок может быть представлен по-разному. Оптимальный способ его отображения на экране зависит от свойств дисплея и прежде всего от числа цветов и разрешения. Если бы не AppKit, то для каждого приложения разработчикам пришлось бы самостоятельно выяснять, какой реализацией пользоваться в конкретных условиях.
AppKit предоставляет мост NXImage/NXImageRep. Класс NXImage определяет интерфейс для обработки изображений. Реализация же определена в отдельной иерархии классов NXImageRep, в которой есть такие подклассы, как NXEPSImageRep, NXCachedImageRep и NXBitMapImageRep. В классе NXImage хранятся ссылки на один или более объектов NXImageRep. Если имеется более одной реализации изображения, то NXImage выбирает самую подходящую для данного дисплея. При необходимости NXImage также может преобразовать изображение из одного формата в другой. Интересная особенность этого варианта моста в том, что NXImage может одновременно хранить несколько реализаций NXImageRep.
Паттерн абстрактная фабрика может создать и сконфигурировать мост.
Для обеспечения совместной работы не связанных между собой классов прежде всего предназначен паттерн адаптер. Обычно он применяется в уже готовых системах. Мост же участвует в проекте с самого начала и призван поддержать возможность независимого изменения абстракций и их реализаций.
Компоновщик – паттерн, структурирующий объекты.
Компонует объекты в древовидные структуры для представления иерархий часть-целое. Позволяет клиентам единообразно трактовать индивидуальные и составные объекты.
Такие приложения, как графические редакторы и редакторы электрических схем, позволяют пользователям строить сложные диаграммы из более простых компонентов. Проектировщик может сгруппировать мелкие компоненты для формирования более крупных, которые, в свою очередь, могут стать основой для со-здания еще более крупных. В простой реализации допустимо было бы определить классы графических примитивов, например текста и линий, а также классы, выступающие в роли контейнеров для этих примитивов.
Но у такого решения есть существенный недостаток. Программа, в которой эти классы используются, должна по-разному обращаться с примитивами и контейнерами, хотя пользователь чаще всего работает с ними единообразно. Необходимость различать эти объекты усложняет приложение. Паттерн компоновщик описывает, как можно применить рекурсивную композицию таким образом, что клиенту не придется проводить различие между простыми и составными объектами.
Ключом к паттерну компоновщик является абстрактный класс, который представляет одновременно и примитивы, и контейнеры. В графической системе этот класс может называться Graphic. В нем объявлены операции, специфичные для каждого вида графического объекта (такие как Draw) и общие для всех составных объектов, например операции для доступа и управления потомками.
Подклассы Line, Rectangle и Text (см. диаграмму выше) определяют примитивные графические объекты. В них операция Draw реализована соответственно для рисования прямых, прямоугольников и текста. Поскольку у примитивных объектов нет потомков, то ни один из этих подклассов не реализует операции, относящиеся к управлению потомками.
Класс Picture определяет агрегат, состоящий из объектов Graphic. Реализованная в нем операция Draw вызывает одноименную функцию для каждого потомка, а операции для работы с потомками уже не пусты. Поскольку интерфейс класса Picture соответствует интерфейсу Graphic, то в состав объекта Picture могут входить и другие такие же объекты.
Ниже на диаграмме показана типичная структура составного объекта, рекурсивно скомпонованного из объектов класса Graphic.
Используйте паттерн компоновщик, когда:
• нужно представить иерархию объектов вида часть-целое;
• хотите, чтобы клиенты единообразно трактовали составные и индивидуальные объекты.
Структура типичного составного объекта могла бы выглядеть так:
• Component (Graphic) – компонент:
– объявляет интерфейс для компонуемых объектов;
– предоставляет подходящую реализацию операций по умолчанию, общую для всех классов;
– объявляет интерфейс для доступа к потомкам и управления ими;
– определяет интерфейс для доступа к родителю компонента в рекурсивной структуре и при необходимости реализует его. Описанная возможность необязательна;
• Leaf (Rectangle, Line, Text, и т.п.) – лист:
– представляет листовые узлы композиции и не имеет потомков;
– определяет поведение примитивных объектов в композиции;
• Composite (Picture) – составной объект:
– определяет поведение компонентов, у которых есть потомки;
– хранит компоненты-потомки;
– реализует относящиеся к управлению потомками операции в интерфейсе класса Component;
• Client – клиент:
– манипулирует объектами композиции через интерфейс Component.
Клиенты используют интерфейс класса Component для взаимодействия с объектами в составной структуре. Если получателем запроса является листовый объект Leaf, то он и обрабатывает запрос. Когда же получателем является составной объект Composite, то обычно он перенаправляет запрос своим потомкам, возможно, выполняя некоторые дополнительные операции до или после перенаправления.
Паттерн компоновщик:
• определяет иерархии классов, состоящие из примитивных и составных объектов. Из примитивных объектов можно составлять более сложные, которые, в свою очередь, участвуют в более сложных композициях и так далее. Любой клиент, ожидающий примитивного объекта, может работать и с составным;
• упрощает архитектуру клиента. Клиенты могут единообразно работать
с индивидуальными и объектами и с составными структурами. Обычно клиенту неизвестно, взаимодействует ли он с листовым или составным объектом. Это упрощает код клиента, поскольку нет необходимости писать функции, ветвящиеся в зависимости от того, с объектом какого класса они работают;
• облегчает добавление новых видов компонентов. Новые подклассы классов Composite или Leaf будут автоматически работать с уже существующими структурами и клиентским кодом. Изменять клиента при добавлении новых компонентов не нужно;
• способствует созданию общего дизайна. Однако такая простота добавления новых компонентов имеет и свои отрицательные стороны: становится трудно наложить ограничения на то, какие объекты могут входить в состав композиции. Иногда желательно, чтобы составной объект мог включать только определенные виды компонентов. Паттерн компоновщик не позволяет воспользоваться для реализации таких ограничений статической системой типов. Вместо этого следует проводить проверки во время выполнения.
При реализации паттерна компоновщик приходится рассматривать много вопросов:
• явные ссылки на родителей. Хранение в компоненте ссылки на своего родителя может упростить обход структуры и управление ею. Наличие такой ссылки облегчает передвижение вверх по структуре и удаление компонента. Кроме того, ссылки на родителей помогают поддержать паттерн цепочка обязанностей.
Обычно ссылку на родителя определяют в классе Component. Классы Leaf и Composite могут унаследовать саму ссылку и операции с ней.
При наличии ссылки на родителя важно поддерживать следующий инвариант: если некоторый объект в составной структуре ссылается на другой составной объект как на своего родителя, то для последнего первый является потомком. Простейший способ гарантировать соблюдение этого условия – изменять родителя компонента только тогда, когда он добавляется или удаляется из составного объекта. Если это удается один раз реализовать в операциях Add и Remove, то реализация будет унаследована всеми подклассами и, значит, инвариант будет поддерживаться автоматически;
• разделение компонентов. Часто бывает полезно разделять компоненты, например для уменьшения объема занимаемой памяти. Но если у компонента может быть более одного родителя, то разделение становится проблемой.
Возможное решение – позволить компонентам хранить ссылки на нескольких родителей. Однако в таком случае при распространении запроса по структуре могут возникнуть неоднозначности. Паттерн приспособленец показывает, как следует изменить дизайн, чтобы вовсе отказаться от хранения родителей. Работает он в тех случаях, когда потомки могут не посылать сообщений своим родителям, вынеся за свои границы часть внутреннего состояния;
• максимизация интерфейса класса Component. Одна из целей паттерна компоновщик – избавить клиентов от необходимости знать, работают ли они
с листовым или составным объектом. Для достижения этой цели класс Component должен сделать как можно больше операций общими для классов Composite и Leaf. Обычно класс Component предоставляет для этих операций реализации по умолчанию, а подклассы Composite и Leaf замещают их.
Однако иногда эта цель вступает в конфликт с принципом проектирования иерархии классов, согласно которому класс должен определять только логичные для всех его подклассах операции. Класс Component поддерживает много операций, не имеющих смысла для класса Leaf. Как же тогда предоставить для них реализацию по умолчанию?
Иногда, проявив изобретательность, удается перенести в класс Component операцию, которая, на первый взгляд, имеет смысл только для составных объектов. Например, интерфейс для доступа к потомкам является фундаментальной частью класса Composite, но вовсе не обязательно класса Leaf. Однако если рассматривать Leaf как Component, у которого никогда не бывает потомков, то мы можем определить в классе Component операцию доступа к потомкам как никогда не возвращающую потомков. Тогда подклассы Leaf могут использовать эту реализацию по умолчанию, а в подклассах Composite она будет переопределена, чтобы возвращать потомков.
Операции для управления потомками довольно хлопотны, мы обсудим их
в следующем разделе;
• объявление операций для управления потомками. Хотя в классе Composite реализованы операции Add и Remove для добавления и удаления потомков, но для паттерна компоновщик важно, в каких классах эти операции объявлены. Надо ли объявлять их в классе Component и тем самым делать доступными в Leaf, или их следует объявить и определить только в классе Composite и его подклассах?
Решая этот вопрос, мы должны выбирать между безопасностью и прозрачностью:
– если определить интерфейс для управления потомками в корне иерархии классов, то мы добиваемся прозрачности, так как все компоненты удается трактовать единообразно. Однако расплачиваться приходится безопасностью, поскольку клиент может попытаться выполнить бессмысленное действие, например добавить или удалить объект из листового узла;
– если управление потомками сделать частью класса Composite, то безопасность удастся обеспечить, ведь любая попытка добавить или удалить объекты из листьев в статически типизированном языке вроде C++ будет перехвачена на этапе компиляции. Но прозрачность мы утрачиваем, ибо у листовых и составных объектов оказываются разные интерфейсы.
В паттерне компоновщик мы придаем особое значение прозрачности, а не безопасности. Если для вас важнее безопасность, будьте готовы к тому, что иногда вы можете потерять информацию о типе и придется преобразовывать компонент к типу составного объекта. Как это сделать, не прибегая
к небезопасным приведениям типов?
Можно, например, объявить в классе Component операцию Composite* GetComposite(). Класс Component реализует ее по умолчанию, возвращая нулевой указатель. А в классе Composite эта операция переопределена и возвращает указатель this на сам объект:
class Composite;
class Component {
public:
//...
virtual Composite* GetComposite() { return 0; }
};
class Composite : public Component {
public:
void Add(Component*);
// ...
virtual Composite* GetComposite() { return this; }
};
class Leaf : public Component {
// ...
};
Благодаря операции GetComposite можно спросить у компонента, является ли он составным. К возвращаемому этой операцией составному объекту допустимо безопасно применять операции Add и Remove:
Composite* aComposite = new Composite;
Leaf* aLeaf = new Leaf;
Component* aComponent;
Composite* test;
aComponent = aComposite;
if (test = aComponent->GetComposite()) {
test->Add(new Leaf);
}
aComponent = aLeaf;
if (test = aComponent->GetComposite()) {
test->Add(new Leaf); // не добавит лист
}
Аналогичные проверки на принадлежность классу Composite в C++ выполняют и с помощью оператора dynamic_cast.
Разумеется, при таком подходе мы не обращаемся со всеми компонентами единообразно, что плохо. Снова приходится проверять тип, перед тем как предпринять то или иное действие.
Единственный способ обеспечить прозрачность – это включить в класс Component реализации операций Add и Remove по умолчанию. Но появится новая проблема: нельзя реализовать Component::Add так, чтобы она никогда не приводила к ошибке. Можно, конечно, сделать данную операцию пустой, но тогда нарушается важное проектное ограничение: попытка добавить что-то в листовый объект, скорее всего, свидетельствует об ошибке. Допустимо было бы заставить ее удалять свой аргумент, но клиент может быть не рассчитанным на это.
Обычно лучшим решением является такая реализация Add и Remove по умолчанию, при которой они завершаются с ошибкой (возможно, возбуждая исключение), если компоненту не разрешено иметь потомков (для Add) или аргумент не является чьим-либо потомком (для Remove).
Другая возможность – слегка изменить семантику операции «удаления». Если компонент хранит ссылку на родителя, то можно было бы считать, что Component::Remove удаляет самого себя. Но для операции Add по-прежнему нет разумной интерпретации;
• должен ли Component реализовывать список компонентов. Может возникнуть желание определить множество потомков в виде переменной экземпляра класса Component, в котором объявлены операции доступа и управления потомками. Но размещение указателя на потомков в базовом классе приводит к непроизводительному расходу памяти во всех листовых узлах, хотя у листа потомков быть не может. Такой прием можно применить, только если в структуре не слишком много потомков;
• упорядочение потомков. Во многих случаях порядок следования потомков составного объекта важен. В рассмотренном выше примере класса Graphic под порядком может пониматься Z-порядок расположения потомков. В составных объектах, описывающих деревья синтаксического разбора, составные операторы могут быть экземплярами класса Composite, порядок следования потомков которых отражает семантику программы.
Если порядок следования потомков важен, необходимо учитывать его при проектировании интерфейсов доступа и управления потомками. В этом может помочь паттерн итератор;
• кэширование для повышения производительности. Если приходится часто обходить композицию или производить в ней поиск, то класс Composite может кэшировать информацию об обходе и поиске. Кэшировать разрешается либо полученные результаты, либо только информацию, достаточную для ускорения обхода или поиска. Например, класс Picture из примера, приведенного в разделе «Мотивация», мог бы кэшировать охватывающие прямоугольники своих потомков. При рисовании или выборе эта информация позволила бы пропускать тех потомков, которые не видимы в текущем окне.
Любое изменение компонента должно делать кэши всех его родителей недействительными. Наиболее эффективен такой подход в случае, когда компонентам известно об их родителях. Поэтому, если вы решите воспользоваться кэшированием, необходимо определить интерфейс, позволяющий уведомить составные объекты о недействительности их кэшей;
• кто должен удалять компоненты. В языках, где нет сборщика мусора, лучше всего поручить классу Composite удалять своих потомков в момент уничтожения. Исключением из этого правила является случай, когда листовые объекты постоянны и, следовательно, могут разделяться;
• какая структура данных лучше всего подходит для хранения компонентов. Составные объекты могут хранить своих потомков в самых разных структурах данных, включая связанные списки, деревья, массивы и хэш-таблицы. Выбор структуры данных определяется, как всегда, эффективностью. Собственно говоря, вовсе не обязательно пользоваться какой-либо из универсальных структур. Иногда в составных объектах каждый потомок представляется отдельной переменной. Правда, для этого каждый подкласс Composite должен реализовывать свой собственный интерфейс управления памятью. См. пример в описании паттерна интерпретатор.
Такие изделия, как компьютеры и стереокомпоненты, часто имеют иерархическую структуру. Например, в раме монтируются дисковые накопители и плоские электронные платы, к шине подсоединяются различные карты, а корпус содержит раму, шины и т.д. Подобные структуры моделируются с помощью паттерна компоновщик.
Класс Equipment определяет интерфейс для всех видов аппаратуры в иерархии вида часть-целое:
class Equipment {
public:
virtual ~Equipment();
const char* Name() { return _name; }
virtual Watt Power();
virtual Currency NetPrice();
virtual Currency DiscountPrice();
virtual void Add(Equipment*);
virtual void Remove(Equipment*);
virtual Iterator<Equipment*>* CreateIterator();
protected:
Equipment(const char*);
private:
const char* _name;
};
В классе Equipment объявлены операции, которые возвращают атрибуты аппаратного блока, например энергопотребление и стоимость. Подклассы реализуют эти операции для конкретных видов оборудования. Класс Equipment объявляет также операцию CreateIterator, возвращающую итератор Iterator (см. приложение C) для доступа к отдельным частям. Реализация этой операции по умолчанию возвращает итератор NullIterator, умеющий обходить только пустое множество.
Среди подклассов Equipment могут быть листовые классы, представляющие дисковые накопители, СБИС и переключатели:
class FloppyDisk : public Equipment {
public:
FloppyDisk(const char*);
virtual ~FloppyDisk();
virtual Watt Power();
virtual Currency NetPrice();
virtual Currency DiscountPrice();
};
CompositeEquipment – это базовый класс для оборудования, содержащего другое оборудование. Одновременно это подкласс класса Equipment:
class CompositeEquipment : public Equipment {
public:
virtual ~CompositeEquipment();
virtual Watt Power();
virtual Currency NetPrice();
virtual Currency DiscountPrice();
virtual void Add(Equipment*);
virtual void Remove(Equipment*);
virtual Iterator<Equipment*>* CreateIterator();
protected:
CompositeEquipment(const char*);
private:
List<Equipment*> _equipment;
};
CompositeEquipment определяет операции для доступа и управления внутренними аппаратными блоками. Операции Add и Remove добавляют и удаляют оборудование из списка, хранящегося в переменной-члене _equipment. Операция CreateIterator возвращает итератор (точнее, экземпляр класса ListIterator), который будет обходить этот список.
Подразумеваемая реализация операции NetPrice могла бы использовать CreateIterator для суммирования цен на отдельные блоки:1
Currency CompositeEquipment::NetPrice () {
Iterator<Equipment*>* i = CreateIterator();
Currency total = 0;
for (i->First(); !i->IsDone(); i->Next()) {
total += i->CurrentItem()->NetPrice();
}
delete i;
return total;
}
Теперь мы можем представить аппаратный блок компьютера в виде подкласса к CompositeEquipment под названием Chassis. Chassis наследует порожденные операции класса CompositeEquipment.
class Chassis : public CompositeEquipment {
public:
Chassis(const char*);
virtual ~Chassis();
virtual Watt Power();
virtual Currency NetPrice();
virtual Currency DiscountPrice();
};
Мы можем аналогично определить и другие контейнеры для оборудования, например Cabinet (корпус) и Bus (шина). Этого вполне достаточно для сборки из отдельных блоков довольно простого персонального компьютера:
Cabinet* cabinet = new Cabinet("PC Cabinet");
Chassis* chassis = new Chassis("PC Chassis");
cabinet->Add(chassis);
Bus* bus = new Bus("MCA Bus");
bus->Add(new Card("16Mbs Token Ring"));
chassis->Add(bus);
chassis->Add(new FloppyDisk("3.5in Floppy"));
cout << "Полная стоимость равна " << chassis->NetPrice() << endl;
Примеры паттерна компоновщик можно найти почти во всех объектно-ориентированных системах. Первоначально класс View в схеме модель/вид/контроллер в языке Smalltalk [KP88] был компоновщиком, и почти все библиотеки для построения пользовательских интерфейсов и каркасы проектировались аналогично. Среди них ET++ (со своей библиотекой VObjects [WGM88]) и InterViews (классы Styles [LCI+92], Graphics [VL88] и Glyphs [CL90]). Интересно отметить, что первоначально вид View имел несколько подвидов, то есть он был одновременно
и классом Component, и классом Composite. В версии 4.0 языка Smalltalk-80 схема модель/вид/контроллер была пересмотрена, в нее ввели класс VisualComponent, подклассами которого являлись View и CompositeView.
В каркасе для построения компиляторов RTL, который написан на Smalltalk [JML92], паттерн компоновщик используется очень широко. RTLExpression – это разновидность класса Component для построения деревьев синтаксического разбора. У него есть подклассы, например BinaryExpression, потомками которого являются объекты класса RTLExpression. В совокупности эти классы определяют составную структуру для деревьев разбора. RegisterTransfer – класс Component для промежуточной формы представления программы SSA (Single Static Assignment). Листовые подклассы RegisterTransfer определяют различные статические присваивания, например:
• примитивные присваивания, которые выполняют операцию над двумя регистрами и сохраняют результат в третьем;
• присваивание, у которого есть исходный, но нет целевого регистра. Следовательно, регистр используется после возврата из процедуры;
• присваивание, у которого есть целевой, но нет исходного регистра. Это означает, что присваивание регистру происходит перед началом процедуры.
Подкласс RegisterTransferSet является примером класса Composite для представления присваиваний, изменяющих сразу несколько регистров.
Другой пример применения паттерна компоновщик – финансовые программы, когда инвестиционный портфель состоит их нескольких отдельных активов. Можно поддержать сложные агрегаты активов, если реализовать портфель в виде компоновщика, согласованного с интерфейсом каждого актива [BE93].
Паттерн команда описывает, как можно компоновать и упорядочивать объекты Command с помощью класса компоновщика MacroCommand.
Отношение компонент-родитель используется в паттерне цепочка обязанностей.
Паттерн декоратор часто применяется совместно с компоновщиком. Когда декораторы и компоновщики используются вместе, у них обычно бывает общий родительский класс. Поэтому декораторам придется поддержать интерфейс компонентов такими операциями, как Add, Remove и GetChild.
Паттерн приспособленец позволяет разделять компоненты, но ссылаться на своих родителей они уже не могут.
Итератор можно использовать для обхода составных объектов.
Посетитель локализует операции и поведение, которые в противном случае пришлось бы распределять между классами Composite и Leaf.
Декоратор – паттерн, структурирующий объекты.
Динамически добавляет объекту новые обязанности. Является гибкой альтернативой порождению подклассов с целью расширения функциональности.
Wrapper (обертка).
Иногда бывает нужно возложить дополнительные обязанности на отдельный объект, а не на класс в целом. Так, библиотека для построения графических интерфейсов пользователя должна «уметь» добавлять новое свойство, скажем, рамку или новое поведение (например, возможность прокрутки к любому элементу интерфейса).
Добавить новые обязанности допустимо с помощью наследования. При наследовании классу с рамкой вокруг каждого экземпляра подкласса будет рисоваться рамка. Однако это решение статическое, а значит, недостаточно гибкое. Клиент не может управлять оформлением компонента рамкой.
Более гибким является другой подход: поместить компонент в другой объект, называемый декоратором, который как раз и добавляет рамку. Декоратор следует интерфейсу декорируемого объекта, поэтому его присутствие прозрачно для клиентов компонента. Декоратор переадресует запросы внутреннему компоненту, но может выполнять и дополнительные действия (например, рисовать рамку) до или после переадресации. Поскольку декораторы прозрачны, они могут вкладываться друг в друга, добавляя тем самым любое число новых обязанностей.
Предположим, что имеется объект класса TextView, который отображает текст в окне. По умолчанию TextView не имеет полос прокрутки, поскольку они не всегда нужны. Но при необходимости их удастся добавить с помощью декоратора ScrollDecorator. Допустим, что еще мы хотим добавить жирную сплошную рамку вокруг объекта TextView. Здесь может помочь декоратор BorderDecorator. Мы просто компонуем оба декоратора с BorderDecorator и получаем искомый результат.
Ниже на диаграмме показано, как композиция объекта TextView с объектами BorderDecorator и ScrollDecorator порождает элемент для ввода текста, окруженный рамкой и снабженный полосой прокрутки.
Классы ScrollDecorator и BorderDecorator являются подклассами Decorator – абстрактного класса, который представляет визуальные компоненты, применяемые для оформления других визуальных компонентов.
VisualComponent – это абстрактный класс для представления визуальных объектов. В нем определен интерфейс для рисования и обработки событий. Отметим, что класс Decorator просто переадресует запросы на рисование своему компоненту, а его подклассы могут расширять эту операцию.
Подклассы Decorator могут добавлять любые операции для обеспечения необходимой функциональности. Так, операция ScrollTo объекта ScrollDecorator позволяет другим объектам выполнять прокрутку, если им известно о присутствии объекта ScrollDecorator. Важная особенность этого паттерна состоит
в том, что декораторы могут употребляться везде, где возможно появление самого объекта VisualComponent. Поэтому клиент не может отличить декорированный объект от недекорированного, а значит, и никоим образом не зависит от наличия или отсутствия оформлений.
Используйте паттерн декоратор:
• для динамического, прозрачного для клиентов добавления обязанностей объектам;
• для реализации обязанностей, которые могут быть сняты с объекта;
• когда расширение путем порождения подклассов по каким-то причинам неудобно или невозможно. Иногда приходится реализовывать много независимых расширений, так что порождение подклассов для поддержки всех возможных комбинаций приведет к комбинаторному росту их числа. В других случаях определение класса может быть скрыто или почему-либо еще недоступно, так что породить от него подкласс нельзя.
• Component (VisualComponent) – компонент:
– определяет интерфейс для объектов, на которые могут быть динамически возложены дополнительные обязанности;
• ConcreteComponent (TextView) – конкретный компонент:
– определяет объект, на который возлагаются дополнительные обязанности;
• Decorator – декоратор:
– хранит ссылку на объект Component и определяет интерфейс, соответствующий интерфейсу Component;
• ConcreteDecorator (BorderDecorator, ScrollDecorator) – конкретный декоратор:
– возлагает дополнительные обязанности на компонент.
Decorator переадресует запросы объекту Component. Может выполнять
и дополнительные операции до и после переадресации.
У паттерна декоратор есть, по крайней мере, два плюса и два минуса:
• большая гибкость, нежели у статического наследования. Паттерн декоратор позволяет более гибко добавлять объекту новые обязанности, чем было бы возможно в случае статического (множественного) наследования. Декоратор может добавлять и удалять обязанности во время выполнения программы. При использовании же наследования требуется создавать новый класс для каждой дополнительной обязанности (например, BorderedScrollableTextView, BorderedTextView), что ведет к увеличению числа классов и, как следствие, к возрастанию сложности системы. Кроме того, применение нескольких декораторов к одному компоненту позволяет произвольным образом сочетать обязанности.
Декораторы позволяют легко добавить одно и то же свойство дважды. Например, чтобы окружить объект TextView двойной рамкой, нужно просто добавить два декоратора BorderDecorators. Двойное наследование классу Border в лучшем случае чревато ошибками;
• позволяет избежать перегруженных функциями классов на верхних уровнях иерархии. Декоратор разрешает добавлять новые обязанности по мере необходимости. Вместо того чтобы пытаться поддержать все мыслимые возможности в одном сложном, допускающем разностороннюю настройку классе, вы можете определить простой класс и постепенно наращивать его функциональность с помощью декораторов. В результате приложение уже не платит за неиспользуемые функции. Нетрудно также определять новые виды декораторов независимо от классов, которые они расширяют, даже если первоначально такие расширения не планировались. При расширении же сложного класса обычно приходится вникать в детали, не имеющие отношения
к добавляемой функции;
• декоратор и его компонент не идентичны. Декоратор действует как прозрачное обрамление. Но декорированный компонент все же не идентичен исходному. При использовании декораторов это следует иметь в виду;
• множество мелких объектов. При использовании в проекте паттерна декоратор нередко получается система, составленная из большого числа мелких объектов, которые похожи друг на друга и различаются только способом взаимосвязи, а не классом и не значениями своих внутренних переменных. Хотя проектировщик, разбирающийся в устройстве такой системы, может легко настроить ее, но изучать и отлаживать ее очень тяжело.
Применение паттерна декоратор требует рассмотрения нескольких вопросов:
• соответствие интерфейсов. Интерфейс декоратора должен соответствовать интерфейсу декорируемого компонента. Поэтому классы ConcreteDecorator должны наследовать общему классу (по крайней мере, в C++);
• отсутствие абстрактного класса Decorator. Нет необходимости определять абстрактный класс Decorator, если планируется добавить всего одну обязанность. Так часто происходит, когда вы работаете с уже существующей иерархией классов, а не проектируете новую. В таком случае ответственность за переадресацию запросов, которую обычно несет класс Decorator, можно возложить непосредственно на ConcreteDecorator;
• облегченные классы Component. Чтобы можно было гарантировать соответствие интерфейсов, компоненты и декораторы должны наследовать общему классу Component. Важно, чтобы этот класс был настолько легким, насколько возможно. Иными словами, он должен определять интерфейс, а не хранить данные. В противном случае декораторы могут стать весьма тяжеловесными, и применять их в большом количестве будет накладно. Включение большого числа функций в класс Component также увеличивает вероятность, что конкретным подклассам придется платить за то, что им не нужно;
• изменение облика, а не внутреннего устройства объекта. Декоратор можно рассматривать как появившуюся у объекта оболочку, которая изменяет его поведение. Альтернатива – изменение внутреннего устройства объекта, хорошим примером чего может служить паттерн стратегия.
Стратегии лучше подходят в ситуациях, когда класс Component уже достаточно тяжел, так что применение паттерна декоратор обходится слишком дорого. В паттерне стратегия компоненты передают часть своей функциональности отдельному объекту-стратегии, поэтому изменить или расширить поведение компонента допустимо, заменив этот объект.
Например, мы можем поддержать разные стили рамок, поручив рисование рамки специальному объекту Border. Объект Border является примером объекта-стратегии: в данном случае он инкапсулирует стратегию рисования рамки. Число стратегий может быть любым, поэтому эффект такой же, как от рекурсивной вложенности декораторов.
Например, в системах MacApp 3.0 [App89] и Bedrock [Sym93a] графические компоненты, называемые видами (views), хранят список объектов-оформителей (adorner), которые могут добавлять различные оформления вроде границ
к виду. Если к виду присоединены такие объекты, он дает им возможность выполнить свои функции. MacApp и Bedrock вынуждены предоставить доступ к этим операциям, поскольку класс View весьма тяжел. Было бы слишком расточительно использовать полномасштабный объект этого класса только для того, чтобы добавить рамку.
Поскольку паттерн декоратор изменяет лишь внешний облик компонента, последнему ничего не надо «знать» о своих декораторах, то есть декораторы прозрачны для компонента.
В случае стратегий самому компоненту известно о возможных расширениях. Поэтому он должен располагать информацией обо всех стратегиях и ссылаться на них.
При использовании подхода, основанного на стратегиях, может возникнуть необходимость модифицировать компонент, чтобы он соответствовал новому расширению. С другой стороны, у стратегии может быть свой собственный специализированный интерфейс, тогда как интерфейс декоратора должен повторять интерфейс компонента. Например, стратегии рисования рамки необходимо определить всего лишь интерфейс для этой операции (DrawBorder, GetWidth и т.д.), то есть класс стратегии может быть легким, несмотря на тяжеловесность компонента.
Системы MacApp и Bedrock применяют такой подход не только для оформления видов, но и для расширения особенностей поведения объектов, связанных с обработкой событий. В обеих системах вид ведет список объектов поведения, которые могут модифицировать и перехватывать события. Каждому зарегистрированному объекту поведения вид предоставляет возможность обработать событие до того, как оно будет передано незарегистрированным объектам такого рода, за счет чего достигается переопределение поведения. Можно, например, декорировать вид специальной поддержкой работы с клавиатурой, если зарегистрировать объект поведения, который перехватывает и обрабатывает события нажатия клавиш.
В следующем примере показано, как реализовать декораторы пользовательского интерфейса в программе на C++. Мы будем предполагать, что класс компонента называется VisualComponent:
class VisualComponent {
public:
VisualComponent();
virtual void Draw();
virtual void Resize();
// ...
};
Определим подкласс класса VisualComponent с именем Decorator, от которого затем породим подклассы, реализующие различные оформления:
class Decorator : public VisualComponent {
public:
Decorator(VisualComponent*);
virtual void Draw();
virtual void Resize();
// ...
private:
VisualComponent* _component;
};
Объект класса Decorator декорирует объект VisualComponent, на который ссылается переменная экземпляра _component, инициализируемая в конструкторе. Для каждой операции в интерфейсе VisualComponent в классе Decorator определена реализация по умолчанию, передающая запросы объекту, на который ведет ссылка _component:
void Decorator::Draw () {
_component->Draw();
}
void Decorator::Resize () {
_component->Resize();
}
Подклассы Decorator определяют специализированные операции. Например, класс BorderDecorator добавляет к своему внутреннему компоненту рамку. BorderDecorator – это подкласс Decorator, где операция Draw замещена так, что рисует рамку. В этом классе определена также закрытая вспомогательная операция DrawBorder, которая, собственно, и изображает рамку. Реализации всех остальных операций этот подкласс наследует от Decorator:
class BorderDecorator : public Decorator {
public:
BorderDecorator(VisualComponent*, int borderWidth);
virtual void Draw();
private:
void DrawBorder(int);
private:
int _width;
};
void BorderDecorator::Draw () {
Decorator::Draw();
DrawBorder(_width);
}
Подклассы ScrollDecorator и DropShadowDecorator, которые добавят визуальному компоненту возможность прокрутки и оттенения можно реализовать аналогично.
Теперь нам удастся скомпоновать экземпляры этих классов для получения различных оформлений. Ниже показано, как использовать декораторы для создания прокручиваемого компонента TextView с рамкой.
Во-первых, нужен какой-то способ поместить визуальный компонент в оконный объект. Предположим, что в нашем классе Window для этой цели имеется операция SetContents:
void Window::SetContents (VisualComponent* contents) {
// ...
}
Теперь можно создать поле для ввода текста и окно, в котором будет находиться это поле:
Window* window = new Window;
TextView* textView = new TextView;
TextView – подкласс VisualComponent, значит, мы могли бы поместить его в окно:
window->SetContents(textView);
Но нам нужно поле ввода с рамкой и возможностью прокрутки. Поэтому предварительно мы его надлежащим образом оформим:
window->SetContents(
new BorderDecorator(
new ScrollDecorator(textView), 1
)
);
Поскольку класс Window обращается к своему содержимому только через интерфейс VisualComponent, то ему неизвестно о присутствии декоратора. Клиент при желании может сохранить ссылку на само поле ввода, если ему нужно работать с ним непосредственно, например вызывать операции, не входящие в интерфейс VisualComponent. Клиенты, которым важна идентичность объекта, также должны обращаться к нему напрямую.
Во многих библиотеках для построения объектно-ориентированных интерфейсов пользователя декораторы применяются для добавления к виджетам графических оформлений. В качестве примеров можно назвать InterViews [LVC89, LCI+92], ET++ [WGM88] и библиотеку классов ObjectWorks\Smalltalk [Par90]. Другие варианты применения паттерна декоратор – это класс DebuggingGlyph из библиотеки InterViews и PassivityWrapper из ParcPlace Smalltalk. DebuggingGlyph печатает отладочную информацию до и после того, как переадресует запрос на размещение своему компоненту. Эта информация может быть полезна для анализа и отладки стратегии размещения объектов в сложном контейнере. Класс PassivityWrapper позволяет разрешить или запретить взаимодействие компонента с пользователем.
Но применение паттерна декоратор никоим образом не ограничивается графическими интерфейсами пользователя, как показывает следующий пример, основанный на потоковых классах из каркаса ET++ [WGM88].
Поток – это фундаментальная абстракция в большинстве средств ввода/вывода. Он может предоставлять интерфейс для преобразования объектов в последовательность байтов или символов. Это позволяет записать объект в файл или буфер в памяти и впоследствии извлечь его оттуда. Самый очевидный способ сделать это – определить абстрактный класс Stream с подклассами MemoryStream и FileStream. Предположим, однако, что нам хотелось бы еще уметь:
• компрессировать данные в потоке, применяя различные алгоритмы сжатия (кодирование повторяющихся серий, алгоритм Лемпеля-Зива и т.д.);
• преобразовывать данные в 7-битные символы кода ASCII для передачи по каналу связи.
Паттерн декоратор позволяет весьма элегантно добавить такие обязанности потокам. На диаграмме ниже показано одно из возможных решений задачи.
Абстрактный класс Stream имеет внутренний буфер и предоставляет операции для помещения данных в поток (PutInt, PutString). Как только буфер заполняется, Stream вызывает абстрактную операцию HandleBufferFull, которая выполняет реальное перемещение данных. В классе FileStream эта операция замещается так, что буфер записывается в файл.
Ключевым здесь является класс StreamDecorator. Именно в нем хранится ссылка на тот поток-компонент, которому переадресуются все запросы. Подклассы StreamDecorator замещают операцию HandleBufferFull и выполняют дополнительные действия, перед тем как вызвать реализацию этой операции в классе StreamDecorator.
Например, подкласс CompressingStream сжимает данные, а ASCII7Stream конвертирует их в 7-битный код ASCII. Теперь, для того чтобы создать объект FileStream, который одновременно сжимает данные и преобразует результат
в 7-битный код, достаточно просто декорировать FileStream с использованием CompressingStream и ASCII7Stream:
Stream* aStream = new CompressingStream(
new ASCII7Stream(
new FileStream("aFileName")
)
);
aStream->PutInt(12);
aStream->PutString("aString");
Адаптер: если декоратор изменяет только обязанности объекта, но не его интерфейс, то адаптер придает объекту совершенно новый интерфейс.
Компоновщик: декоратор можно считать вырожденным случаем составного объекта, у которого есть только один компонент. Однако декоратор добавляет новые обязанности, агрегирование объектов не является его целью.
Стратегия: декоратор позволяет изменить внешний облик объекта, стратегия – его внутреннее содержание. Это два взаимодополняющих способа изменения объекта.
Фасад – паттерн, структурирующий объекты.
Предоставляет унифицированный интерфейс вместо набора интерфейсов некоторой подсистемы. Фасад определяет интерфейс более высокого уровня, который упрощает использование подсистемы.
Разбиение на подсистемы облегчает проектирование сложной системы в целом. Общая цель всякого проектирования – свести к минимуму зависимость подсистем друг от друга и обмен информацией между ними. Один из способов решения этой задачи – введение объекта фасад, предоставляющий единый упрощенный интерфейс к более сложным системным средствам.
Рассмотрим, например, среду программирования, которая дает приложениям доступ к подсистеме компиляции. В этой подсистеме имеются такие классы, как Scanner (лексический анализатор), Parser (синтаксический анализатор), ProgramNode (узел программы), BytecodeStream (поток байтовых кодов)
и ProgramNodeBuilder (строитель узла программы). Все вместе они составляют компилятор. Некоторым специализированным приложениям, возможно, понадобится прямой доступ к этим классам. Но для большинства клиентов компилятора такие детали, как синтаксический разбор и генерация кода, обычно не нужны; им просто требуется откомпилировать некоторую программу. Для таких клиентов применение мощного, но низкоуровневого интерфейса подсистемы компиляции только усложняет задачу.
Чтобы предоставить интерфейс более высокого уровня, изолирующий клиента от этих классов, в подсистему компиляции включен также класс Compiler (компилятор). Он определяет унифицированный интерфейс ко всем возможностям компилятора. Класс Compiler выступает в роли фасада: предлагает простой интерфейс к более сложной подсистеме. Он «склеивает» классы, реализующие функциональность компилятора, но не скрывает их полностью. Благодаря фасаду компилятора работа большинства программистов облегчается. При этом те, кому нужен доступ к средствам низкого уровня, не лишаются его.
Используйте паттерн фасад, когда:
• хотите предоставить простой интерфейс к сложной подсистеме. Часто подсистемы усложняются по мере развития. Применение большинства паттернов приводит к появлению меньших классов, но в большем количестве. Такую подсистему проще повторно использовать и настраивать под конкретные нужды, но вместе с тем применять подсистему без настройки становится труднее. Фасад предлагает некоторый вид системы по умолчанию, устраивающий большинство клиентов. И лишь те объекты, которым нужны более широкие возможности настройки, могут обратиться напрямую к тому, что находится за фасадом;
• между клиентами и классами реализации абстракции существует много зависимостей. Фасад позволит отделить подсистему как от клиентов, так
и от других подсистем, что, в свою очередь, способствует повышению степени независимости и переносимости;
• вы хотите разложить подсистему на отдельные слои. Используйте фасад для определения точки входа на каждый уровень подсистемы. Если подсистемы зависят друг от друга, то зависимость можно упростить, разрешив подсистемам обмениваться информацией только через фасады.
• Facade (Compiler) – фасад:
– «знает», каким классам подсистемы адресовать запрос;
– делегирует запросы клиентов подходящим объектам внутри подсистемы;
• Классы подсистемы (Scanner, Parser, ProgramNode и т.д.):
– реализуют функциональность подсистемы;
– выполняют работу, порученную объектом Facade;
– ничего не «знают» о существовании фасада, то есть не хранят ссылок на него.
Клиенты общаются с подсистемой, посылая запросы фасаду. Он переадресует их подходящим объектам внутри подсистемы. Хотя основную работу выполняют именно объекты подсистемы, фасаду, возможно, придется преобразовать свой интерфейс в интерфейсы подсистемы.
Клиенты, пользующиеся фасадом, не имеют прямого доступа к объектам подсистемы.
У паттерна фасад есть следующие преимущества:
• изолирует клиентов от компонентов подсистемы, уменьшая тем самым число объектов, с которыми клиентам приходится иметь дело, и упрощая работу с подсистемой;
• позволяет ослабить связанность между подсистемой и ее клиентами. Зачастую компоненты подсистемы сильно связаны. Слабая связанность позволяет видоизменять компоненты, не затрагивая при этом клиентов. Фасады помогают разложить систему на слои и структурировать зависимости между объектами, а также избежать сложных и циклических зависимостей. Это может оказаться важным, если клиент и подсистема реализуются независимо.
Уменьшение числа зависимостей на стадии компиляции чрезвычайно важно в больших системах. Хочется, конечно, чтобы время, уходящее на перекомпиляцию после изменения классов подсистемы, было минимальным. Сокращение числа зависимостей за счет фасадов может уменьшить количество нуждающихся в повторной компиляции файлов после небольшой модификации какой-нибудь важной подсистемы. Фасад может также упростить процесс переноса системы на другие платформы, поскольку уменьшается вероятность того, что в результате изменения одной подсистемы понадобится изменять и все остальные;
• фасад не препятствует приложениям напрямую обращаться к классам подсистемы, если это необходимо. Таким образом, у вас есть выбор между простотой и общностью.
При реализации фасада следует обратить внимание на следующие вопросы:
• уменьшение степени связанности клиента с подсистемой. Степень связанности можно значительно уменьшить, если сделать класс Facade абстрактным. Его конкретные подклассы будут соответствовать различным реализациям подсистемы. Тогда клиенты смогут взаимодействовать с подсистемой через интерфейс абстрактного класса Facade. Это изолирует клиентов от информации о том, какая реализация подсистемы используется.
Вместо порождения подклассов можно сконфигурировать объект Facade различными объектами подсистем. Для настройки фасада достаточно заменить один или несколько таких объектов;
• открытые и закрытые классы подсистем. Подсистема похожа на класс в том отношении, что у обоих есть интерфейсы и оба что-то инкапсулируют. Класс инкапсулирует состояние и операции, а подсистема – классы. И если полезно различать открытый и закрытый интерфейсы класса, то не менее разумно говорить об открытом и закрытом интерфейсах подсистемы.
Открытый интерфейс подсистемы состоит из классов, к которым имеют доступ все клиенты; закрытый интерфейс доступен только для расширения подсистемы. Класс Facade, конечно же, является частью открытого интерфейса, но это не единственная часть. Другие классы подсистемы также могут быть открытыми. Например, в системе компиляции классы Parser
и Scanner – часть открытого интерфейса.
Делать классы подсистемы закрытыми иногда полезно, но это поддерживается немногими объектно-ориентированными языками. И в C++, и в Smalltalk для классов традиционно использовалось глобальное пространство имен. Однако комитет по стандартизации C++ добавил к языку пространства имен [Str94], и это позволило разрешать доступ только к открытым классам подсистемы.
Рассмотрим более подробно, как возвести фасад вокруг подсистемы компиляции.
В подсистеме компиляции определен класс BytecodeStream, который реализует поток объектов Bytecode. Объект Bytecode инкапсулирует байтовый код, с помощью которого описываются машинные команды. В этой же подсистеме определен еще класс Token для объектов, инкапсулирующих лексемы языка программирования.
Класс Scanner принимает на входе поток символов и генерирует поток лексем, по одной каждый раз:
class Scanner {
public:
Scanner(istream&);
virtual ~Scanner();
virtual Token& Scan();
private:
istream& _inputStream;
};
Класс Parser использует класс ProgramNodeBuilder для построения дерева разбора из лексем, возвращенных классом Scanner:
class Parser {
public:
Parser();
virtual ~Parser();
virtual void Parse(Scanner&, ProgramNodeBuilder&);
};
Parser вызывает ProgramNodeBuilder для инкрементного построения дерева. Взаимодействие этих классов описывается паттерном строитель:
class ProgramNodeBuilder {
public:
ProgramNodeBuilder();
virtual ProgramNode* NewVariable(
const char* variableName
) const;
virtual ProgramNode* NewAssignment(
ProgramNode* variable, ProgramNode* expression
) const;
virtual ProgramNode* NewReturnStatement(
ProgramNode* value
) const;
virtual ProgramNode* NewCondition(
ProgramNode* condition,
ProgramNode* truePart, ProgramNode* falsePart
) const;
// ...
ProgramNode* GetRootNode();
private:
ProgramNode* _node;
};
Дерево разбора состоит из экземпляров подклассов класса ProgramNode, таких как StatementNode, ExpressionNode и т.д. Иерархия классов ProgramNode – это пример паттерна компоновщик. Класс ProgramNode определяет интерфейс для манипулирования узлом программы и его потомками, если таковые имеются:
class ProgramNode {
public:
// манипулирование узлом программы
virtual void GetSourcePosition(int& line, int& index);
// ...
// манипулирование потомками
virtual void Add(ProgramNode*);
virtual void Remove(ProgramNode*);
// ...
virtual void Traverse(CodeGenerator&);
protected:
ProgramNode();
};
Операция Traverse (обход) принимает объект CodeGenerator (кодогенератор) в качестве параметра. Подклассы ProgramNode используют этот объект для генерации машинного кода в форме объектов Bytecode, которые помещаются в поток BytecodeStream. Класс CodeGenerator описывается паттерном посетитель:
class CodeGenerator {
public:
virtual void Visit(StatementNode*);
virtual void Visit(ExpressionNode*);
// ...
protected:
CodeGenerator(BytecodeStream&);
protected:
BytecodeStream& _output;
};
У CodeGenerator есть подклассы, например StackMachineCodeGenerator и RISCCodeGenerator, генерирующие машинный код для различных аппаратных архитектур.
Каждый подкласс ProgramNode реализует операцию Traverse и обращается к ней для обхода своих потомков. Каждый потомок рекурсивно делает то же самое для своих потомков. Например, в подклассе ExpressionNode (узел выражения) операция Traverse определена так:
void ExpressionNode::Traverse (CodeGenerator& cg) {
cg.Visit(this);
ListIterator<ProgramNode*> i(_children);
for (i.First(); !i.IsDone(); i.Next()) {
i.CurrentItem()->Traverse(cg);
}
}
Классы, о которых мы говорили до сих пор, составляют подсистему компиляции. А теперь введем класс Compiler, который будет служить фасадом, позволяющим собрать все эти фрагменты воедино. Класс Compiler предоставляет простой интерфейс для компилирования исходного текста и генерации кода для конкретной машины:
class Compiler {
public:
Compiler();
virtual void Compile(istream&, BytecodeStream&);
};
void Compiler::Compile (
istream& input, BytecodeStream& output
) {
Scanner scanner(input);
ProgramNodeBuilder builder;
Parser parser;
parser.Parse(scanner, builder);
RISCCodeGenerator generator(output);
ProgramNode* parseTree = builder.GetRootNode();
parseTree->Traverse(generator);
}
В этой реализации жестко «зашит» тип кодогенератора, поэтому программисту не нужно явно задавать целевую архитектуру. Это может быть вполне разумно, когда есть всего одна такая архитектура. Если же это не так, можно было бы изменить конструктор класса Compiler, чтобы он принимал объект CodeGenerator в качестве параметра. Тогда программист указывал бы, каким генератором пользоваться при инстанцировании объекта Compiler. Фасад компилятора можно параметризовать
и другими участниками, скажем, объектами Scanner и ProgramNodeBuilder, что повышает гибкость, но в то же время сводит на нет основную цель фасада – предоставление упрощенного интерфейса для наиболее распространенного случая.
Пример компилятора в разделе «Пример кода» навеян идеями из системы компиляции языка ObjectWorks\Smalltalk [Par90].
В каркасе ET++ [WGM88] приложение может иметь встроенные средства инспектирования объектов во время выполнения. Они реализуются в отдельной подсистеме, включающей класс фасада с именем ProgrammingEnvironment. Этот фасад определяет такие операции, как InspectObject и InspectClass для доступа к инспекторам.
Приложение, написанное в среде ET++, может также запретить поддержку инспектирования. В таком случае класс ProgrammingEnvironment реализует соответствующие запросы как пустые операции, не делающие ничего. Только подкласс ETProgrammingEnvironment реализует эти операции так, что они отображают окна соответствующих инспекторов. Приложению неизвестно, доступно инспектирование или нет. Здесь мы встречаем пример абстрактной связанности между приложением и подсистемой инспектирования.
В операционной системе Choices [CIRM93] фасады используются для составления одного каркаса из нескольких. Ключевыми абстракциями в системе Choices являются процессы, память и адресные пространства. Для каждой из них есть соответствующая подсистема, реализованная в виде каркаса. Это обеспечивает поддержку переноса Choices на разные аппаратные платформы. У двух таких подсистем есть «представители», то есть фасады. Они называются FileSystemInterface (память) и Domain (адресные пространства).
Например, для каркаса виртуальной памяти фасадом служит Domain. Класс Domain представляет адресное пространство. Он обеспечивает отображение между виртуальными адресами и смещениями объектов в памяти, файле или на устройстве длительного хранения. Базовые операции класса Domain поддерживают добавление объекта в память по указанному адресу, удаление объекта из памяти
и обработку ошибок отсутствия страниц.
Как видно из вышеприведенной диаграммы, внутри подсистемы виртуальной памяти используются следующие компоненты:
• MemoryObject представляет объекты данных;
• MemoryObjectCache кэширует данные из объектов MemoryObjects в физической памяти. MemoryObjectCache – это не что иное, как объект Стратегия, в котором локализована политика кэширования;
• AddressTranslation инкапсулирует особенности оборудования трансляции адресов.
Операция RepairFault вызывается при возникновении ошибки из-за отсутствия страницы. Domain находит объект в памяти по адресу, где произошла ошибка и делегирует операцию RepairFault кэшу, ассоциированному с этим объектом. Поведение объектов Domain можно настроить, заменив их компоненты.
Паттерн абстрактная фабрика допустимо использовать вместе с фасадом, чтобы предоставить интерфейс для создания объектов подсистем способом, не зависимым от этих подсистем. Абстрактная фабрика может выступать и как альтернатива фасаду, чтобы скрыть платформенно-зависимые классы.
Паттерн посредник аналогичен фасаду в том смысле, что абстрагирует функциональность существующих классов. Однако назначение посредника – абстрагировать произвольное взаимодействие между «сотрудничающими» объектами. Часто он централизует функциональность, не присущую ни одному из них. Коллеги посредника обмениваются информацией именно с ним, а не напрямую между собой. Напротив, фасад просто абстрагирует интерфейс объектов подсистемы, чтобы ими было проще пользоваться. Он не определяет новой функциональности, и классам подсистемы ничего неизвестно о его существовании.
Обычно требуется только один фасад. Поэтому объекты фасадов часто бывают одиночками.
Приспособленец – паттерн, структурирующий объекты.
Использует разделение для эффективной поддержки множества мелких объектов.
В некоторых приложениях использование объектов могло бы быть очень полезным, но прямолинейная реализация оказывается недопустимо расточительной.
Например, в большинстве редакторов документов имеются средства форматирования и редактирования текстов, в той или иной степени модульные. Объектно-ориентированные редакторы обычно применяют объекты для представления таких встроенных элементов, как таблицы и рисунки. Но они не используют объекты для представления каждого символа, несмотря на то что это увеличило бы гибкость на самых нижних уровнях приложения. Ведь тогда к рисованию и форматированию символов и встроенных элементов можно было бы применить единообразный подход. И для поддержки новых наборов символов не пришлось бы как-либо затрагивать остальные функции редактора. Да и общая структура приложения отражала бы физическую структуру документа. На следующей диаграмме показано, как редактор документов мог бы воспользоваться объектами для представления символов.
У такого дизайна есть один недостаток – стоимость. Даже в документе скромных размеров было бы несколько сотен тысяч объектов-символов, а это привело бы к расходованию огромного объема памяти и неприемлемым затратам во время выполнения. Паттерн приспособленец показывает, как разделять очень мелкие объекты без недопустимо высоких издержек.
Приспособленец – это разделяемый объект, который можно использовать одновременно в нескольких контекстах. В каждом контексте он выглядит как независимый объект, то есть неотличим от экземпляра, который не разделяется. Приспособленцы не могут делать предположений о контексте, в котором работают. Ключевая идея здесь – различие между внутренним и внешним состояниями. Внутреннее состояние хранится в самом приспособленце и состоит из информации, не зависящей от его контекста. Именно поэтому он может разделяться. Внешнее состояние зависит от контекста и изменяется вместе с ним, поэтому не подлежит разделению. Объекты-клиенты отвечают за передачу внешнего состояния приспособленцу, когда в этом возникает необходимость.
Приспособленцы моделируют концепции или сущности, число которых слишком велико для представления объектами. Например, редактор документов мог бы создать по одному приспособленцу для каждой буквы алфавита. Каждый приспособленец хранит код символа, но координаты положения символа в документе и стиль его начертания определяются алгоритмами размещения текста
и командами форматирования, действующими в том месте, где символ появляется. Код символа – это внутреннее состояние, а все остальное – внешнее.
Логически для каждого вхождения данного символа в документ существует объект.
Физически, однако, есть лишь по одному объекту-приспособленцу для каждого символа, который появляется в различных контекстах в структуре документа. Каждое вхождение данного объекта-символа ссылается на один и тот же экземпляр в разделяемом пуле объектов-приспособленцев.
Ниже изображена структура класса для этих объектов. Glyph – это абстрактный класс для представления графических объектов (некоторые из них могут быть приспособленцами). Операции, которые могут зависеть от внешнего состояния, передают его в качестве параметра. Например, операциям Draw (рисование) и Intersects (пересечение) должно быть известно, в каком контексте встречается глиф, иначе они не смогут выполнить то, что от них требуется.
Приспособленец, представляющий букву «a», содержит только соответствующий ей код; ни положение, ни шрифт буквы ему хранить не надо. Клиенты передают приспособленцу всю зависящую от контекста информацию, которая нужна, чтобы он мог изобразить себя. Например, глифу Row известно, где его потомки должны себя показать, чтобы это выглядело как горизонтальная строка. Поэтому вместе с запросом на рисование он может передавать каждому потомку координаты.
Поскольку число различных объектов-символов гораздо меньше, чем число символов в документе, то и общее количество объектов существенно меньше, чем было бы при простой реализации. Документ, в котором все символы изображаются одним шрифтом и цветом, создаст порядка 100 объектов-символов (это примерно равно числу кодов в таблице ASCII) независимо от своего размера. А поскольку в большинстве документов применяется не более десятка различных комбинаций шрифта и цвета, то на практике эта величина возрастет несущественно. Поэтому абстракция объекта становится применимой и к отдельным символам.
Эффективность паттерна приспособленец во многом зависит от того, как
и где он используется. Применяйте этот паттерн, когда выполнены все нижеперечисленные условия:
• в приложении используется большое число объектов;
• из-за этого накладные расходы на хранение высоки;
• большую часть состояния объектов можно вынести вовне;
• многие группы объектов можно заменить относительно небольшим количеством разделяемых объектов, поскольку внешнее состояние вынесено;
• приложение не зависит от идентичности объекта. Поскольку объекты-приспособленцы могут разделяться, то проверка на идентичность возвратит «истину» для концептуально различных объектов.
На следующей диаграмме показано, как приспособленцы разделяются.
• Flyweight (Glyph) – приспособленец:
– объявляет интерфейс, с помощью которого приспособленцы могут получать внешнее состояние или как-то воздействовать на него;
• ConcreteFlyweight (Character) – конкретный приспособленец:
– реализует интерфейс класса Flyweight и добавляет при необходимости внутреннее состояние. Объект класса ConcreteFlyweight должен быть разделяемым. Любое сохраняемое им состояние должно быть внутренним, то есть не зависящим от контекста;
• UnsharedConcreteFlyweight (Row, Column) – неразделяемый конкретный приспособленец:
– не все подклассы Flyweight обязательно должны быть разделяемыми. Интерфейс Flyweight допускает разделение, но не навязывает его. Часто
у объектов UnsharedConcreteFlyweight на некотором уровне структуры приспособленца есть потомки в виде объектов класса ConcreteFlyweight, как, например, у объектов классов Row и Column;
• FlyweightFactory – фабрика приспособленцев:
– создает объекты-приспособленцы и управляет ими;
– обеспечивает должное разделение приспособленцев. Когда клиент запрашивает приспособленца, объект FlyweightFactory предоставляет существующий экземпляр или создает новый, если готового еще нет;
• Client – клиент:
– хранит ссылки на одного или нескольких приспособленцев;
– вычисляет или хранит внешнее состояние приспособленцев.
• состояние, необходимое приспособленцу для нормальной работы, можно охарактеризовать как внутреннее или внешнее. Первое хранится в самом объекте ConcreteFlyweight. Внешнее состояние хранится или вычисляется клиентами. Клиент передает его приспособленцу при вызове операций;
• клиенты не должны создавать экземпляры класса ConcreteFlyweight напрямую, а могут получать их только от объекта FlyweightFactory. Это позволит гарантировать корректное разделение.
При использовании приспособленцев не исключены затраты на передачу, поиск или вычисление внутреннего состояния, особенно если раньше оно хранилось как внутреннее. Однако такие расходы с лихвой компенсируются экономией памяти за счет разделения объектов-приспособленцев.
Экономия памяти возникает по ряду причин:
• уменьшение общего числа экземпляров;
• сокращение объема памяти, необходимого для хранения внутреннего состояния;
• вычисление, а не хранение внешнего состояния (если это действительно так).
Чем выше степень разделения приспособленцев, тем существеннее экономия. С увеличением объема разделяемого состояния экономия также возрастает. Самого большого эффекта удается добиться, когда суммарный объем внутренней
и внешней информации о состоянии велик, а внешнее состояние вычисляется, а не хранится. Тогда разделение уменьшает стоимость хранения внутреннего состояния, а за счет вычислений сокращается память, отводимая под внешнее состояние.
Паттерн приспособленец часто применяется вместе с компоновщиком для представления иерархической структуры в виде графа с разделяемыми листовыми узлами. Из-за разделения указатель на родителя не может храниться в листовом узле-приспособленце, а должен передаваться ему как часть внешнего состояния. Это оказывает заметное влияние на способ взаимодействия объектов иерархии между собой.
При реализации приспособленца следует обратить внимание на следующие вопросы:
• вынесение внешнего состояния. Применимость паттерна в значительной степени зависит от того, насколько легко идентифицировать внешнее состояние и вынести его за пределы разделяемых объектов. Вынесение внешнего состояния не уменьшает стоимости хранения, если различных внешних состояний так же много, как и объектов до разделения. Лучший вариант – внешнее состояние вычисляется по объектам с другой структурой, требующей значительно меньшей памяти.
Например, в нашем редакторе документов мы можем поместить карту с типографской информацией в отдельную структуру, а не хранить шрифт и начертание вместе с каждым символом. Данная карта будет отслеживать непрерывные серии символов с одинаковыми типографскими атрибутами. Когда объект-символ изображает себя, он получает типографские атрибуты от алгоритма обхода. Поскольку обычно в документах используется немного разных шрифтов и начертаний, то хранить эту информацию отдельно от объекта-символа гораздо эффективнее, чем непосредственно в нем;
• управление разделяемыми объектами. Так как объекты разделяются, клиенты не должны инстанцировать их напрямую. Фабрика FlyweightFactory позволяет клиентам найти подходящего приспособленца. В объектах этого класса часто есть хранилище, организованное в виде ассоциативного массива, с помощью которого можно быстро находить приспособленца, нужного клиенту. Так, в примере редактора документов фабрика приспособленцев может содержать внутри себя таблицу, индексированную кодом символа,
и возвращать нужного приспособленца по его коду. А если требуемый приспособленец отсутствует, он тут же создается.
Разделяемость подразумевает также, что имеется некоторая форма подсчета ссылок или сбора мусора для освобождения занимаемой приспособленцем памяти, когда необходимость в нем отпадает. Однако ни то, ни другое необязательно, если число приспособленцев фиксировано и невелико (например, если речь идет о представлении набора символов кода ASCII).
В таком случае имеет смысл хранить приспособленцев постоянно.
Возвращаясь к примеру с редактором документов, определим базовый класс Glyph для графических объектов-приспособленцев. Логически глифы – это составные объекты, которые обладают графическими атрибутами и умеют изображать себя (см. описание паттерна компоновщик). Сейчас мы ограничимся только шрифтом, но тот же подход применим и к любым другим графическим атрибутам:
class Glyph {
public:
virtual ~Glyph();
virtual void Draw(Window*, GlyphContext&);
virtual void SetFont(Font*, GlyphContext&);
virtual Font* GetFont(GlyphContext&);
virtual void First(GlyphContext&);
virtual void Next(GlyphContext&);
virtual bool IsDone(GlyphContext&);
virtual Glyph* Current(GlyphContext&);
virtual void Insert(Glyph*, GlyphContext&);
virtual void Remove(GlyphContext&);
protected:
Glyph();
};
В подклассе Character хранится просто код символа:
class Character : public Glyph {
public:
Character(char);
virtual void Draw(Window*, GlyphContext&);
private:
char _charcode;
};
Чтобы не выделять память для шрифта каждого глифа, будем хранить этот атрибут во внешнем объекте класса GlyphContext. Данный объект поддерживает соответствие между глифом и его шрифтом (а также любыми другими графическими атрибутами) в различных контекстах. Любой операции, у которой должна быть информация о шрифте глифа в данном контексте, в качестве параметра будет передаваться экземпляр GlyphContext. У него операция и может запросить нужные сведения. Контекст определяется положением глифа в структуре. Поэтому операциями обхода и манипулирования потомками обновляется GlyphContext:
class GlyphContext {
public:
GlyphContext();
virtual ~GlyphContext();
virtual void Next(int step = 1);
virtual void Insert(int quantity = 1);
virtual Font* GetFont();
virtual void SetFont(Font*, int span = 1);
private:
int _index;
BTree* _fonts;
};
Объекту GlyphContext должно быть известно о текущем положении в структуре глифов во время ее обхода. Операция GlyphContext::Next увеличивает переменную _index по мере обхода структуры. Подклассы класса Glyph, имеющие потомков (например, Row и Column), должны реализовывать операцию Next так, чтобы она вызывала GlyphContext::Next в каждой точке обхода.
Операция GlyphContext::GetFont использует переменную _index в качестве ключа для структуры BTree, в которой хранится отображение между глифами и шрифтами. Каждый узел дерева помечен длиной строки, для которой он предоставляет информацию о шрифте. Листья дерева указывают на шрифт, а внутренние узлы разбивают строку на подстроки – по одной для каждого потомка.
Рассмотрим фрагмент текста, представляющий собой композицию глифов.
Структура BTree, в которой хранится информация о шрифтах, может выглядеть так:
Внутренние узлы определяют диапазоны индексов глифов. Дерево обновляется в ответ на изменение шрифта, а также при каждом добавлении и удалении глифов из структуры. Например, если предположить, что текущей точке обхода соответствует индекс 102, то следующий код установит шрифт каждого символа в слове «expect» таким же, как у близлежащего текста (то есть times12 – экземпляр класса Font для шрифта Times Roman размером 12 пунктов):
GlyphContext gc;
Font* times12 = new Font("Times-Roman-12");
Font* timesItalic12 = new Font("Times-Italic-12");
// ...
gc.SetFont(times12, 6);
Новая структура BTree выглядит так (изменения выделены более ярким цветом):
Добавим перед «expect» слово «don’t » (включая пробел после него), написанное шрифтом Times Italic размером 12 пунктов. В предположении, что текущей позиции все еще соответствует индекс 102, следующий код проинформирует объект gc об этом:
gc.Insert(6);
gc.SetFont(timesItalic12, 6);
Теперь структура BTree выглядит так:
При запрашивании шрифта текущего глифа объект GlyphContext спускается вниз по дереву, суммируя индексы, пока не будет найден шрифт для текущего индекса. Поскольку шрифт меняется нечасто, размер дерева мал по сравнению
с размером структуры глифов. Это позволяет уменьшить расходы на хранение без заметного увеличения времени поиска.1
И наконец, нам нужна еще фабрика FlyweightFactory, которая создает глифы и обеспечивает их корректное разделение. Класс GlyphFactory создает объекты Character и глифы других видов. Разделению подлежат только объекты Character. Составных глифов гораздо больше, и их существенное состояние (то есть множество потомков) в любом случае является внутренним:
const int NCHARCODES = 128;
class GlyphFactory {
public:
GlyphFactory();
virtual ~GlyphFactory();
virtual Character* CreateCharacter(char);
virtual Row* CreateRow();
virtual Column* CreateColumn();
// ...
private:
Character* _character[NCHARCODES];
};
Массив _character содержит указатели на глифы Character, индексированные кодом символа. Конструктор инициализирует этот массив нулями:
GlyphFactory::GlyphFactory () {
for (int i = 0; i < NCHARCODES; ++i) {
_character[i] = 0;
}
}
Операция CreateCharacter ищет символ в массиве и возвращает соответствующий глиф, если он существует. В противном случае CreateCharacter со-здает глиф, помещает его в массив и затем возвращает:
Character* GlyphFactory::CreateCharacter (char c) {
if (!_character[c]) {
_character[c] = new Character(c);
}
return _character[c];
}
Остальные операции просто создают новый объект при каждом обращении, так как несимвольные глифы не разделяются:
Row* GlyphFactory::CreateRow () {
return new Row;
}
Column* GlyphFactory::CreateColumn () {
return new Column;
}
Эти операции можно было бы опустить и позволить клиентам инстанцировать неразделяемые глифы напрямую. Но если позже мы решим сделать разделяемыми и их тоже, то придется изменять клиентский код, в котором они создаются.
Концепция объектов-приспособленцев впервые была описана и использована как техника проектирования в библиотеке InterViews 3.0 [CL90]. Ее разработчики построили мощный редактор документов Doc, чтобы доказать практическую полезность подобной идеи. В Doc объекты-глифы используются для представления любого символа документа. Редактор строит по одному экземпляру глифа для каждого сочетания символа и стиля (в котором определены все графические атрибуты). Таким образом, внутреннее состояние символа состоит из его кода и информации о стиле (индекс в таблицу стилей).1 Следовательно, внешней оказывается только позиция, поэтому Doc работает быстро. Документы представляются классом Document, который выполняет функции фабрики FlyweightFactory. Измерения показали, что реализованное в Doc разделение символов-приспособленцев весьма эффективно. В типичном случае для документа из 180 тысяч знаков необходимо создать только 480 объектов-символов.
В каркасе ET++ [WGM88] приспособленцы используются для поддержки независимости от внешнего облика.2 Его стандарт определяет расположение элементов пользовательского интерфейса (полос прокрутки, кнопок, меню и пр., в совокупности именуемых виджетами) и их оформления (тени и т.д.). Виджет делегирует заботу о своем расположении и изображении отдельному объекту Layout. Изменение этого объекта ведет к изменению внешнего облика даже во время выполнения.
Для каждого класса виджета имеется соответствующий класс Layout (например, ScrollbarLayout, MenubarLayout и т.д.). В данном случае очевидная проблема состоит в том, что удваивается число объектов пользовательского интерфейса, ибо для каждого интерфейсного объекта есть дополнительный объект Layout. Чтобы избавиться от расходов, объекты Layout реализованы в виде приспособленцев. Они прекрасно подходят на эту роль, так как заняты преимущественно определением поведения и им легко передать тот небольшой объем внешней информации о состоянии, который необходим для изображения объекта.
Объекты Layout создаются и управляются объектами класса Look. Класс Look – это абстрактная фабрика, которая производит объекты Layout с помощью таких операций, как GetButtonLayout, GetMenuBarLayout и т.д. Для каждого стандарта внешнего облика у класса Look есть соответствующий подкласс (MotifLook, OpenLook и т.д.).
Кстати говоря, объекты Layout – это, по существу, стратегии (см. описание паттерна стратегия). Таким образом, мы имеем пример объекта-стратегии, реализованный в виде приспособленца.
Паттерн приспособленец часто используется в сочетании с компоновщиком для реализации иерархической структуры в виде ациклического направленного графа с разделяемыми листовыми вершинами.
Часто наилучшим способом реализации объектов состояния и стратегии является паттерн приспособленец.
Заместитель – паттерн, структурирующий объекты.
Является суррогатом другого объекта и контролирует доступ к нему.
Surrogate (суррогат).
Разумно управлять доступом к объекту, поскольку тогда можно отложить расходы на создание и инициализацию до момента, когда объект действительно понадобится. Рассмотрим редактор документов, который допускает встраивание в документ графических объектов. Затраты на создание некоторых таких объектов, например больших растровых изображений, могут быть весьма значительны. Но документ должен открываться быстро, поэтому следует избегать создания всех «тяжелых» объектов на стадии открытия (да и вообще это излишне, поскольку не все они будут видны одновременно).
В связи с такими ограничениями кажется разумным создавать «тяжелые» объекты по требованию. Это означает «когда изображение становится видимым». Но что поместить в документ вместо изображения? И как, не усложняя реализации редактора, скрыть то, что изображение создается по требованию? Например, оптимизация не должна отражаться на коде, отвечающем за рисование и форматирование.
Решение состоит в том, чтобы использовать другой объект – заместитель изображения, который временно подставляется вместо реального изображения. Заместитель ведет себя точно так же, как само изображение, и выполняет при необходимости его инстанцирование.
Заместитель создает настоящее изображение, только если редактор документа вызовет операцию Draw. Все последующие запросы заместитель переадресует непосредственно изображению. Поэтому после создания изображения он должен сохранить ссылку на него.
Предположим, что изображения хранятся в отдельных файлах. В таком случае мы можем использовать имя файла как ссылку на реальный объект. Заместитель хранит также размер изображения, то есть длину и ширину. «Зная» ее, заместитель может отвечать на запросы форматера о своем размере, не инстанцируя изображение.
На следующей диаграмме классов этот пример показан более подробно.
Редактор документов получает доступ к встроенным изображениям только через интерфейс, определенный в абстрактном классе Graphic. ImageProxy – это класс для представления изображений, создаваемых по требованию. В ImageProxy хранится имя файла, играющее роль ссылки на изображение, которое находится на диске. Имя файла передается конструктору класса ImageProxy.
В объекте ImageProxy находятся также ограничивающий прямоугольник изображения и ссылка на экземпляр реального объекта Image. Ссылка остается недействительной, пока заместитель не инстанцирует реальное изображение. Операцией Draw гарантируется, что изображение будет создано до того, как заместитель переадресует ему запрос. Операция GetExtent переадресует запрос изображению, только если оно уже инстанцировано; в противном случае ImageProxy возвращает размеры, которые хранит сам.
Паттерн заместитель применим во всех случаях, когда возникает необходимость сослаться на объект более изощренно, чем это возможно, если использовать простой указатель. Вот несколько типичных ситуаций, где заместитель оказывается полезным:
• удаленный заместитель предоставляет локального представителя вместо объекта, находящегося в другом адресном пространстве. В системе NEXTSTEP [Add94] для этой цели применяется класс NXProxy. Заместителя такого рода Джеймс Коплиен [Cop92] называет «послом»;
• виртуальный заместитель создает «тяжелые» объекты по требованию. Примером может служить класс ImageProxy, описанный в разделе «Мотивация»;
• защищающий заместитель контролирует доступ к исходному объекту. Такие заместители полезны, когда для разных объектов определены различные права доступа. Например, в операционной системе Choices [CIRM93] объекты KernelProxy ограничивают права доступа к объектам операционной системы;
• «умная» ссылка – это замена обычного указателя. Она позволяет выполнить дополнительные действия при доступе к объекту. К типичным применениям такой ссылки можно отнести:
– подсчет числа ссылок на реальный объект, с тем чтобы занимаемую им память можно было освободить автоматически, когда не останется ни одной ссылки (такие ссылки называют еще «умными» указателями [Ede92]);
– загрузку объекта в память при первом обращении к нему;
– проверку и установку блокировки на реальный объект при обращении
к нему, чтобы никакой другой объект не смог в это время изменить его.
Вот как может выглядеть диаграмма объектов для структуры с заместителем во время выполнения.
• Proxy (ImageProxy) – заместитель:
– хранит ссылку, которая позволяет заместителю обратиться к реальному субъекту. Объект класса Proxy может обращаться к объекту класса Subject, если интерфейсы классов RealSubject и Subject одинаковы;
– предоставляет интерфейс, идентичный интерфейсу Subject, так что заместитель всегда может быть подставлен вместо реального субъекта;
– контролирует доступ к реальному субъекту и может отвечать за его создание и удаление;
– прочие обязанности зависят от вида заместителя:
– удаленный заместитель отвечает за кодирование запроса и его аргументов и отправление закодированного запроса реальному субъекту в другом адресном пространстве;
– виртуальный заместитель может кэшировать дополнительную информацию о реальном субъекте, чтобы отложить его создание. Например, класс ImageProxy из раздела «Мотивация» кэширует размеры реального изображения;
– защищающий заместитель проверяет, имеет ли вызывающий объект необходимые для выполнения запроса права;
• Subject (Graphic) – субъект:
– определяет общий для RealSubject и Proxy интерфейс, так что класс Proxy можно использовать везде, где ожидается RealSubject;
• RealSubject (Image) – реальный субъект:
– определяет реальный объект, представленный заместителем.
Proxy при необходимости переадресует запросы объекту RealSubject. Детали зависят от вида заместителя.
С помощью паттерна заместитель при доступе к объекту вводится дополнительный уровень косвенности. У этого подхода есть много вариантов в зависимости от вида заместителя:
• удаленный заместитель может скрыть тот факт, что объект находится в другом адресном пространстве;
• виртуальный заместитель может выполнять оптимизацию, например создание объекта по требованию;
• защищающий заместитель и «умная» ссылка позволяют решать дополнительные задачи при доступе к объекту.
Есть еще одна оптимизация, которую паттерн заместитель иногда скрывает от клиента. Она называется копированием при записи (copy-on-write) и имеет много общего с созданием объекта по требованию. Копирование большого и сложного объекта – очень дорогая операция. Если копия не модифицировалась, то нет смысла эту цену платить. Если отложить процесс копирования, применив заместитель, то можно быть уверенным, что эта операция произойдет только тогда, когда он действительно был изменен.
Чтобы во время записи можно было копировать, необходимо подсчитывать ссылки на субъект. Копирование заместителя просто увеличивает счетчик ссылок. И только тогда, когда клиент запрашивает операцию, изменяющую субъект, заместитель действительно выполняет копирование. Одновременно заместитель должен уменьшить счетчик ссылок. Когда счетчик ссылок становится равным нулю, субъект уничтожается.
Копирование при записи может существенно уменьшить плату за копирование «тяжелых» субъектов.
При реализации паттерна заместитель можно использовать следующие возможности языка:
• перегрузку оператора доступа к членам в C++. Язык C++ поддерживает перегрузку оператора доступа к членам класса ->. Это позволяет производить дополнительные действия при любом разыменовании указателя на объект. Для реализации некоторых видов заместителей это оказывается полезно, поскольку заместитель ведет себя аналогично указателю.
В следующем примере показано, как воспользоваться данным приемом для реализации виртуального заместителя ImagePtr:
class Image;
extern Image* LoadAnImageFile(const char*);
// внешняя функция
class ImagePtr {
public:
ImagePtr(const char* imageFile);
virtual ~ImagePtr();
virtual Image* operator->();
virtual Image& operator*();
private:
Image* LoadImage();
private:
Image* _image;
const char* _imageFile;
};
ImagePtr::ImagePtr (const char* theImageFile) {
_imageFile = theImageFile;
_image = 0;
}
Image* ImagePtr::LoadImage () {
if (_image == 0) {
_image = LoadAnImageFile(_imageFile);
}
return _image;
}
Перегруженные операторы -> и * используют операцию LoadImage для возврата клиенту изображения, хранящегося в переменной _image (при необходимости загрузив его):
Image* ImagePtr::operator-> () {
return LoadImage();
}
Image& ImagePtr::operator* () {
return *LoadImage();
}
Такой подход позволяет вызывать операции объекта Image через объекты ImagePtr, не заботясь о том, что они не являются частью интерфейса данного класса:
ImagePtr image = ImagePtr("anImageFileName");
image->Draw(Point(50, 100));
// (image.operator->())->Draw(Point(50, 100))
Обратите внимание, что заместитель изображения ведет себя подобно указателю, но не объявлен как указатель на Image. Это означает, что использовать его в точности как настоящий указатель на Image нельзя. Поэтому при таком подходе клиентам следует трактовать объекты Image и ImagePtr по-разному.
Перегрузка оператора доступа – лучшее решение далеко не для всех видов заместителей. Некоторым из них должно быть точно известно, какая операция вызывается, а в таких случаях перегрузка оператора доступа не работает.
Рассмотрим пример виртуального заместителя, обсуждавшийся в разделе «Мотивация». Изображение нужно загружать в точно определенное время – при вызове операции Draw, а не при каждом обращении к нему. Перегрузка оператора доступа не позволяет различить подобные случаи. В такой ситуации придется вручную реализовать каждую операцию заместителя, переадресующую запрос субъекту.
Обычно все эти операции очень похожи друг на друга, как видно из примера кода в одноименном разделе. Они проверяют, что запрос корректен, что объект-адресат существует и т.д., а потом уже перенаправляют ему запрос. Писать этот код снова и снова надоедает. Поэтому нередко для его автоматической генерации используют препроцессор;
• метод doesNotUnderstand в Smalltalk. В языке Smalltalk есть возможность, позволяющая автоматически поддержать переадресацию запросов. При отправлении клиентом сообщения, для которого у получателя нет соответствующего метода, Smalltalk вызывает метод doesNotUnderstand: aMessage. Заместитель может переопределить doesNotUnderstand так, что сообщение будет переадресовано субъекту.
Дабы гарантировать, что запрос будет перенаправлен субъекту, а не просто тихо поглощен заместителем, класс Proxy можно определить так, что он не станет понимать никаких сообщений. Smalltalk позволяет это сделать, надо лишь, чтобы у Proxy не было суперкласса1.
Главный недостаток метода doesNotUnderstand: в том, что в большинстве Smalltalk-систем имеется несколько специальных сообщений, обрабатываемых непосредственно виртуальной машиной, а в этом случае стандартный механизм поиска методов обходится. Правда, единственной такой операцией, написанной в классе Object (следовательно, могущей затронуть заместителей), является тождество ==.
Если вы собираетесь применять doesNotUnderstand: для реализация заместителя, то должны как-то решить вышеописанную проблему. Нельзя же ожидать, что совпадение заместителей – это то же самое, что и совпадение реальных субъектов. К сожалению, doesNotUnderstand: изначально создавался для обработки ошибок, а не для построения заместителей, поэтому его быстродействие оставляет желать лучшего;
• заместителю не всегда должен быть известен тип реального объекта. Если класс Proxy может работать с субъектом только через его абстрактный интерфейс, то не нужно создавать Proxy для каждого класса реального субъекта RealSubject; заместитель может обращаться к любому из них единообразно. Но если заместитель должен инстанцировать реальных субъектов (как обстоит дело в случае виртуальных заместителей), то знание конкретного класса обязательно.
К проблемам реализации можно отнести и решение вопроса о том, как обращаться к еще не инстанцированному субъекту. Некоторые заместители должны обращаться к своим субъектам вне зависимости от того, где они находятся – диске или в памяти. Это означает, что нужно использовать какую-то форму не зависящих от адресного пространства идентификаторов объектов. В разделе «Мотивация» для этой цели использовалось имя файла.
В коде реализовано два вида заместителей: виртуальный, описанный в разделе «Мотивация», и реализованный с помощью метода doesNotUnderstand:.2
• виртуальный заместитель. В классе Graphic определен интерфейс для графических объектов:
class Graphic {
public:
virtual ~Graphic();
virtual void Draw(const Point& at) = 0;
virtual void HandleMouse(Event& event) = 0;
virtual const Point& GetExtent() = 0;
virtual void Load(istream& from) = 0;
virtual void Save(ostream& to) = 0;
protected:
Graphic();
};
Класс Image реализует интерфейс Graphic для отображения файлов изображений. В нем замещена операция HandleMouse, посредством которой пользователь может интерактивно изменять размер изображения:
class Image : public Graphic {
public:
Image(const char* file); // загрузка изображения из файла
virtual ~Image();
virtual void Draw(const Point& at);
virtual void HandleMouse(Event& event);
virtual const Point& GetExtent();
virtual void Load(istream& from);
virtual void Save(ostream& to);
private:
// ...
};
Класс ImageProxy имеет тот же интерфейс, что и Image:
class ImageProxy : public Graphic {
public:
ImageProxy(const char* imageFile);
virtual ~ImageProxy();
virtual void Draw(const Point& at);
virtual void HandleMouse(Event& event);
virtual const Point& GetExtent();
virtual void Load(istream& from);
virtual void Save(ostream& to);
protected:
Image* GetImage();
private:
Image* _image;
Point _extent;
char* _fileName;
};
Конструктор сохраняет локальную копию имени файла, в котором хранится изображение, и инициализирует члены _extent и _image:
ImageProxy::ImageProxy (const char* fileName) {
_fileName = strdup(fileName);
_extent = Point::Zero; // размеры пока не известны
_image = 0;
}
Image* ImageProxy::GetImage() {
if (_image == 0) {
_image = new Image(_fileName);
}
return _image;
}
Реализация операции GetExtent возвращает кэшированный размер, если это возможно. В противном случае изображение загружается из файла. Операция Draw загружает изображение, а HandleMouse перенаправляет событие реальному изображению:
const Point& ImageProxy::GetExtent () {
if (_extent == Point::Zero) {
_extent = GetImage()->GetExtent();
}
return _extent;
}
void ImageProxy::Draw (const Point& at) {
GetImage()->Draw(at);
}
void ImageProxy::HandleMouse (Event& event) {
GetImage()->HandleMouse(event);
}
Операция Save записывает кэшированный размер изображения и имя файла в поток, а Load считывает эту информацию и инициализирует соответствующие члены:
void ImageProxy::Save (ostream& to) {
to << _extent << _fileName;
}
void ImageProxy::Load (istream& from) {
from >> _extent >> _fileName;
}
Наконец, предположим, что есть класс TextDocument для представления документа, который может содержать объекты класса Graphic:
class TextDocument {
public:
TextDocument();
void Insert(Graphic*);
// ...
};
Мы можем вставить объект ImageProxy в документ следующим образом:
TextDocument* text = new TextDocument;
// ...
text->Insert(new ImageProxy("anImageFileName"));
• заместители, использующие метод doesNotUnderstand. В языке Smalltalk можно создавать обобщенных заместителей, определяя классы, для которых нет суперкласса 1, а в них – метод doesNotUnderstand: для обработки сообщений.
В показанном ниже фрагменте предполагается, что у заместителя есть метод realSubject, возвращающий связанный с ним реальный субъект. При использовании ImageProxy этот метод должен был бы проверить, создан ли объект Image, при необходимости создать его и затем вернуть. Для обработки перехваченного сообщения, которое было адресовано реальному субъекту, используется метод perform:withArguments:.
doesNotUnderstand: aMessage
^ self realSubject
perform: aMessage selector
withArguments: aMessage arguments
Аргументом doesNotUnderstand: является экземпляр класса Message, представляющий сообщение, не понятое заместителем. Таким образом, при ответе на любое сообщение заместитель сначала проверяет, что реальный субъект существует, а потом уже переадресует ему сообщение.
Одно из преимуществ метода doesNotUnderstand: – он способен выполнить произвольную обработку. Например, можно было бы создать защищающего заместителя, определив набор legalMessages-сообщений, которые следует принимать, и снабдив заместителя следующим методом:
doesNotUnderstand: aMessage
^ (legalMessages includes: aMessage selector)
ifTrue: [self realSubject
perform: aMessage selector
withArguments: aMessage arguments]
ifFalse: [self error: ‘Illegal operator’]
Прежде чем переадресовать сообщение реальному субъекту, указанный метод проверяет, что оно допустимо. Если это не так, doesNotUnderstand: посылает сообщение error: самому себе, что приведет к зацикливанию, если в заместителе не определен метод error:. Следовательно, определение error: должно быть скопировано из класса Object вместе со всеми методами, которые в нем используются.
Пример виртуального заместителя из раздела «Мотивация» заимствован из классов строительного блока текста, определенных в каркасе ET++.
В системе NEXTSTEP [Add94] заместители (экземпляры класса NXProxy) используются как локальные представители объектов, которые могут быть распределенными. Сервер создает заместителей для удаленных объектов, когда клиент их запрашивает. Заместитель кодирует полученное сообщение вместе со всеми аргументами, после чего отправляет его удаленному субъекту. Аналогично субъект кодирует возвращенные результаты и посылает их обратно объекту NXProxy.
В работе McCullough [McC87] обсуждается применение заместителей в Smalltalk для доступа к удаленным объектам. Джефри Пэско (Geoffrey Pascoe) [Pas86] описывает, как обеспечить побочные эффекты при вызове методов и реализовать контроль доступа с помощью «инкапсуляторов».
Паттерн адаптер предоставляет другой интерфейс к адаптируемому объекту. Напротив, заместитель в точности повторяет интерфейс своего субъекта. Однако, если заместитель используется для ограничения доступа, он может отказаться выполнять операцию, которую субъект выполнил бы, поэтому на самом деле интерфейс заместителя может быть и подмножеством интерфейса субъекта.
Несколько замечаний относительно декоратора. Хотя его реализация и похожа на реализацию заместителя, но назначение совершенно иное. Декоратор добавляет объекту новые обязанности, а заместитель контролирует доступ к объекту.
Степень схожести реализации заместителей и декораторов может быть различной. Защищающий заместитель мог бы быть реализован в точности как декоратор. С другой стороны, удаленный заместитель не содержит прямых ссылок на реальный субъект, а лишь косвенную ссылку, что-то вроде «идентификатор хоста и локальный адрес на этом хосте». Вначале виртуальный заместитель имеет только косвенную ссылку (скажем, имя файла), но в конечном итоге получает и использует прямую ссылку.
Возможно, вы обратили внимание на то, что структурные паттерны похожи между собой, особенно когда речь идет об их участниках и взаимодействиях. Вероятное объяснение такому явлению: все структурные паттерны основаны на небольшом множестве языковых механизмов структурирования кода и объектов (одиночном и множественном наследовании для паттернов уровня класса и композиции для паттернов уровня объектов). Но имеющееся сходство может быть обманчиво, ибо с помощью разных паттернов можно решать совершенно разные задачи. В этом разделе сопоставлены группы структурных паттернов, и вы сможете яснее почувствовать их сравнительные достоинства и недостатки.
У паттернов адаптер и мост есть несколько общих атрибутов. Тот и другой повышают гибкость, вводя дополнительный уровень косвенности при обращении к другому объекту. Оба перенаправляют запросы другому объекту, используя иной интерфейс.
Основное различие между адаптером и мостом в их назначении. Цель первого – устранить несовместимость между двумя существующими интерфейсами. При разработке адаптера не учитывается, как эти интерфейсы реализованы
и то, как они могут независимо развиваться в будущем. Он должен лишь обеспечить совместную работу двух независимо разработанных классов, так чтобы ни один из них не пришлось переделывать. С другой стороны, мост связывает абстракцию с ее, возможно, многочисленными реализациями. Данный паттерн предоставляет клиентам стабильный интерфейс, позволяя в то же время изменять классы, которые его реализуют. Мост также подстраивается под новые реализации, появляющиеся в процессе развития системы.
В связи с описанными различиями адаптер и мост часто используются в разные моменты жизненного цикла системы. Когда выясняется, что два несовместимых класса должны работать вместе, следует обратиться к адаптеру. Тем самым удастся избежать дублирования кода. Заранее такую ситуацию предвидеть нельзя. Наоборот, пользователь моста с самого начала понимает, что у абстракции может быть несколько реализаций и развитие того и другого будет идти независимо. Адаптер обеспечивает работу после того, как нечто спроектировано; мост – до того. Это доказывает, что адаптер и мост предназначены для решения именно своих задач.
Фасад можно представлять себе как адаптер к набору других объектов. Но при такой интерпретации легко не заметить такой нюанс: фасад определяет новый интерфейс, тогда как адаптер повторно использует уже имеющийся. Подчеркнем, что адаптер заставляет работать вместе два существующих интерфейса, а не определяет новый.
Компоновщик, декоратор и заместитель
У компоновщика и декоратора аналогичные структурные диаграммы, свидетельствующие о том, что оба паттерна основаны на рекурсивной композиции
и предназначены для организации заранее неопределенного числа объектов. При обнаружении данного сходства может возникнуть искушение посчитать объект-декоратор вырожденным случаем компоновщика, но при этом будет искажен сам смысл паттерна декоратор. Сходство и заканчивается на рекурсивной композиции, и снова из-за различия задач, решаемых с помощью паттернов.
Назначение декоратора – добавить новые обязанности объекта без порождения подклассов. Этот паттерн позволяет избежать комбинаторного роста числа подклассов, если проектировщик пытается статически определить все возможные комбинации. У компоновщика другие задачи. Он должен так структурировать классы, чтобы различные взаимосвязанные объекты удавалось трактовать единообразно, а несколько объектов рассматривать как один. Акцент здесь делается не на оформлении, а на представлении.
Указанные цели различны, но дополняют друг друга. Поэтому компоновщик и декоратор часто используются совместно. Оба паттерна позволяют спроектировать систему так, что приложения можно будет создавать, просто соединяя объекты между собой, без определения новых классов. Появится некий абстрактный класс, одни подклассы которого – компоновщики, другие – декораторы,
а третьи – реализации фундаментальных строительных блоков системы. В таком случае у компоновщиков и декораторов будет общий интерфейс. Для декоратора компоновщик является конкретным компонентом. А для компоновщика декоратор – это листовый узел. Разумеется, их необязательно использовать вместе, и, как мы видели, цели данных паттернов различны.
Заместитель – еще один паттерн, структура которого напоминает декоратор. Оба они описывают, как можно предоставить косвенный доступ к объекту, и в реализации объектов-декораторов и заместителей хранится ссылка на другой объект, которому переадресуются запросы. Но и здесь цели различаются.
Как и декоратор, заместитель предоставляет клиенту интерфейс, совпадающий с интерфейсом замещаемого объекта. Но в отличие от декоратора заместителю не нужно динамически добавлять и отбирать свойства, он не предназначен для рекурсивной композиции. Заместитель должен предоставить стандартную замену субъекту, когда прямой доступ к нему неудобен или нежелателен, например потому, что он находится на удаленной машине, хранится на диске или доступен лишь ограниченному кругу клиентов.
В паттерне заместитель субъект определяет основную функциональность,
а заместитель разрешает или запрещает доступ к ней. В декораторе компонент обладает лишь частью функциональности, а остальное привносят один или несколько декораторов. Декоратор позволяет справиться с ситуацией, когда полную функциональность объекта нельзя определить на этапе компиляции или это по тем или иным причинам неудобно. Такая неопределенность делает рекурсивную композицию неотъемлемой частью декоратора. Для заместителя дело обстоит не так, ибо ему важно лишь одно отношение – между собой и своим субъектом, а данное отношение можно выразить статически.
Указанные различия существенны, поскольку в них абстрагированы решения конкретных проблем, снова и снова возникающих при объектно-ориентированном проектировании. Но это не означает, что сочетание разных паттернов невозможно. Можно представить себе заместителя-декоратора, который добавляет новую функциональность заместителю, или декоратора-заместителя, который оформляет удаленный объект. Такие гибриды теоретически могут быть полезны (у нас, правда, не нашлось реального примера), а вот паттерны, из которых они составлены, полезны наверняка.