AbstractClass
PrimitiveOperation1()
PrimitiveOpetation2()
ConcreteClass
…
PrimitiveOperation1()
…
PrimitiveOperation2()
…
376 Глава 5.
Паттерны поведенияУчастники
AbstractClass (
Application
) — абстрактный класс:
• определяет абстрактные примитивные операции, замещаемые в кон- кретных подклассах для реализации шагов алгоритма;
• реализует шаблонный метод, определяющий скелет алгоритма. Ша- блонный метод вызывает примитивные операции, а также операции, определенные в классе AbstractClass или в других объектах;
ConcreteClass (
MyApplication
) — конкретный класс:
• реализует примитивные операции, выполняющие шаги алгоритма способом, который зависит от подкласса.
Отношения
ConcreteClass предполагает, что инвариантные шаги алгоритма будут вы- полняться в
AbstractClass
Результаты
Шаблонные методы — один из фундаментальных приемов повторного использования кода. Они играют особенно важную роль в библиотеках классов, поскольку предоставляют возможность вынести общее поведение в библиотечные классы.
Шаблонные методы приводят к инвертированной структуре кода, которую иногда называют принципом Голливуда, подразумевая часто употребляе- мую в этой киноимперии фразу «Не звоните нам, мы сами вам позвоним»
[Swe85]. В данном случае это означает, что родительский класс вызывает операции подкласса, а не наоборот.
Шаблонные методы вызывают операции следующих видов:
конкретные операции (либо из класса
ConcreteClass
, либо из классов клиента);
конкретные операции из класса
AbstractClass
(то есть операции, полез- ные всем подклассам);
примитивные операции (то есть абстрактные операции);
фабричные методы (см. паттерн фабричный метод (135));
операции-зацепки (hook operations), реализующие поведение по умол- чанию, которое может быть расширено в подклассах. Часто такая опера- ция по умолчанию не делает ничего.
Паттерн Template Method (шаблонный метод)
377
Важно, чтобы в шаблонном методе четко различались операции-зацепки
(которые можно замещать) и абстрактные операции (которые нужно заме- щать). Чтобы повторно использовать абстрактный класс с максимальной эффективностью, авторы подклассов должны понимать, какие операции предназначены для замещения.
Подкласс может расширить поведение некоторой операции, заместив ее и явно вызвав эту операцию из родительского класса:
void DerivedClass::Operation () {
// Расширенное поведение DerivedClass
ParentClass::Operation();
}
К сожалению, очень легко забыть о необходимости вызывать унаследован- ную операцию. Такую операцию можно трансформировать в шаблонный метод, чтобы предоставить родителю контроль над тем, как подклассы рас- ширяют его. Идея в том, чтобы вызывать операцию-зацепку из шаблонного метода в родительском классе. Тогда подклассы смогут переопределить именно эту операцию:
void ParentClass::Operation () {
// Поведение ParentClass
HookOperation();
}
В родительском классе
ParentClass операция
HookOperation не делает ни- чего:
void ParentClass::HookOperation () { }
Подклассы переопределяют
HookOperation
, чтобы расширить свое поведение:
void DerivedClass::HookOperation () {
// Расширение в производном классе
}
Реализация
При реализации паттерна шаблонный метод следует обратить внимание на следующие аспекты:
использование контроля доступа в C++. В этом языке примитивные операции, которые вызывает шаблонный метод, можно объявить защи- щенными членами. Тогда гарантируется, что вызывать их сможет толь- ко сам шаблонный метод. Примитивные операции, которые обязатель-
но нужно замещать, объявляются как чисто виртуальные функции. Сам
378
Глава 5. Паттерны поведения шаблонный метод замещать не надо, так что его можно сделать невирту- альной функцией-членом;
сокращение числа примитивных операций. Важной целью при проек- тировании шаблонных методов является всемерное сокращение числа примитивных операций, которые должны быть замещены в подклассах.
Чем больше операций нужно замещать, тем утомительнее становится программирование клиента;
соглашение об именах. Выделить операции, которые необходимо за- местить, можно путем добавления к их именам некоторого префикса.
Например, в каркасе MacApp для приложений на платформе Macin- tosh [App89] имена шаблонных методов начинаются с префикса
Do
:
DoCreateDocument
,
DoRead и т. д.
Пример кода
Следующий пример на языке C++ показывает, как родительский класс может навязать своим подклассам некоторый инвариант. Пример взят из библиотеки NeXT AppKit [Add94]. Рассмотрим класс
View
, поддерживающий рисование на экране, — своего рода инвариант, который заключается в том, что подклассы могут изменять вид только тогда, когда он находится в фо- кусе. Для этого необходимо, чтобы был установлен определенный контекст рисования (например, цвета и шрифты).
Для установки состояния можно воспользоваться шаблонным методом
Display
. В классе
View определены две конкретные операции (
SetFocus и
ResetFocus
), которые соответственно устанавливают и сбрасывают кон- текст рисования. Операция-зацепка
DoDisplay класса
View занимается собственно рисованием.
Display вызывает
SetFocus перед
DoDisplay
, чтобы подготовить контекст, и
ResetFocus после
DoDisplay
— чтобы его сбросить:
void View::Display () {
SetFocus();
DoDisplay();
ResetFocus();
}
С целью поддержки инварианта клиенты класса
View всегда вызывают
Display и подклассы
View всегда замещают
DoDisplay
В классе
View операция
DoDisplay не делает ничего:
void View::DoDisplay () { }
Паттерн Visitor (посетитель)
379Подклассы переопределяют ее, чтобы добавить свое конкретное поведение рисования:
void MyView::DoDisplay () {
// изобразить содержимое вида
}
Известные применения
Шаблонные методы настолько фундаментальны, что встречаются поч- ти в каждом абстрактном классе. В работах Ребекки Вирфс-Брок и др.
[WBWW90, WBJ90] подробно обсуждаются шаблонные методы.
Родственные паттерны
Фабричные методы (135) часто вызываются из шаблонных. В примере из раздела «Мотивация» шаблонный метод
OpenDocument вызывал фабричный метод
DoCreateDocument
Стратегия (362): шаблонные методы применяют наследование для модифи- кации части алгоритма. Стратегии используют делегирование для модифи- кации алгоритма в целом.
ПАТТЕРН VISITOR (ПОСЕТИТЕЛЬ)
Название и классификация паттерна
Посетитель — паттерн поведения объектов.
Назначение
Описывает операцию, выполняемую с каждым объектом из некоторой структуры. Паттерн посетитель позволяет определить новую операцию, не изменяя классы этих объектов.
Мотивация
Рассмотрим компилятор, который представляет программу в виде абстракт- ного синтаксического дерева. Над такими деревьями он должен выполнять операции «статического семантического» анализа,
например проверять, что все переменные определены. Еще ему нужно генерировать код. Аналогично можно было бы определить операции контроля типов, оптимизации кода,
380
Глава 5. Паттерны поведения анализа потока выполнения, проверки того, что каждой переменной было присвоено конкретное значение перед первым использованием, и т. д. Более того, абстрактные синтаксические деревья могли бы служить для красивого оформления результатов программы, реструктурирования кода и вычисле- ния различных метрик программы.
В большинстве таких операций узлы дерева, представляющие операторы присваивания, должны рассматриваться иначе, чем узлы, представляющие переменные и арифметические выражения. Поэтому один класс будет создан для операторов присваивания, другой — для доступа к переменным, тре- тий — для арифметических выражений и т. д. Набор классов узлов, конечно, зависит от компилируемого языка, но не очень сильно.
Node
TypeCheck()
GenerateCode()
PrettyPrint()
VariableRefNode
TypeCheck()
GenerateCode()
PrettyPrint()
AssignmentNode
TypeCheck()
GenerateCode()
PrettyPrint()
На схеме показана часть иерархии классов
Node
. Проблема здесь в том, что при распределении всех операций по классам различных узлов получится система, которую трудно понять, сопровождать и изменять. Вряд ли кто- нибудь разберется в программе, если код, отвечающий за проверку типов, будет перемешан с кодом, реализующим красивую печать или анализ потока выполнения. Кроме того, добавление любой новой операции потребует пере- компиляции всех классов. Оптимальный вариант — наличие возможности добавлять операции по отдельности и отсутствие зависимости классов узлов от применяемых к ним операций.
Обе проблемы можно решить выделением взаимосвязанных операций из каждого класса в отдельный объект, называемый посетителем, и передавать его элементам абстрактного синтаксического дерева по мере обхода. «При-
Паттерн Visitor (посетитель)
381
нимая» посетителя, элемент посылает ему запрос, в котором содержится, в частности, класс элемента. Кроме того, в запросе присутствует в виде аргумента и сам элемент. Посетителю в данной ситуации предстоит выпол- нить операцию над элементом — ту самую, которая наверняка находилась бы в классе элемента.
Например, компилятор, который не использует посетителей, мог бы про- верить тип процедуры, вызвав операцию
TypeCheck для представляющего ее абстрактного синтаксического дерева. Каждый узел дерева должен был реализовать операцию
TypeCheck путем рекурсивного вызова ее же для своих компонентов (см. приведенную выше схему классов). Если же компилятор проверяет тип процедуры посредством посетителей, то ему достаточно создать объект класса
TypeCheckingVisitor и вызвать для дерева операцию
Accept
, передав ей этот объект в аргументе. Каждый узел должен был ре- ализовать
Accept путем обращения к посетителю: узел, соответствующий оператору присваивания, вызывает операцию посетителя
VisitAssignment
, а узел, ссылающийся на переменную, — операцию
VisitVariableReference
То, что раньше было операцией
TypeCheck в классе
AssignmentNode
, стало операцией
VisitAssignment в классе
TypeCheckingVisitor
Чтобы посетители могли заниматься не только проверкой типов, нам пона- добится абстрактный класс
NodeVisitor
, являющийся родителем для всех посетителей синтаксического дерева. Приложение, которому нужно вычис- лять метрики программы, определило бы новые подклассы
NodeVisitor
, так что нам не пришлось бы добавлять зависящий от приложения код в классы узлов. Паттерн посетитель инкапсулирует операции, выполняемые на каждой фазе компиляции, в классе
Visitor
, ассоциированном с этой фазой.
VisitAssignment(AssignmentNode)
VisitVariableRef(VariableRefNode)
NodeVisitor
VisitAssignment(AssignmentNode)
VisitVariableRef(VariableRefNode)
TypeCheckingVisitor
VisitAssignment(AssignmentNode)
VisitVariableRef(VariableRefNode)
CodeGeneratingVisitor
382
Глава 5. Паттерны поведения
AssignmentNode
Accept(NodeVisitor v)
VariableRefNode
Accept(NodeVisitor v)
Node
Program
Accept(NodeVisitor)
v–>VisitAssignment(this)
v–>VisitVariableRef(this)
Применяя паттерн посетитель, вы определяете две иерархии классов: одну для элементов, над которыми выполняется операция (иерархия
Node
), а дру- гую — для посетителей, описывающих те операции, которые выполняются над элементами (иерархия
NodeVisitor
). Новая операция создается путем добавления подкласса в иерархию классов посетителей. До тех пор пока грамматика языка остается постоянной (то есть не добавляются новые под- классы
Node
), новую функциональность можно получить путем определения новых подклассов
NodeVisitor
Применимость
Основные условия для применения паттерна посетитель:
в структуре присутствуют объекты многих классов с различными ин- терфейсами, и вы хотите выполнять над ними операции, зависящие от конкретных классов;
над объектами, входящими в состав структуры, должны выполняться раз- нообразные, не связанные между собой операции и вы не хотите «засо- рять» классы такими операциями. Посетитель позволяет объединить род- ственные операции, поместив их в один класс. Если структура объектов является общей для нескольких приложений, то паттерн посетитель позво- лит в каждое приложение включить только относящиеся к нему операции;
классы, определяющие структуру объектов, изменяются редко, но но- вые операции над этой структурой добавляются часто. При изменении классов, представленных в структуре, придется переопределить интер- фейсы всех посетителей, а это может вызвать затруднения. Поэтому если классы меняются достаточно часто, то, вероятно, лучше опреде- лить операции прямо в них.
Паттерн Visitor (посетитель)
383
Структура
ConcreteElementA
Accept(Visitor v)
OperationA()
ConcreteElementB
Accept(Visitor v)
OperationB()
Element
ObjectStructure
Client
Accept(Visitor)
v–>VisitConcreteElementA(this)
v–>VisitConcreteElementB(this)
VisitConcreteElementA(ConcreteElementA)
VisitConcreteElementB(ConcreteElementB)
Visitor
VisitConcreteElementA(ConcreteElementA)
VisitConcreteElementB(ConcreteElementB)
ConcreteVisitor1
VisitConcreteElementA(ConcreteElementA)
VisitConcreteElementB(ConcreteElementB)
ConcreteVisitor2
Участники
Visitor (
NodeVisitor
) — посетитель:
• объявляет операцию
Visit для каждого класса
ConcreteElement в структуре объектов. Имя и сигнатура этой операции идентифици- руют класс, который посылает посетителю запрос
Visit
. Это позво- ляет посетителю определить, элемент какого конкретного класса он посещает. Владея такой информацией, посетитель может обращаться к элементу напрямую через его интерфейс;
ConcreteVisitor (
TypeCheckingVisitor
) — конкретный посетитель:
• реализует все операции, объявленные в классе
Visitor
. Каждая опера- ция реализует фрагмент алгоритма, определенного для класса соответ- ствующего объекта в структуре. Класс
ConcreteVisitor предоставляет контекст для этого алгоритма и сохраняет его локальное состояние.
Часто в этом состоянии аккумулируются результаты, полученные в процессе обхода структуры;
384
Глава 5. Паттерны поведения
Element (
Node
) — элемент:
• определяет операцию
Accept
, которая принимает посетителя в аргу- менте;
ConcreteElement (
AssignmentNode
,
VariableRefNode
) — конкретный эле- мент:
• реализует операцию
Accept
, принимающую посетителя как аргумент;
ObjectStructure (
Program
) — структура объектов:
• может перечислить свои элементы;
• может предоставить посетителю высокоуровневый интерфейс для посещения своих элементов;
• может быть как составным объектом (см. паттерн компоновщик (196)), так и коллекцией, например списком или множеством.
Отношения
Клиент, использующий паттерн посетитель, должен создать объект клас- са
ConcreteVisitor
, а затем обойти всю структуру, посетив каждый ее элемент.
При посещении элемента последний вызывает операцию посетителя, соответствующую своему классу. Элемент передает этой операции себя в аргументе, чтобы посетитель мог при необходимости получить доступ к его состоянию.
На представленной диаграмме взаимодействий показаны отношения между объектом, структурой, посетителем и двумя элементами.
anObjectStructure aConcreteElementA aConcreteElementB
aConcreteVisitor
Accept(aVisitor)
Accept(aVisitor)
VisitConcreteElementA(aConcreteElementA)
VisitConcreteElementB(aConcreteElementB)
OperationA()
OperationB()
Паттерн Visitor (посетитель)
385
Результаты
Основные достоинства и недостатки паттерна посетитель:
упрощение добавления новых операций. С помощью посетителей легко добавлять операции, зависящие от компонентов сложных объектов. Для определения новой операции над структурой объектов достаточно про- сто ввести нового посетителя. Напротив, если функциональность рас- пределена по нескольким классам, то для определения новой операции придется изменить каждый класс;
объединение родственных операций и отсечение тех, которые не имеют
к ним отношения. Родственное поведение не разносится по всем клас- сам, присутствующим в структуре объектов, оно локализовано в по- сетителе. Не связанные друг с другом функции распределяются по от- дельным подклассам класса
Visitor
. Это способствует упрощению как классов, определяющих элементы, так и алгоритмов, инкапсулирован- ных в посетителях. Все относящиеся к алгоритму структуры данных можно скрыть в посетителе;
трудности с добавлением новых классов ConcreteElement. Паттерн посе- титель усложняет добавление новых подклассов класса
Element
. Каж- дый новый конкретный элемент требует объявления новой абстракт- ной операции в классе
Visitor
, которую нужно реализовать в каждом из существующих классов
ConcreteVisitor
. Иногда большинство кон- кретных посетителей могут унаследовать операцию по умолчанию, предоставляемую классом
Visitor
, что скорее исключение, чем прави- ло. Поэтому при решении вопроса о том, стоит ли использовать паттерн посетитель, нужно прежде всего посмотреть, что будет изменяться чаще: алгоритм, применяемый к объектам структуры, или классы объектов, составляющих эту структуру. Вполне вероятно, что с сопровождени- ем иерархии классов
Visitor возникнут трудности, если новые классы
ConcreteElement добавляются часто. В таких случаях проще определить операции прямо в классах, представленных в структуре. Если же иерар- хия классов
Element стабильна, но постоянно расширяется набор опе- раций или модифицируются алгоритмы, то паттерн посетитель поможет лучше управлять такими изменениями;
посещение различных иерархий классов. Итератор (см. описание паттер- на итератор) может посещать объекты структуры по мере ее обхода, вы- зывая операции объектов. Но итератор не способен работать со струк- турами, состоящими из объектов разных типов. Так, интерфейс класса
Iterator
, рассмотренный на с. 310, может всего лишь получить доступ к объектам типа
Item
:
386
Глава 5. Паттерны поведения template
class Iterator {
// ...
Item CurrentItem() const;
};
Отсюда следует, что все элементы, которые итератор может посетить, должны иметь общий родительский класс
Item
У посетителя таких ограничений нет. Ему разрешено посещать объекты, не имеющие общего родительского класса. В интерфейс класса
Visitor можно добавить операции для объектов любого типа. Например, в сле- дующем объявлении class Visitor {
public:
// ...
void VisitMyType(MyType*);
void VisitYourType(YourType*);
};
классы
MyType и
YourType необязательно должны быть связаны отноше- нием наследования;
накопление состояния. Посетители могут накапливать информацию о состоянии при посещении объектов структуры. Если не использовать этот паттерн, то состояние придется передавать в дополнительных аргу- ментах операций, выполняющих обход, или хранить в глобальных пере- менных;
нарушение инкапсуляции. Применение посетителей подразумевает, что класс
ConcreteElement имеет достаточно развитый интерфейс, для того чтобы посетители могли справиться со своей работой. Поэтому при ис- пользовании данного паттерна приходится предоставлять открытые операции для доступа к внутреннему состоянию элементов, что ставит под угрозу инкапсуляцию.
Реализация
С каждым объектом структуры ассоциируется некий класс посетителя
Visitor
В этом абстрактном классе объявлены операции
VisitConcreteElement для каждого конкретного класса
ConcreteElement элементов, представленных в структуре. В каждой операции типа
Visit аргумент объявлен как принад- лежащий одному из классов ConcreteElement, так что посетитель может напрямую обращаться к интерфейсу этого класса. Классы
ConcreteVisitor
Паттерн Visitor (посетитель)
387
замещают операции
Visit с целью реализации поведения посетителя для соответствующего класса
ConcreteElement
В C++ объявление класса
Visitor выглядело бы примерно так:
class Visitor {
public:
virtual void VisitElementA(ElementA*);
virtual void VisitElementB(ElementB*);
// И т. д. для других конкретных элементов protected:
Visitor();
};
Каждый класс
ConcreteElement реализует операцию
Accept
, которая вы- зывает соответствующую операцию
Visit...
посетителя для этого класса.
Следовательно, вызываемая в конечном итоге операция зависит как от класса элемента, так и от класса посетителя
1
Объявления конкретных элементов выглядят так:
class Element {
public:
virtual Element();
virtual void Accept(Visitor&) = 0;
protected:
Element();
};
class ElementA : public Element {
public:
ElementA();
virtual void Accept(Visitor& v) { v.VisitElementA(this); }
};
class ElementB : public Element {
public:
ElementB();
virtual void Accept(Visitor& v) { v.VisitElementB(this); }
};
1
Можно было бы использовать перегрузку функций, чтобы дать этим операциям одно и то же простое имя (например, Visit), так как они уже различаются типом передавае- мого параметра. Имеются аргументы как в пользу подобной перегрузки, так и против нее. С одной стороны, подчеркивается, что все операции выполняют однотипный ана- лиз, хотя и с разными аргументами. С другой стороны, при этом читателю программы может быть не вполне понятно, что происходит при вызове. В общем все зависит от того, часто ли вы применяете перегрузку функций.
388
Глава 5. Паттерны поведения
Класс
CompositeElement мог бы реализовать операцию
Accept следующим образом:
class CompositeElement : public Element {
public:
virtual void Accept(Visitor&);
private:
List* _children;
};
void CompositeElement::Accept (Visitor& v) {
ListIterator i(_children);
for (i.First(); !i.IsDone(); i.Next()) {
i.CurrentItem()->Accept(v);
}
v.VisitCompositeElement(this);
}
При решении вопроса о применении паттерна посетитель часто возникают два спорных момента:
двойная диспетчеризация. По своей сути паттерн посетитель добавляет в классы новые операции без их изменения. Это делается с помощью приема, называемого двойной диспетчеризацией. Данная техника хоро- шо известна. Некоторые языки программирования (например, CLOS) поддерживают ее явно. Языки же вроде C++ и Smalltalk поддерживают только одинарную диспетчеризацию.
Для определения того, какая операция будет выполнять запрос, в языках с одинарной диспетчеризацией необходимы имя запроса и тип полу- чателя. Например, то, какая операция будет вызвана для обработки за- проса
GenerateCode
, зависит от типа объекта в узле, которому адресован запрос. В C++ вызов
GenerateCode для экземпляра
VariableRefNode приводит к вызову функции
VariableRefNode::GenerateCode
(гене- рирующей код обращения к переменной). Вызов же
GenerateCode для узла класса
AssignmentNode приводит к вызову функции
AssignmentNode::GenerateCode
(генерирующей код для оператора при- сваивания). Таким образом, выполняемая операция определяется одно- временно видом запроса и типом получателя.
Понятие «двойная диспетчеризация» означает, что выполняемая опе- рация зависит от вида запроса и типов двух получателей.
Accept
— это операция с двойной диспетчеризацией. Ее семантика зависит от типов двух объектов:
Visitor и
Element
. Двойная диспетчеризация позволяет
Паттерн Visitor (посетитель)
389
посетителю запрашивать разные операции для каждого класса эле- мента
1
Поэтому возникает необходимость в паттерне посетитель: выполняемая операция зависит и от типа посетителя, и от типа посещаемого элемента.
Вместо статической привязки операций к интерфейсу класса
Element мы можем консолидировать эти операции в классе
Visitor и использовать
Accept для привязки их во время выполнения. Расширение интерфейса класса
Element сводится к определению нового подкласса
Visitor
, а не к модификации многих подклассов
Element
;
какой участник несет ответственность за обход структуры. Посети- тель должен обойти каждый элемент структуры объектов. Вопрос в том, как туда попасть. Ответственность за обход можно возложить на саму структуру объектов, на посетителя или на отдельный объект-итератор
(см. паттерн итератор (302)). Чаще всего структура объектов отвечает за обход. Коллекция просто обходит все свои элементы, вызывая для каждого операцию
Accept
. Составной объект обычно обходит самого себя, заставляя операцию
Accept посетить потомков текущего элемента и рекурсивно вызвать
Accept для каждого из них.
Другое решение — воспользоваться итератором для посещения элементов.
В C++ можно применить внутренний или внешний итератор, в зависимо- сти от того, что доступно и более эффективно. В Smalltalk обычно рабо- тают с внутренним итератором на основе метода do:
и блока. Поскольку внутренние итераторы реализуются самой структурой объектов, то работа с ними во многом напоминает предыдущее решение, когда за обход от- вечает структура. Основное различие заключается в том, что внутренний итератор не приводит к двойной диспетчеризации: он вызывает операцию
посетителя с элементом в качестве аргумента, а не операцию элемента
с посетителем в качестве аргумента. Однако использовать паттерн по- сетитель с внутренним итератором легко в том случае, когда операция посетителя вызывает операцию элемента без рекурсии.
Можно даже включить алгоритм обхода в посетителя, хотя закончится это дублированием кода обхода в каждом классе
ConcreteVisitor для
1
Если есть двойная диспетчеризация, то почему бы не быть тройной, четверной или диспетчеризации произвольной кратности? Двойная диспетчеризация — это просто частный случай множественной диспетчеризации, при которой выбираемая операция зависит от любого числа типов. (CLOS как раз и поддерживает множественную дис- петчеризацию.) В языках с поддержкой двойной или множественной диспетчеризации необходимость в паттерне посетитель возникает гораздо реже.
390
Глава 5. Паттерны поведения каждого агрегата
ConcreteElement
. Основная причина такого решения — необходимость реализовать особо сложную стратегию обхода, зависящую от результатов операций над объектами структуры. Этот случай рассма- тривается в разделе «Пример кода».
Пример кода
Поскольку посетители обычно ассоциируются с составными объектами, то для иллюстрации паттерна посетитель мы воспользуемся классами
Equipment
, определенными в разделе «Пример кода» из описания паттерна компонов- щик (196). Для определения операций, создающих инвентарную опись материалов и вычисляющих полную стоимость агрегата, нам понадобится паттерн посетитель. Классы
Equipment настолько просты, что применять пат- терн посетитель в общем-то излишне, но на этом примере демонстрируются основные особенности его реализации.
Приведем еще раз объявление класса
Equipment из описания паттерна компо- новщик (196). Мы добавили операцию
Accept
, чтобы можно было работать с посетителем:
class Equipment {
public:
virtual Equipment();
const char* Name() { return _name; }
virtual Watt Power();
virtual Currency NetPrice();
virtual Currency DiscountPrice();
virtual void Accept(EquipmentVisitor&);
protected:
Equipment(const char*);
private:
const char* _name;
};
Операции класса
Equipment возвращают такие атрибуты единицы обору- дования, как энергопотребление и стоимость. В подклассах эти операции переопределены в соответствии с конкретными типами оборудования (рама, дисководы и электронные платы).
В абстрактном классе всех посетителей оборудования имеются виртуальные функции для каждого подкласса (см. ниже). По умолчанию эти функции ничего не делают:
Паттерн Visitor (посетитель)
391
class EquipmentVisitor {
public:
virtual EquipmentVisitor();
virtual void VisitFloppyDisk(FloppyDisk*);
virtual void VisitCard(Card*);
virtual void VisitChassis(Chassis*);
virtual void VisitBus(Bus*);
// И так далее для всех конкретных подклассов Equipment protected:
EquipmentVisitor();
};
Все подклассы класса
Equipment определяют функцию
Accept практически одинаково. Она вызывает операцию
EquipmentVisitor
, которая соответствует классу, получившему запрос
Accept
:
void FloppyDisk::Accept (EquipmentVisitor& visitor) {
visitor.VisitFloppyDisk(this);
}
Виды оборудования, которые содержат другое оборудование (в частности, подклассы
CompositeEquipment в терминологии паттерна компоновщик), реализуют
Accept путем обхода своих потомков и вызова
Accept для каж- дого из них. Затем, как обычно, вызывается операция
Visit
. Например,
Chassis::Accept могла бы обойти все расположенные на шасси компоненты следующим образом:
void Chassis::Accept (EquipmentVisitor& visitor) {
for (
ListIterator i(_parts);
!i.IsDone();
i.Next()
) {
i.CurrentItem()->Accept(visitor);
}
visitor.VisitChassis(this);
}
Подклассы
EquipmentVisitor определяют конкретные алгоритмы, применя- емые к структуре оборудования. Так,
PricingVisitor вычисляет стоимость всей конструкции, для чего суммирует нетто-цены простых компонентов
(например, гибкие диски) и цену со скидкой составных компонентов (на- пример, рамы и шины):
392
Глава 5. Паттерны поведения class PricingVisitor : public EquipmentVisitor {
public:
PricingVisitor();
Currency& GetTotalPrice();
virtual void VisitFloppyDisk(FloppyDisk*);
virtual void VisitCard(Card*);
virtual void VisitChassis(Chassis*);
virtual void VisitBus(Bus*);
// ...
private:
Currency _total;
};
void PricingVisitor::VisitFloppyDisk (FloppyDisk* e) {
_total += e->NetPrice();
}
void PricingVisitor::VisitChassis (Chassis* e) {
_total += e->DiscountPrice();
}
Таким образом, посетитель
PricingVisitor подсчитает полную стоимость всех узлов конструкции. Заметим, что
PricingVisitor выбирает стратегию вычисления цены в зависимости от класса оборудования, для чего вызыва- ет соответствующую функцию класса. Особенно важно то, что для оценки конструкции можно выбрать другую стратегию, просто поменяв класс
PricingVisitor
Определить посетителя для составления инвентарной описи можно следу- ющим образом:
class InventoryVisitor : public EquipmentVisitor {
public:
InventoryVisitor();
Inventory& GetInventory();
virtual void VisitFloppyDisk(FloppyDisk*);
virtual void VisitCard(Card*);
virtual void VisitChassis(Chassis*);
virtual void VisitBus(Bus*);
// ...
private:
Inventory _inventory;
};
Паттерн Visitor (посетитель)
393
Посетитель
InventoryVisitor подсчитывает итоговое количество каждого вида оборудования во всей конструкции. При этом используется класс
Inventory
, в котором определен интерфейс для добавления компонента
(здесь мы его приводить не будем):
void InventoryVisitor::VisitFloppyDisk (FloppyDisk* e) {
_inventory.Accumulate(e);
}
void InventoryVisitor::VisitChassis (Chassis* e) {
_inventory.Accumulate(e);
}
Добавление
InventoryVisitor к структуре объектов может происходить следующим образом:
Equipment* component;
InventoryVisitor visitor; component->Accept(visitor); cout << "Inventory "
<< component->Name()
<< visitor.GetInventory();
Далее мы покажем, как на языке Smalltalk реализовать пример из описа- ния паттерна интерпретатор (287) с помощью паттерна посетитель. Как и в предыдущем случае, этот пример настолько мал, что паттерн посетитель особой пользы не принесет, но неплохо демонстрирует основные принци- пы. Кроме того, демонстрируется ситуация, в которой за обход отвечает посетитель.
Структура объектов (регулярные выражения) представлена четырьмя классами, в каждом из которых существует метод accept:
, принимающий посетитель в качестве аргумента. В классе
SequenceExpression метод accept
: выглядит так:
accept: aVisitor
^ aVisitor visitSequence: self
Метод accept:
в классах
RepeatExpression
,
AlternationExpression и
LiteralExpression посылает сообщения visitRepeat:
, visitAlternation:
и visitLiteral:
соответственно.
Все четыре класса должны иметь функции доступа, к которым может обра- титься посетитель. Для
SequenceExpression это expression1
и expression2
;
394
Глава 5. Паттерны поведения для
AlternationExpression
— alternative1
и alternative2
; для класса
RepeatExpression
— repetition
, а для
LiteralExpression
— components
Конкретным посетителем выступает класс
REMatchingVisitor
. Он отвечает за обход структуры, поскольку алгоритм обхода нерегулярен. В основном это происходит из-за того, что
RepeatExpression посещает свой компонент многократно. В классе
REMatchingVisitor есть переменная экземпляра inputState
. Его методы практически повторяют методы match:
классов выражений из паттерна интерпретатор, только вместо аргумента inputState подставляется узел, описывающий сравниваемое выражение. Однако они по-прежнему возвращают множество потоков, с которыми должно быть сопоставлено выражение для получения текущего состояния:
visitSequence: sequenceExp inputState := sequenceExp expression1 accept: self.
^ sequenceExp expression2 accept: self.
visitRepeat: repeatExp
| finalState |
finalState := inputState copy.
[inputState isEmpty]
whileFalse:
[inputState := repeatExp repetition accept: self.
finalState addAll: inputState].
^ finalState visitAlternation: alternateExp
| finalState originalState |
originalState := inputState.
finalState := alternateExp alternative1 accept: self.
inputState := originalState.
finalState addAll: (alternateExp alternative2 accept: self).
^ finalState visitLiteral: literalExp
| finalState tStream | finalState := Set new. inputState do:
[:stream | tStream := stream copy.
(tStream nextAvailable: literalExp components size
) = literalExp components ifTrue: [finalState add: tStream]
].
^ finalState
Обсуждение паттернов поведения
395
Известные применения
В компиляторе Smalltalk-80 имеется класс посетителя, который называется
ProgramNodeEnumerator
. В основном он применяется в алгоритмах анализа исходного текста программы и не используется ни для генерирования кода, ни для красивой печати, хотя мог бы.
IRIS Inventor [Str93] — это библиотека для разработки приложений трех- мерной графики. Библиотека представляет собой трехмерную сцену в виде иерархии узлов, каждый из которых соответствует либо геометрическому объекту, либо его атрибуту. Для операций типа изображения сцены или обработки события ввода необходимо по-разному обходить эту иерархию.
В Inventor для этого служат посетители, которые называются действиями
(actions). Есть различные посетители для изображения, обработки событий, поиска, сохранения и определения ограничивающих прямоугольников.
Чтобы упростить добавление новых узлов, в библиотеке Inventor реализова- на схема двойной диспетчеризации на C++. Для этого служит информация о типе, доступная во время выполнения, и двумерная таблица, строки которой представляют посетителей, а колонки — классы узлов. В каждой ячейке хра- нится указатель на функцию, связанную с парой «посетитель — класс» узла.
Марк Линтон (Mark Linton) ввел термин «посетитель» (Visitor) в специ- фикацию библиотеки для построения приложений X Consortium’s Fresco
Application Toolkit [LP93].
Родственные паттерны
Компоновщик (196): посетители могут использоваться для выполнения опе- рации над всеми объектами структуры, определенной с помощью паттерна компоновщик.
Интерпретатор (287): посетитель может использоваться для выполнения интерпретации.
ОБСУЖДЕНИЕ ПАТТЕРНОВ ПОВЕДЕНИЯ
ИНКАПСУЛЯЦИЯ ВАРИАЦИЙ
Инкапсуляция вариаций — элемент многих паттернов поведения. Если определенная часть программы подвержена периодическим изменениям, эти паттерны позволяют определить объект для инкапсуляции такого аспекта.
396 Глава 5. Паттерны поведения
Другие части программы, зависящие от данного аспекта, могут коопериро- ваться с ним. Обычно паттерны поведения определяют абстрактный класс, с помощью которого описывается инкапсулирующий объект
1
. Своим на- званием паттерн как раз и обязан этому объекту.
Например:
объект стратегия (362) инкапсулирует алгоритм;
объект состояние (352) инкапсулирует поведение, зависящее от состо- яния;
объект посредник (319) инкапсулирует протокол общения между объ- ектами;
объект итератор (302) инкапсулирует способы доступа и обхода компо- нентов составного объекта.
Перечисленные паттерны описывают подверженные изменениям аспекты программы. В большинстве паттернов фигурируют два вида объектов: новый объект (или объекты), который инкапсулирует аспект, и существующий объект (или объекты), который пользуется новыми. Если бы не паттерн, то функциональность новых объектов пришлось бы делать неотъемлемой частью существующих. Например, код объекта-стратегии, вероятно, был бы
«зашит» в контекст стратегии, а код объекта-состояния был бы реализован непосредственно в контексте состояния.
Впрочем, не все паттерны поведения разбивают функциональность таким образом. Например, паттерн цепочка обязанностей (263) связан с произ- вольным числом объектов (то есть цепочкой), причем все они могут уже существовать в системе.
Цепочка обязанностей иллюстрирует еще одно различие между паттернами поведения: не все они определяют статические отношения взаимосвязи между классами. В частности, цепочка обязанностей показывает, как ор- ганизовать обмен информацией между заранее неизвестным числом объ- ектов. В других паттернах участвуют объекты, передаваемые в качестве аргументов.
1
Эта тема красной нитью проходит и через другие паттерны.
Абстрактная фабрика
, строи тель и прототип инкапсулируют знание о том, как создаются объекты.
Декоратор
инкапсулирует обязанности, которые могут быть добавлены к объекту.
Мост отделяет абстракцию от ее реализации, позволяя изменять их независимо друг от друга.
Обсуждение паттернов поведения
397
ОБЪЕКТЫ КАК АРГУМЕНТЫ
В нескольких паттернах участвует объект, всегда используемый только как аргумент. Одним из них является посетитель (379). Объект-посетитель — это аргумент полиморфной операции
Accept
, принадлежащей посещаемому объ- екту. Посетитель никогда не рассматривается как часть посещаемых объектов, хотя традиционным альтернативным вариантом этому паттерну служит рас- пределение кода посетителя между классами объектов, входящих в структуру.
Другие паттерны определяют объекты, выступающие в роли волшебных сущ- ностей, которые передаются от одного владельца к другому и активизируются в будущем. К этой категории относятся команда (275) и хранитель (330).
В паттерне команда такой «палочкой» является запрос, а в хранителе она пред- ставляет внутреннее состояние объекта в определенный момент. И там, и там
«палочка» может иметь сложную внутреннюю структуру, но клиент об этом ничего не «знает». Но даже здесь есть различия. В паттерне команда важную роль играет полиморфизм, поскольку выполнение объекта-команды — по- лиморфная операция. Напротив, интерфейс хранителя настолько узок, что его можно передавать лишь как значение. Поэтому вполне вероятно, что храни- тель не предоставляет полиморфных операций своим клиентам.
ДОЛЖЕН ЛИ ОБМЕН ИНФОРМАЦИЕЙ БЫТЬ ИНКАПСУЛИРОВАННЫМ
ИЛИ РАСПРЕДЕЛЕННЫМ?
Паттерны посредник (319) и наблюдатель (339) конкурируют между со- бой. Различие между ними в том, что наблюдатель распределяет обмен ин- формацией за счет объектов наблюдатель и субъект, а посредник, наоборот, инкапсулирует взаимодействие между другими объектами.
В паттерне наблюдатель участники наблюдатель и субъект должны коопери- роваться, чтобы поддержать ограничение. Паттерны обмена информацией определяются тем, как связаны между собой наблюдатели и субъекты; у од- ного субъекта обычно бывает много наблюдателей, а иногда наблюдатель субъекта сам является субъектом наблюдения со стороны другого объекта.
В паттерне посредник ответственность за поддержание ограничения возла- гается исключительно на посредника.
На наш взгляд, повторно использовать наблюдателей и субъектов проще, чем посредников. Паттерн наблюдатель способствует разделению и ослаблению свя- зей между наблюдателем и субъектом, что приводит к появлению сравнительно мелких классов, более приспособленных для повторного использования.
398
Глава 5. Паттерны поведения
С другой стороны, потоки информации в посреднике проще для понима- ния, нежели в наблюдателе. Наблюдатели и субъекты обычно связываются вскоре после создания, и понять, каким же образом организована их связь, в последующих частях программы довольно трудно. Если вы знаете паттерн наблюдатель, то понимаете важность того, как именно связаны наблюдатели и субъекты, и представляете, какие связи надо искать. Однако присущая наблюдателю косвенность затрудняет понимание системы.
В языке Smalltalk наблюдатели можно параметризовать сообщениями, при- меняемыми для доступа к состоянию субъекта, поэтому степень их повтор- ного использования даже выше, чем в C++. Вот почему в Smalltalk паттерн наблюдатель более привлекателен, чем в C++. Следовательно, программист, пишущий на Smalltalk, нередко использует наблюдателя там, где программист на C++ применил бы паттерн посредник.
РАЗДЕЛЕНИЕ ПОЛУЧАТЕЛЕЙ И ОТПРАВИТЕЛЕЙ
Когда взаимодействующие объекты напрямую ссылаются друг на друга, они становятся зависимыми, а это может отрицательно сказаться на повторном использовании системы и разбиении ее на уровни. Паттерны команда, на- блюдатель, посредник и цепочка обязанностей указывают разные способы разделения получателей и отправителей запросов. Каждый способ имеет свои достоинства и недостатки.
Паттерн команда поддерживает разделение за счет объекта-команды, кото- рый определяет привязку отправителя к получателю:
anInvoker
(отправитель)
aCommand aReceiver
(получатель)
Execute()
Action()
Паттерн команда предоставляет простой интерфейс для выдачи запроса
(операцию
Execute
). Определение связи между отправителем и получателем в самостоятельном объекте позволяет отправителю работать с разными по- лучателями. Он отделяет отправителя от получателей, облегчая тем самым повторное использование. Кроме того, объект-команду можно повторно использовать для параметризации получателя различными отправителями.
Номинально паттерн команда требует определения подкласса для каждой
Обсуждение паттернов поведения
399связи «отправитель — получатель», хотя имеются способы реализации, при которых удается избежать порождения подклассов.
Паттерн наблюдатель отделяет отправителей (субъектов) от получателей
(наблюдателей) путем определения интерфейса для извещения о происшед- ших с субъектом изменениях. По сравнению с командой в наблюдателе связь
между отправителем и получателем слабее, поскольку у субъекта может быть много наблюдателей и их число даже может меняться во время выполнения.
aSubject
(отправитель)
anObserver
(получатель)
anObserver
(получатель)
anObserver
(получатель)
Update()
Update()
Update()
Интерфейсы субъекта и наблюдателя в паттерне наблюдатель предназначены для передачи информации об изменениях. Стало быть, этот паттерн лучше всего подходит для разделения объектов в случае, когда между ними есть зависимость по данным.
Паттерн посредник разделяет объекты, заставляя их ссылаться друг на друга косвенно, через объект-посредник.
aColleague
(отправитель/получатель)
aMediator aColleague
(отправитель/получатель)
aColleague
(отправитель/получатель)
400
Глава 5. Паттерны поведения
Объект-посредник распределяет запросы между объектами-коллегами и централизует обмен информацией между ними. Таким образом, коллеги могут «общаться» между собой только с помощью интерфейса посредника.
Поскольку этот интерфейс фиксирован, посредник может реализовать собственную схему диспетчеризации для большей гибкости. Разрешается кодировать запросы и упаковывать аргументы так, что коллеги смогут за- прашивать выполнение операций из заранее неизвестного множества.
Паттерн посредник часто способствует уменьшению числа подклассов в си- стеме, поскольку централизует весь обмен информацией в одном классе, вместо того чтобы распределять его по подклассам. С другой стороны, си- туативные схемы диспетчеризации снижают безопасность типов.
Наконец, паттерн цепочка обязанностей отделяет отправителя от получателя за счет передачи запроса по цепочке потенциальных получателей.
aClient
(отправитель)
aHandler
(получатель)
aHandler
(получатель)
aHandler
(получатель)
HandleHelp()
HandleHelp()
HandleHelp()
Поскольку интерфейс между отправителями и получателями фиксирован, то цепочка обязанностей также может нуждаться в специализированной схеме диспетчеризации. Поэтому она обладает теми же недостатками с точки зре- ния безопасности типов, что и посредник. Цепочка обязанностей — хороший способ разделить отправителя и получателя в случае, если она уже является частью структуры системы, а один объект из группы может принять на себя обязанность обработать запрос. Данный паттерн повышает гибкость и за счет того, что цепочку можно легко изменить или расширить.
РЕЗЮМЕ
За немногочисленными исключениями паттерны поведения дополняют и усиливают друг друга. Например, класс в цепочке обязанностей, скорее
Обсуждение паттернов поведения
401
всего, будет содержать хотя бы один шаблонный метод (373). Он может пользоваться примитивными операциями, чтобы определить, должен ли объ- ект обработать запрос сам, а также в случае необходимости выбрать объект, которому следует переадресовать запрос. Цепочка может применять паттерн команда для представления запросов в виде объектов. Зачастую интерпре- татор (287) пользуется паттерном состояние для определения контекстов синтаксического разбора. Иногда итератор обходит агрегат, а посетитель выполняет операцию с каждым его элементом.
Паттерны поведения хорошо сочетаются и с другими паттернами. Напри- мер, система, в которой применяется паттерн компоновщик (196), время от времени использует посетителя для выполнения операций над компонентами, а также задействует цепочку обязанностей, чтобы обеспечить компонентам доступ к глобальным свойствам через их родителя. Бывает, что в системе применяется и паттерн декоратор (209) для переопределения некоторых свойств частей композиции. А паттерн наблюдатель может связать структуры разных объектов, тогда как паттерн состояние позволит компонентам из- менять свое поведение при изменении состояния. Сама композиция может быть создана с применением строителя (124) и рассматриваться как прототип
(146) какой-то другой частью системы.
Это характерно для хорошо спроектированных объектно-ориентированных систем: внешне они похожи на собрание многочисленных паттернов, но вовсе не потому, что их проектировщики мыслили именно такими категориями.
Композиция на уровне паттернов, а не классов или объектов, позволяет до- биться той же синергии, но с меньшими усилиями.