Паттерны поведения связаны с алгоритмами и распределением обязанностей между объектами. Руечь в них идет не только о самих объектах и классах, но и о типичных способах взаимодействия. Паттерны поведения характеризуют сложный поток управления, который трудно проследить во время выполнения программы. Внимание акцентировано не на потоке управления как таковом, а на связях между объектами.
В паттернах поведения уровня класса используется наследование – чтобы распределить поведение между разными классами. В этой главе описано два таких паттерна. Из них более простым и широко распространенным является шаблонный метод, который представляет собой абстрактное определение алгоритма. Алгоритм здесь определяется пошагово. На каждом шаге вызывается либо примитивная, либо абстрактная операция. Алгоритм «обрастает мясом» за счет подклассов, где определены абстрактные операции. Другой паттерн поведения уровня класса – интерпретатор, который представляет грамматику языка в виде иерархии классов и реализует интерпретатор как последовательность операций над экземплярами этих классов.
В паттернах поведения уровня объектов используется не наследование, а композиция. Некоторые из них описывают, как с помощью кооперации множество равноправных объектов справляется с задачей, которая ни одному из них не под силу. Важно здесь то, как объекты получают информацию о существовании друг друга. Объекты-коллеги могут хранить ссылки друг на друга, но это увеличит степень связанности системы. При максимальной степени связанности каждому объекту пришлось бы иметь информацию обо всех остальных. Эту проблему решает паттерн посредник. Посредник, находящийся между объектами-коллегами, обеспечивает косвенность ссылок, необходимую для разрывания лишних связей.
Паттерн цепочка обязанностей позволяет и дальше уменьшать степень связанности. Он дает возможность посылать запросы объекту не напрямую, а по цепочке «объектов-кандидатов». Запрос может выполнить любой «кандидат», если это допустимо в текущем состоянии выполнения программы. Число кандидатов заранее не определено, а подбирать участников можно во время выполнения.
Паттерн наблюдатель определяет и отвечает за зависимости между объектами. Классический пример наблюдателя встречается в схеме модель/вид/контроллер языка Smalltalk, где все виды модели уведомляются о любых изменениях ее состояния.
Прочие паттерны поведения связаны с инкапсуляцией поведения в объекте
и делегированием ему запросов. Паттерн стратегия инкапсулирует алгоритм объекта, упрощая его спецификацию и замену. Паттерн команда инкапсулирует запрос
в виде объекта, который можно передавать как параметр, хранить в списке истории или использовать как-то иначе. Паттерн состояние инкапсулирует состояние объекта таким образом, что при изменении состояния объект может изменять поведение. Паттерн посетитель инкапсулирует поведение, которое в противном случае пришлось бы распределять между классами, а паттерн итератор абстрагирует способ доступа и обхода объектов из некоторого агрегата.
Цепочка обязанностей – паттерн поведения объектов.
Позволяет избежать привязки отправителя запроса к его получателю, давая шанс обработать запрос нескольким объектам. Связывает объекты-получатели
в цепочку и передает запрос вдоль этой цепочки, пока его не обработают.
Рассмотрим контекстно-зависимую оперативную справку в графическом интерфейсе пользователя, который может получить дополнительную информацию по любой части интерфейса, просто щелкнув на ней мышью. Содержание справки зависит от того, какая часть интерфейса и в каком контексте выбрана. Например, справка по кнопке в диалоговом окне может отличаться от справки по аналогичной кнопке в главном окне приложения. Если для некоторой части интерфейса справки нет, то система должна показать информацию о ближайшем контексте,
в котором она находится, например о диалоговом окне в целом.
Поэтому естественно было бы организовать справочную информацию от более конкретных разделов к более общим. Кроме того, ясно, что запрос на получение справки обрабатывается одним из нескольких объектов пользовательского интерфейса, каким именно – зависит от контекста и имеющейся в наличии информации.
Проблема в том, что объект, инициирующий запрос (например, кнопка), не располагает информацией о том, какой объект в конечном итоге предоставит справку. Нам необходим какой-то способ отделить кнопку-инициатор запроса от объектов, владеющих справочной информацией. Как этого добиться, показывает паттерн цепочка обязанностей.
Идея заключается в том, чтобы разорвать связь между отправителями и получателями, дав возможность обработать запрос нескольким объектам. Запрос перемещается по цепочке объектов, пока один из них не обработает его.
Первый объект в цепочке получает запрос и либо обрабатывает его сам, либо направляет следующему кандидату в цепочке, который ведет себя точно так же.
У объекта, отправившего запрос, отсутствует информация об обработчике. Мы говорим, что у запроса есть анонимный получатель (implicit receiver).
Предположим, что пользователь запрашивает справку по кнопке Print (печать). Она находится в диалоговом окне PrintDialog, содержащем информацию об объекте приложения, которому принадлежит (см. предыдущую диаграмму объектов). На представленной диаграмме взаимодействий показано, как запрос на получение справки перемещается по цепочке.
В данном случае ни кнопка aPrintButton, ни окно aPrintDialog не обрабатывают запрос, он достигает объекта anApplication, который может его обработать или игнорировать. У клиента, инициировавшего запрос, нет прямой ссылки на объект, который его в конце концов выполнит.
Чтобы отправить запрос по цепочке и гарантировать анонимность получателя, все объекты в цепочке имеют единый интерфейс для обработки запросов и для доступа к своему преемнику (следующему объекту в цепочке). Например, в системе оперативной справки можно было бы определить класс HelpHandler (предок классов всех объектов-кандидатов или подмешиваемый класс (mixin class))
с операцией HandleHelp. Тогда классы, которые будут обрабатывать запрос, смогут его передать своему родителю.
Для обработки запросов на получение справки классы Button, Dialog
и Application пользуются операциями HelpHandler. По умолчанию операция HandleHelp просто перенаправляет запрос своему преемнику. В подклассах эта операция замещается, так что при благоприятных обстоятельствах может выдаваться справочная информация. В противном случае запрос отправляется дальше посредством реализации по умолчанию.
Используйте цепочку обязанностей, когда:
• есть более одного объекта, способного обработать запрос, причем настоящий обработчик заранее неизвестен и должен быть найден автоматически;
• вы хотите отправить запрос одному из нескольких объектов, не указывая явно, какому именно;
• набор объектов, способных обработать запрос, должен задаваться динамически.
Типичная структура объектов.
• Handler (HelpHandler) – обработчик:
– определяет интерфейс для обработки запросов;
– (необязательно) реализует связь с преемником;
• ConcreteHandler (PrintButton, PrintDialog) – конкретный обработчик:
– обрабатывает запрос, за который отвечает;
– имеет доступ к своему преемнику;
– если ConcreteHandler способен обработать запрос, то так и делает, если не может, то направляет его – его своему преемнику;
• Client – клиент:
– отправляет запрос некоторому объекту ConcreteHandler в цепочке.
Когда клиент инициирует запрос, он продвигается по цепочке, пока некоторый объект ConcreteHandler не возьмет на себя ответственность за его обработку.
Паттерн цепочка обязанностей имеет следующие достоинства и недостатки:
• ослабление связанности. Этот паттерн освобождает объект от необходимости «знать», кто конкретно обработает его запрос. Отправителю и получателю ничего неизвестно друг о друге, а включенному в цепочку объекту – о структуре цепочки.
Таким образом, цепочка обязанностей помогает упростить взаимосвязи между объектами. Вместо того чтобы хранить ссылки на все объекты, которые могут стать получателями запроса, объект должен располагать информацией лишь о своем ближайшем преемнике;
• дополнительная гибкость при распределении обязанностей между объектами. Цепочка обязанностей позволяет повысить гибкость распределения обязанностей между объектами. Добавить или изменить обязанности по обработке запроса можно, включив в цепочку новых участников или изменив ее каким-то другим образом. Этот подход можно сочетать со статическим порождением подклассов для создания специализированных обработчиков;
• получение не гарантировано. Поскольку у запроса нет явного получателя, то нет и гарантий, что он вообще будет обработан: он может достичь конца цепочки и пропасть. Необработанным запрос может оказаться и в случае неправильной конфигурации цепочки.
При рассмотрении цепочки обязанностей следует обратить внимание на следующие моменты:
• реализация цепочки преемников. Есть два способа реализовать такую цепочку:
– определить новые связи (обычно это делается в классе Handler, но можно и в ConcreteHandler);
– использовать существующие связи.
До сих пор в наших примерах определялись новые связи, однако можно воспользоваться уже имеющимися ссылками на объекты для формирования цепочки преемников. Например, ссылка на родителя в иерархии «часть-целое» может заодно определять и преемника «части». В структуре виджетов такие связи тоже могут существовать. В разделе, посвященном паттерну компоновщик, ссылки на родителей обсуждаются более подробно.
Существующие связи можно использовать, когда они уже поддерживают нужную цепочку. Тогда мы избежим явного определения новых связей и сэкономим память. Но если структура не отражает устройства цепочки обязанностей, то уйти от определения избыточных связей не удастся;
• соединение преемников. Если готовых ссылок, пригодных для определения цепочки, нет, то их придется ввести. В таком случае класс Handler не только определяет интерфейс запросов, но еще и хранит ссылку на преемника. Следовательно у обработчика появляется возможность определить реализацию операции HandleRequest по умолчанию – перенаправление запроса преемнику (если таковой существует). Если подкласс ConcreteHandler не заинтересован в запросе, то ему и не надо замещать эту операцию, поскольку по умолчанию запрос как раз и отправляется дальше.
Вот пример базового класса HelpHandler, в котором хранится указатель на преемника:
class HelpHandler {
public:
HelpHandler(HelpHandler* s) : _successor(s) { }
virtual void HandleHelp();
private:
HelpHandler* _successor;
};
void HelpHandler::HandleHelp () {
if (_successor) {
_successor->HandleHelp();
}
}
• представление запросов. Представлять запросы можно по-разному. В простейшей форме, например в случае класса HandleHelp, запрос жестко кодируется как вызов некоторой операции. Это удобно и безопасно, но переадресовывать тогда можно только фиксированный набор запросов, определенных
в классе Handler.
Альтернатива – использовать одну функцию-обработчик, которой передается код запроса (скажем, целое число или строка). Так можно поддержать заранее неизвестное число запросов. Единственное требование состоит в том, что отправитель и получатель должны договориться о способе кодирования запроса.
Это более гибкий подход, но при реализации нужно использовать условные операторы для раздачи запросов по их коду. Кроме того, не существует безопасного с точки зрения типов способа передачи параметров, поэтому упаковывать и распаковывать их приходится вручную. Очевидно, что это не так безопасно, как прямой вызов операции.
Чтобы решить проблему передачи параметров, допустимо использовать отдельные объекты-запросы, в которых инкапсулированы параметры запроса. Класс Request может представлять некоторые запросы явно, а их новые типы описываются в подклассах. Подкласс может определить другие параметры. Обработчик должен иметь информацию о типе запроса (какой именно подкласс Request используется), чтобы разобрать эти параметры.
Для идентификации запроса в классе Request можно определить функцию доступа, которая возвращает идентификатор класса. Вместо этого получатель мог бы воспользоваться информацией о типе, доступной во время выполнения, если язык программирования поддерживает такую возможность.
Приведем пример функции диспетчеризации, в которой используются объекты для идентификации запросов. Операция GetKind, указанная в базовом классе Request, определяет вид запроса:
void Handler::HandleRequest (Request* theRequest) {
switch (theRequest->GetKind()) {
case Help:
// привести аргумент к походящему типу
HandleHelp((HelpRequest*) theRequest);
break;
case Print:
HandlePrint((PrintRequest*) theRequest);
// ...
break;
default:
// ...
break;
}
}
Подклассы могут расширить схему диспетчеризации, переопределив операцию HandleRequest. Подкласс обрабатывает лишь те запросы, в которых заинтересован, а остальные отправляет родительскому классу. В этом случае подкласс именно расширяет, а не замещает операцию HandleRequest. Подкласс ExtendedHandler расширяет операцию HandleRequest, определенную в классе Handler, следующим образом:
class ExtendedHandler : public Handler {
public:
virtual void HandleRequest(Request* theRequest);
// ...
};
void ExtendedHandler::HandleRequest (Request* theRequest) {
switch (theRequest->GetKind()) {
case Preview:
// обработать запрос Preview
break;
default:
// дать классу Handler возможность обработать
// остальные запросы
Handler::HandleRequest(theRequest);
}
}
• автоматическое перенаправление запросов в языке Smalltalk. С этой целью можно использовать механизм doesNotUnderstand. Сообщения, не имеющие соответствующих методов, перехватываются реализацией doesNotUnderstand, которая может быть замещена для перенаправления сообщения объекту-преемнику. Поэтому осуществлять перенаправление вручную необязательно. Класс обрабатывает только запросы, в которых заинтересован, и ожидает, что механизм doesNotUnderstand выполнит все остальное.
В следующем примере иллюстрируется, как с помощью цепочки обязанностей можно обработать запросы к описанной выше системе оперативной справки. Запрос на получение справки – это явная операция. Мы воспользуемся уже имеющимися в иерархии виджетов ссылками для перемещения запросов по цепочке от одного виджета к другому и определим в классе Handler отдельную ссылку, чтобы можно было передать запрос включенным в цепочку объектам, не являющимся виджетами.
Класс HelpHandler определяет интерфейс для обработки запросов на получение справки. В нем хранится раздел справки (по умолчанию пустой) и ссылка на преемника в цепочке обработчиков. Основной операцией является HandleHelp, которая замещается в подклассах. HasHelp – это вспомогательная операция, проверяющая, ассоциирован ли с объектом какой-нибудь раздел:
typedef int Topic;
const Topic NO_HELP_TOPIC = -1;
class HelpHandler {
public:
HelpHandler(HelpHandler* = 0, Topic = NO_HELP_TOPIC);
virtual bool HasHelp();
virtual void SetHandler(HelpHandler*, Topic);
virtual void HandleHelp();
private:
HelpHandler* _successor;
Topic _topic;
};
HelpHandler::HelpHandler (
HelpHandler* h, Topic t
) : _successor(h), _topic(t) { }
bool HelpHandler::HasHelp () {
return _topic != NO_HELP_TOPIC;
}
void HelpHandler::HandleHelp () {
if (_successor != 0) {
_successor->HandleHelp();
}
}
Все виджеты – подклассы абстрактного класса Widget, который, в свою очередь, является подклассом HelpHandler, так как со всеми элементами пользовательского интерфейса может быть ассоциирована справочная информация. (Можно было, конечно, построить реализацию и на основе подмешиваемого класса.)
class Widget : public HelpHandler {
protected:
Widget(Widget* parent, Topic t = NO_HELP_TOPIC);
private:
Widget* _parent;
};
Widget::Widget (Widget* w, Topic t) : HelpHandler(w, t) {
_parent = w;
}
В нашем примере первым обработчиком в цепочке является кнопка. Класс Button – это подкласс Widget. Конструктор класса Button принимает два параметра – ссылку на виджет, в котором он находится, и раздел справки:
class Button : public Widget {
public:
Button(Widget* d, Topic t = NO_HELP_TOPIC);
virtual void HandleHelp();
// операции класса Widget, которые Button замещает...
};
Реализация HandleHelp в классе Button сначала проверяет, есть ли для кнопки справочная информация. Если разработчик не определил ее, то запрос отправляется преемнику с помощью операции HandleHelp класса HelpHandler. Если же информация есть, то кнопка ее отображает и поиск заканчивается:
Button::Button (Widget* h, Topic t) : Widget(h, t) { }
void Button::HandleHelp () {
if (HasHelp()) {
// предложить справку по кнопке
} else {
HelpHandler::HandleHelp();
}
}
Класс Dialog реализует аналогичную схему, только его преемником является не виджет, а произвольный обработчик запроса на справку. В нашем приложении таким преемником выступает экземпляр класса Application:
class Dialog : public Widget {
public:
Dialog(HelpHandler* h, Topic t = NO_HELP_TOPIC);
virtual void HandleHelp();
// операции класса Widget, которые Dialog замещает...
// ...
};
Dialog::Dialog (HelpHandler* h, Topic t) : Widget(0) {
SetHandler(h, t);
}
void Dialog::HandleHelp () {
if (HasHelp()) {
// предложить справку по диалоговому окну
} else {
HelpHandler::HandleHelp();
}
}
В конце цепочки находится экземпляр класса Application. Приложение – это не виджет, поэтому Application – прямой потомок класса HelpHandler. Если запрос на получение справки дойдет до этого уровня, то класс Application может выдать информацию о приложении в целом или предложить список разделов:
class Application : public HelpHandler {
public:
Application(Topic t) : HelpHandler(0, t) { }
virtual void HandleHelp();
// операции, относящиеся к самому приложению...
};
void Application::HandleHelp () {
// показать список разделов справки
}
Следующий код создает и связывает эти объекты. В данном случае рассматривается диалоговое окно Print, поэтому с объектами связаны разделы справки, касающиеся печати:
const Topic PRINT_TOPIC = 1;
const Topic PAPER_ORIENTATION_TOPIC = 2;
const Topic APPLICATION_TOPIC = 3;
Application* application = new Application(APPLICATION_TOPIC);
Dialog* dialog = new Dialog(application, PRINT_TOPIC);
Button* button = new Button(dialog, PAPER_ORIENTATION_TOPIC);
Мы можем инициировать запрос на получение справки, вызвав операцию HandleHelp для любого объекта в цепочке. Чтобы начать поиск с объекта кнопки, достаточно выполнить его операцию HandleHelp:
button->HandleHelp();
В этом примере кнопка обрабатывает запрос сразу же. Заметим, что класс HelpHandler можно было бы сделать преемником Dialog. Более того, его преемника можно изменять динамически. Вот почему, где бы диалоговое окно ни встретилось, вы всегда получите справочную информацию с учетом контекста.
Паттерн цепочка обязанностей используется в нескольких библиотеках классов для обработки событий, инициированных пользователем. Класс Handler в них называется по-разному, но идея всегда одна и та же: когда пользователь щелкает кнопкой мыши или нажимает клавишу, генерируется некоторое событие, которое распространяется по цепочке. В MacApp [App89] и ET++ [WGM88] класс называется EventHandler, в библиотеке TCL фирмы Symantec [Sym93b] Bureaucrat, а в библиотеке из системы NeXT [Add94] Responder.
В каркасе графических редакторов Unidraw определены объекты Command, которые инкапсулируют запросы к объектам Component и ComponentView [VL90]. Объекты Command – это запросы, которые компонент или вид компонента могут интерпретировать как команду на выполнение определенной операции. Это соответствует подходу «запрос как объект», описанному в разделе «Реализация». Компоненты и виды компонентов могут быть организованы иерархически. Как компонент, так и его вид могут перепоручать интерпретацию команды своему родителю, тот – своему родителю и так далее, то есть речь идет о типичной цепочке обязанностей.
В ET++ паттерн цепочка обязанностей применяется для обработки запросов на обновление графического изображения. Графический объект вызывает операцию InvalidateRect всякий раз, когда возникает необходимость обновить часть занимаемой им области. Но выполнить эту операцию самостоятельно графический объект не может, так как не имеет достаточной информации о своем контексте, например из-за того, что окружен такими объектами, как Scroller (полоса прокрутки) или Zoomer (лупа), которые преобразуют его систему координат. Это означает, что объект может быть частично невидим, так как он оказался за границей области прокрутки или изменился его масштаб. Поэтому реализация InvalidateRect по умолчанию переадресует запрос контейнеру, где находится соответствующий объект. Последний объект в цепочке обязанностей – экземпляр класса Window. Гарантируется, что к тому моменту, как Window получит запрос, недействительный прямоугольник будет трансформирован правильно. Window обрабатывает InvalidateRect, послав запрос интерфейсу оконной системы и требуя тем самым выполнить обновление.
Паттерн цепочка обязанностей часто применяется вместе с паттерном компоновщик. В этом случае родитель компонента может выступать в роли его преемника.
Команда – паттерн поведения объектов.
Инкапсулирует запрос как объект, позволяя тем самым задавать параметры клиентов для обработки соответствующих запросов, ставить запросы в очередь или протоколировать их, а также поддерживать отмену операций.
Action (действие), Transaction (транзакция).
Иногда необходимо посылать объектам запросы, ничего не зная о том, выполнение какой операции запрошено и кто является получателем. Например, в библиотеках для построения пользовательских интерфейсов встречаются такие объекты, как кнопки и меню, которые посылают запрос в ответ на действие пользователя. Но в саму библиотеку не заложена возможность обрабатывать этот запрос, так как только приложение, использующее ее, располагает информацией о том, что следует сделать. Проектировщик библиотеки не владеет никакой информацией о получателе запроса и о том, какие операции тот должен выполнить.
Паттерн команда позволяет библиотечным объектам отправлять запросы неизвестным объектам приложения, преобразовав сам запрос в объект. Этот объект можно хранить и передавать, как и любой другой. В основе списываемого паттерна лежит абстрактный класс Command, в котором объявлен интерфейс для выполнения операций. В простейшей своей форме этот интерфейс состоит из одной абстрактной операции Execute. Конкретные подклассы Command определяют пару «получатель-действие», сохраняя получателя в переменной экземпляра, и реализуют операцию Execute, так чтобы она посылала запрос. У получателя есть информация, необходимая для выполнения запроса.
С помощью объектов Command легко реализуются меню. Каждый пункт меню – это экземпляр класса MenuItem. Сами меню и все их пункты создает класс Application наряду со всеми остальными элементами пользовательского интерфейса. Класс Application отслеживает также открытые пользователем документы.
Приложение конфигурирует каждый объект MenuItem экземпляром конкретного подкласса Command. Когда пользователь выбирает некоторый пункт меню, ассоциированный с ним объект MenuItem вызывает Execute для своего объекта-команды, а Execute выполняет операцию. Объекты MenuItem не имеют информации, какой подкласс класса Command они используют. Подклассы Command хранят информацию о получателе запроса и вызывают одну или несколько операций этого получателя.
Например, подкласс PasteCommand поддерживает вставку текста из буфера обмена в документ. Получателем для PasteCommand является Document, который был передан при создании объекта. Операция Execute вызывает операцию Paste документа-получателя.
Для подкласса OpenCommand операция Execute ведет себя по-другому: она запрашивает у пользователя имя документа, создает соответствующий объект Document, извещает о новом документе приложение-получатель и открывает этот документ.
Иногда объект MenuItem должен выполнить последовательность команд. Например, пункт меню для центрирования страницы стандартного размера можно было бы сконструировать сразу из двух объектов: CenterDocumentCommand
и NormalSizeCommand. Поскольку такое комбинирование команд – явление обычное, то мы можем определить класс MacroCommand, позволяющий объекту MenuItem выполнять произвольное число команд. MacroCommand – это конкретный подкласс класса Command, который просто выполняет последовательность команд. У него нет явного получателя, поскольку для каждой команды определен свой собственный.
Обратите внимание, что в каждом из приведенных примеров паттерн команда отделяет объект, инициирующий операцию, от объекта, который «знает», как ее выполнить. Это позволяет добиться высокой гибкости при проектировании пользовательского интерфейса. Пункт меню и кнопка одновременно могут быть ассоциированы в приложении с некоторой функцией, для этого достаточно приписать обоим элементам один и тот же экземпляр конкретного подкласса класса Command. Мы можем динамически подменять команды, что очень полезно для реализации контекстно-зависимых меню. Можно также поддержать сценарии, если компоновать простые команды в более сложные. Все это выполнимо потому, что объект, инициирующий запрос, должен располагать информацией лишь о том, как его отправить, а не о том, как его выполнить.
Используйте паттерн команда, когда хотите:
• параметризовать объекты выполняемым действием, как в случае с пунктами меню MenuItem. В процедурном языке такую параметризацию можно выразить с помощью функции обратного вызова, то есть такой функции, которая регистрируется, чтобы быть вызванной позднее. Команды представляют собой объектно-ориентированную альтернативу функциям обратного вызова;
• определять, ставить в очередь и выполнять запросы в разное время. Время жизни объекта Command необязательно должно зависеть от времени жизни исходного запроса. Если получателя запроса удается реализовать так, чтобы он не зависел от адресного пространства, то объект-команду можно передать другому процессу, который займется его выполнением;
• поддержать отмену операций. Операция Execute объекта Command может сохранить состояние, необходимое для отката действий, выполненных командой. В этом случае в интерфейсе класса Command должна быть дополнительная операция Unexecute, которая отменяет действия, выполненные предшествующим обращением к Execute. Выполненные команды хранятся в списке истории. Для реализации произвольного числа уровней отмены и повтора команд нужно обходить этот список соответственно в обратном
и прямом направлениях, вызывая при посещении каждого элемента команду Unexecute или Execute;
• поддержать протоколирование изменений, чтобы их можно было выполнить повторно после аварийной остановки системы. Дополнив интерфейс класса Command операциями сохранения и загрузки, вы сможете вести протокол изменений во внешней памяти. Для восстановления после сбоя нужно будет загрузить сохраненные команды с диска и повторно выполнить их с помощью операции Execute;
• структурировать систему на основе высокоуровневых операций, построенных из примитивных. Такая структура типична для информационных систем, поддерживающих транзакции. Транзакция инкапсулирует набор изменений данных. Паттерн команда позволяет моделировать транзакции. У всех команд есть общий интерфейс, что дает возможность работать одинаково
с любыми транзакциями. С помощью этого паттерна можно легко добавлять в систему новые виды транзакций.
• Command – команда:
– объявляет интерфейс для выполнения операции;
• ConcreteCommand (PasteCommand, OpenCommand) – конкретная команда:
– определяет связь между объектом-получателем Receiver и действием;
– реализует операцию Execute путем вызова соответствующих операций объекта Receiver;
• Client (Application) – клиент:
– создает объект класса ConcreteCommand и устанавливает его получателя;
• Invoker (MenuItem) – инициатор:
– обращается к команде для выполнения запроса;
• Receiver (Document, Application) – получатель:
– располагает информацией о способах выполнения операций, необходимых для удовлетворения запроса. В роли получателя может выступать любой класс.
• клиент создает объект ConcreteCommand и устанавливает для него получателя;
• инициатор Invoker сохраняет объект ConcreteCommand;
• инициатор отправляет запрос, вызывая операцию команды Execute. Если поддерживается отмена выполненных действий, то ConcreteCommand перед вызовом Execute сохраняет информацию о состоянии, достаточную для выполнения отката;
• объект ConcreteCommand вызывает операции получателя для выполнения запроса.
На следующей диаграмме видно, как Command разрывает связь между инициатором и получателем (а также запросом, который должен выполнить последний).
Результаты применения паттерна команда таковы:
• команда разрывает связь между объектом, инициирующим операцию, и объектом, имеющим информацию о том, как ее выполнить;
• команды – это самые настоящие объекты. Допускается манипулировать ими и расширять их точно так же, как в случае с любыми другими объектами;
• из простых команд можно собирать составные, например класс MacroCommand, рассмотренный выше. В общем случае составные команды описываются паттерном компоновщик;
• добавлять новые команды легко, поскольку никакие существующие классы изменять не нужно.
При реализации паттерна команда следует обратить внимание на следующие аспекты:
• насколько «умной» должна быть команда. У команды может быть широкий круг обязанностей. На одном полюсе стоит простое определение связи между получателем и действиями, которые нужно выполнить для удовлетворения запроса. На другом – реализация всего самостоятельно, без обращения за помощью к получателю. Последний вариант полезен, когда вы хотите определить команды, не зависящие от существующих классов, когда подходящего получателя не существует или когда получатель команде точно не известен. Например, команда, создающая новое окно приложения, может не понимать, что именно она создает, а трактовать окно, как любой другой объект. Где-то посередине между двумя крайностями находятся команды, обладающие достаточной информацией для динамического обнаружения своего получателя;
• поддержка отмены и повтора операций. Команды могут поддерживать отмену и повтор операций, если имеется возможность отменить результаты выполнения (например, операцию Unexecute или Undo). В классе ConcreteCommand может сохраняться необходимая для этого дополнительная информация,
в том числе:
– объект-получатель Receiver, который выполняет операции в ответ на запрос;
– аргументы операции, выполненной получателем;
– исходные значения различных атрибутов получателя, которые могли измениться в результате обработки запроса. Получатель должен предоставить операции, позволяющие команде вернуться в исходное состояние.
Для поддержки всего одного уровня отмены приложению достаточно сохранять только последнюю выполненную команду. Если же нужны многоуровневые отмена и повтор операций, то придется вести список истории выполненных команд. Максимальная длина этого списка и определяет число уровней отмены и повтора. Проход по списку в обратном направлении и откат результатов всех встретившихся по пути команд отменяет их действие; проход в прямом направлении и выполнение встретившихся команд приводит к повтору действий.
Команду, допускающую отмену, возможно, придется скопировать перед помещением в список истории. Дело в том, что объект команды, использованный для доставки запроса, скажем от пункта меню MenuItem, позже мог быть использован для других запросов. Поэтому копирование необходимо, чтобы определить разные вызовы одной и той же команды, если ее состояние при любом вызове может изменяться.
Например, команда DeleteCommand, которая удаляет выбранные объекты, при каждом вызове должна сохранять разные наборы объектов. Поэтому объект DeleteCommand необходимо скопировать после выполнения, а копию поместить в список истории. Если в результате выполнения состояние команды никогда не изменяется, то копировать не нужно – в список достаточно поместить лишь ссылку на команду. Команды, которые обязательно нужно копировать перед помещением в список истории, ведут себя подобно прототипам (см. описание паттерна прототип);
• как избежать накопления ошибок в процессе отмены. При обеспечении надежного, сохраняющего семантику механизма отмены и повтора может возникнуть проблема гистерезиса. При выполнении, отмене и повторе команд иногда накапливаются ошибки, в результате чего состояние приложения оказывается отличным от первоначального. Поэтому порой необходимо сохранять в команде больше информации, дабы гарантировать, что объекты будут целиком восстановлены. Чтобы предоставить команде доступ к этой информации, не раскрывая внутреннего устройства объектов, можно воспользоваться паттерном хранитель;
• применение шаблонов в C++. Для команд, которые не допускают отмену
и не имеют аргументов, в языке C++ можно воспользоваться шаблонами, чтобы не создавать подкласс класса Command для каждой пары действие-получатель. Как это сделать, мы продемонстрируем в разделе «Пример кода».
Приведенный ниже код на языке C++ дает представление о реализации классов Command, обсуждавшихся в разделе «Мотивация». Мы определим классы OpenCommand, PasteCommand и MacroCommand. Сначала абстрактный класс Command:
class Command {
public:
virtual ~Command();
virtual void Execute() = 0;
protected:
Command();
};
Команда OpenCommand открывает документ, имя которому задает пользователь. Конструктору OpenCommand передается объект Application. Функция AskUser запрашивает у пользователя имя открываемого документа:
class OpenCommand : public Command {
public:
OpenCommand(Application*);
virtual void Execute();
protected:
virtual const char* AskUser();
private:
Application* _application;
char* _response;
};
OpenCommand::OpenCommand (Application* a) {
_application = a;
}
void OpenCommand::Execute () {
const char* name = AskUser();
if (name != 0) {
Document* document = new Document(name);
_application->Add(document);
document->Open();
}
}
Команде PasteCommand в конструкторе передается объект Document, являющийся получателем:
class PasteCommand : public Command {
public:
PasteCommand(Document*);
virtual void Execute();
private:
Document* _document;
};
PasteCommand::PasteCommand (Document* doc) {
_document = doc;
}
void PasteCommand::Execute () {
_document->Paste();
}
В случае с простыми командами, не допускающими отмены и не требующими аргументов, можно воспользоваться шаблоном класса для параметризации получателя. Определим для них шаблонный подкласс SimpleCommand, который параметризуется типом получателя Receiver и хранит связь между объектом-получателем и действием, представленным указателем на функцию-член:
template <class Receiver>
class SimpleCommand : public Command {
public:
typedef void (Receiver::* Action)();
SimpleCommand(Receiver* r, Action a) :
_receiver(r), _action(a) { }
virtual void Execute();
private:
Action _action;
Receiver* _receiver;
};
Конструктор сохраняет информацию о получателе и действии в соответствующих переменных экземпляра. Операция Execute просто выполняет действие по отношению к получателю:
template <class Receiver>
void SimpleCommand<Receiver>::Execute () {
(_receiver->*_action)();
}
Чтобы создать команду, которая вызывает операцию Action для экземпляра класса MyClass, клиент пишет следующий код:
MyClass* receiver = new MyClass;
// ...
Command* aCommand =
new SimpleCommand<MyClass>(receiver, &MyClass::Action);
// ...
aCommand->Execute();
Имейте в виду, что такое решение годится только для простых команд. Для более сложных команд, которые отслеживают не только получателей, но и аргументы и, возможно, состояние, необходимое для отмены операции, приходится порождать подклассы от класса Command.
Класс MacroCommand управляет выполнением последовательности подкоманд и предоставляет операции для добавления и удаления подкоманд. Задавать получателя не требуется, так как в каждой подкоманде уже определен свой получатель:
class MacroCommand : public Command {
public:
MacroCommand();
virtual ~MacroCommand();
virtual void Add(Command*);
virtual void Remove(Command*);
virtual void Execute();
private:
List<Command*>* _cmds;
};
Основой класса MacroCommand является его функция-член Execute. Она обходит все подкоманды и для каждой вызывает ее операцию Execute:
void MacroCommand::Execute () {
ListIterator<Command*> i(_cmds);
for (i.First(); !i.IsDone(); i.Next()) {
Command* c = i.CurrentItem();
c->Execute();
}
}
Обратите внимание, что если бы в классе MacroCommand была реализована операция отмены Unexecute, то при ее выполнении подкоманды должны были бы отменяться в порядке, обратном тому, который применяется в реализации Execute.
Наконец, в классе MacroCommand должны быть операции для добавления и удаления подкоманд:
void MacroCommand::Add (Command* c) {
_cmds->Append(c);
}
void MacroCommand::Remove (Command* c) {
_cmds->Remove(c);
}
Быть может, впервые паттерн команда появился в работе Генри Либермана (Henry Lieberman) [Lie85]. В системе MacApp [App89] команды широко применяются для реализации допускающих отмену операций. В ET++ [WGM88], InterViews [LCI+92] и Unidraw [VL90] также имеются классы, описываемые паттерном команда. Так, в библиотеке InterViews определен абстрактный класс Action, который определяет всю функциональность команд. Есть и шаблон ActionCallback, параметризованный действием Action, который автоматически инстанцирует подклассы команд.
В библиотеке классов THINK [Sym93b] также используются команды для поддержки отмены операций. В THINK команды называются задачами (Tasks). Объекты Task передаются по цепочке обязанностей, пока не будут кем-то обработаны.
Объекты команд в каркасе Unidraw уникальны в том отношении, что могут вести себя подобно сообщениям. В Unidraw команду можно послать другому объекту для интерпретации, результат которой зависит от объекта-получателя. Более того, сам получатель может делегировать интерпретацию следующему объекту, обычно своему родителю. Это напоминает паттерн цепочка обязанностей. Таким образом, в Unidraw получатель вычисляется, а не хранится. Механизм интерпретации в Unidraw использует информацию о типе, доступную во время выполнения.
Джеймс Коплиен описывает, как в языке C++ реализуются функторы – объекты, ведущие себя, как функции [Cop92]. За счет перегрузки оператора вызова operator() он становится более понятным. Смысл паттерна команда в другом – он устанавливает и поддерживает связь между получателем и функцией (то есть действием), а не просто функцию.
Паттерн компоновщик можно использовать для реализации макрокоманд.
Паттерн хранитель иногда проектируется так, что сохраняет состояние команды, необходимое для отмены ее действия.
Команда, которую нужно копировать перед помещением в список истории, ведет себя, как прототип.
Интерпретатор – паттерн поведения классов.
Для заданного языка определяет представление его грамматики, а также интерпретатор предложений этого языка.
Если некоторая задача возникает часто, то имеет смысл представить ее конкретные проявления в виде предложений на простом языке. Затем можно будет создать интерпретатор, который решает задачу, анализируя предложения этого языка.
Например, поиск строк по образцу – весьма распространенная задача. Регулярные выражения – это стандартный язык для задания образцов поиска. Вместо того чтобы программировать специализированные алгоритмы для сопоставления строк с каждым образцом, не проще ли построить алгоритм поиска так, чтобы он мог интерпретировать регулярное выражение, описывающее множество строк-образцов?
Паттерн интерпретатор определяет грамматику простого языка, представляет предложения на этом языке и интерпретирует их. Для приведенного примера паттерн описывает определение грамматики и интерпретации языка регулярных выражений.
Предположим, что они описаны следующей грамматикой:
expression ::= literal | alternation | sequence | repetition |
‘(‘ expression ‘)’
alternation ::= expression ‘|’ expression
sequence ::= expression ‘&’ expression
repetition ::= expression ‘*’
literal ::= ‘a’ | ‘b’ | ‘c’ | ... { ‘a’ | ‘b’ | ‘c’ | ... }*
где expression – это начальный символ, а literal – терминальный символ, определяющий простые слова.
Паттерн интерпретатор использует класс для представления каждого правила грамматики. Символы в правой части правила – это переменные экземпляров таких классов. Для представления приведенной выше грамматики требуется пять классов: абстрактный класс RegularExpression и четыре его подкласса LiteralExpression, AlternationExpression, SequenceExpression
и RepetitionExpression. В последних трех подклассах определены переменные для хранения подвыражений.
Каждое регулярное выражение, описываемое этой грамматикой, представляется в виде абстрактного синтаксического дерева, в узлах которого находятся экземпляры этих классов. Например, дерево
представляет выражение
raining & (dogs | cats) *
Мы можем создать интерпретатор регулярных выражений, определив в каждом подклассе RegularExpression операцию Interpret, принимающую в качестве аргумента контекст, где нужно интерпретировать выражение. Контекст состоит из входной строки и информации о том, как далеко по ней мы уже продвинулись. В каждом подклассе RegularExpression операция Interpret производит сопоставление с оставшейся частью входной строки. Например:
• LiteralExpression проверяет, соответствует ли входная строка литералу, который хранится в объекте подкласса;
• AlternationExpression проверяет, соответствует ли строка одной из альтернатив;
• RepetitionExpression проверяет, если в строке повторяющиеся вхождения выражения, совпадающего с тем, что хранится в объекте.
И так далее.
Используйте паттерн интерпретатор, когда есть язык для интерпретации, предложения которого можно представить в виде абстрактных синтаксических деревьев. Лучше всего этот паттерн работает, когда:
• грамматика проста. Для сложных грамматик иерархия классов становится слишком громоздкой и неуправляемой. В таких случаях лучше применять генераторы синтаксических анализаторов, поскольку они могут интерпретировать выражения, не строя абстрактных синтаксических деревьев, что экономит память, а возможно, и время;
• эффективность не является главным критерием. Наиболее эффективные интерпретаторы обычно не работают непосредственно с деревьями, а сначала транслируют их в другую форму. Так, регулярное выражение часто преобразуют в конечный автомат. Но даже в этом случае сам транслятор можно реализовать с помощью паттерна интерпретатор.
• AbstractExpression (RegularExpression) – абстрактное выражение:
– объявляет абстрактную операцию Interpret, общую для всех узлов в абстрактном синтаксическом дереве;
• TerminalExpression (LiteralExpression) – терминальное выражение:
– реализует операцию Interpret для терминальных символов грамматики;
– необходим отдельный экземпляр для каждого терминального символа
в предложении;
• NonterminalExpression (AlternationExpression, RepetitionExpression, SequenceExpressions) – нетерминальное выражение:
– по одному такому классу требуется для каждого грамматического правила R :: = R1 R2 ... Rn;
– хранит переменные экземпляра типа AbstractExpression для каждого символа от R1 до Rn;
– реализует операцию Interpret для нетерминальных символов грамматики. Эта операция рекурсивно вызывает себя же для переменных, представляющих R1, ... Rn;
• Context – контекст:
– содержит информацию, глобальную по отношению к интерпретатору;
• Client – клиент:
– строит (или получает в готовом виде) абстрактное синтаксическое дерево, представляющее отдельное предложение на языке с данной грамматикой. Дерево составлено из экземпляров классов NonterminalExpression
и TerminalExpression;
– вызывает операцию Interpret.
• клиент строит (или получает в готовом виде) предложение в виде абстрактного синтаксического дерева, в узлах которого находятся объекты классов NonterminalExpression и TerminalExpression. Затем клиент инициализирует контекст и вызывает операцию Interpret;
• в каждом узле вида NonterminalExpression через операции Interpret определяется операция Interpret для каждого подвыражения. Для класса TerminalExpression операция Interpret определяет базу рекурсии;
• операции Interpret в каждом узле используют контекст для сохранения и доступа к состоянию интерпретатора.
У паттерна интерпретатор есть следующие достоинства и недостатки:
• грамматику легко изменять и расширять. Поскольку для представления грамматических правил в паттерне используются классы, то для изменения или расширения грамматики можно применять наследование. Существующие выражения можно модифицировать постепенно, а новые определять как вариации старых;
• простая реализация грамматики. Реализации классов, описывающих узлы абстрактного синтаксического дерева, похожи. Такие классы легко кодировать, а зачастую их может автоматически сгенерировать компилятор или генератор синтаксических анализаторов;
• сложные грамматики трудно сопровождать. В паттерне интерпретатор определяется по меньшей мере один класс для каждого правила грамматики (для правил, определенных с помощью формы Бэкуса-Наура – BNF, может понадобиться и более одного класса). Поэтому сопровождение грамматики с большим числом правил иногда оказывается трудной задачей. Для ее решения могут быть применены другие паттерны (см. раздел «Реализация»). Но если грамматика очень сложна, лучше прибегнуть к другим методам, например воспользоваться генератором компиляторов или синтаксических анализаторов;
• добавление новых способов интерпретации выражений. Паттерн интерпретатор позволяет легко изменить способ вычисления выражений. Например, реализовать красивую печать выражения вместо проверки входящих в него типов можно, просто определив новую операцию в классах выражений. Если вам приходится часто создавать новые способы интерпретации выражений, подумайте о применении паттерна посетитель. Это поможет избежать изменения классов, описывающих грамматику.
У реализаций паттернов интерпретатор и компоновщик есть много общего. Следующие вопросы относятся только к интерпретатору:
• создание абстрактного синтаксического дерева. Паттерн интерпретатор не поясняет, как создавать дерево, то есть разбор выражения не входит в его задачу. Создать дерево разбора может таблично-управляемый или написанный вручную (обычно методом рекурсивного спуска) анализатор, а также сам клиент;
• определение операции Interpret. Определять операцию Interpret в классах выражений необязательно. Если создавать новые интерпретаторы приходится часто, то лучше воспользоваться паттерном посетитель и поместить операцию Interpret в отдельный объект-посетитель. Например, для грамматики языка программирования будет нужно определить много операций над абстрактными синтаксическими деревьями: проверку типов, оптимизацию, генерацию кода и т.д. Лучше, конечно, использовать посетителя и не определять эти операции в каждом классе грамматики;
• разделение терминальных символов с помощью паттерна приспособленец. Для грамматик, предложения которых содержат много вхождений одного
и того же терминального символа, может оказаться полезным разделение этого символа. Хорошим примером служат грамматики компьютерных программ, поскольку в них каждая переменная встречается в коде многократно. В примере из раздела «Мотивация» терминальный символ dog (для моделирования которого используется класс LiteralExpression) может попадаться много раз.
В терминальных узлах обычно не хранится информация о положении в абстрактном синтаксическом дереве. Необходимый для интерпретации контекст предоставляют им родительские узлы. Налицо различие между разделяемым (внутренним) и передаваемым (внешним) состояниями, так что вполне применим паттерн приспособленец.
Например, каждый экземпляр класса LiteralExpression для dog получает контекст, состоящий из уже просмотренной части строки. И каждый такой экземпляр делает в своей операции Interpret одно и то же – проверяет, содержит ли остаток входной строки слово dog, – безотносительно
к тому, в каком месте дерева этот экземпляр встречается.
Мы приведем два примера. Первый – законченная программа на Smalltalk для проверки того, соответствует ли данная последовательность регулярному выражению. Второй – программа на C++ для вычисления булевых выражений.
Программа сопоставления с регулярным выражением проверяет, является ли строка корректным предложением языка, определяемого этим выражением. Регулярное выражение определено следующей грамматикой:
expression ::= literal | alternation | sequence | repetition |
‘(‘ expression ‘)’
alternation ::= expression ‘|’ expression
sequence ::= expression ‘&’ expression
repetition ::= expression ‘repeat’
literal ::= ‘a’ | ‘b’ | ‘c’ | ... { ‘a’ | ‘b’ | ‘c’ | ... }*
Между этой грамматикой и той, что приведена в разделе «Мотивация», есть небольшие отличия. Мы слегка изменили синтаксис регулярных выражений, поскольку в Smalltalk символ * не может быть постфиксной операцией. Поэтому вместо него мы употребляем слово repeat. Например, регулярное выражение
((‘dog ‘ | ‘cat ‘) repeat & ‘weather’)
соответствует входной строке ‘dog dog cat weather’.
Для реализации программы сопоставления мы определим пять классов, упомянутых на стр. 237. В классе SequenceExpression есть переменные экземпляра expression1 и expression2 для хранения ссылок на потомков в дереве. Класс AlternationExpression хранит альтернативы в переменных экземпляра alternative1 и alternative2, а класс RepetitionExpression – повторяемое выражение в переменной экземпляра repetition. В классе LiteralExpression есть переменная экземпляра components для хранения списка объектов (скорее всего, символов), представляющих литеральную строку, которая должна соответствовать входной строке.
Операция match: реализует интерпретатор регулярных выражений. В каждом из классов, входящих в абстрактное синтаксическое дерево, эта операция реализована. Ее аргументом является переменная inputState, описывающая текущее состояние процесса сопоставления, то есть уже прочитанную часть входной строки.
Текущее состояние характеризуется множеством входных потоков, представляющим множество тех входных строк, которое регулярное выражение могло бы к настоящему моменту принять. (Это примерно то же, что регистрация всех состояний, в которых побывал бы эквивалентный конечный автомат, распознавший входной поток до данного места.)
Текущее состояние наиболее важно для операции repeat. Например, регулярному выражению
‘a’ repeat
интерпретатор сопоставил бы строки «a», «aa», «aaa» и т.д. А регулярному выражению
‘a’ repeat & ‘bc’
строки «abc», «aabc», «aaabc» и т.д. Но при наличии регулярного выражения
‘a’ repeat & ‘abc’
сопоставление входной строки «aabc» с подвыражением «’a’ repeat» дало бы два потока, один из которых соответствует одному входному символу, а другой – двум. Из них лишь поток, принявший один символ, может быть сопоставлен с остатком строки «abc».
Теперь рассмотрим определения match: для каждого класса, описывающего регулярное выражение. SequenceExpression производит сопоставление с каждым подвыражением в определенной последовательности. Обычно потоки ввода не входят в его состояние inputState.
match: inputState
^ expression2 match: (expression1 match: inputState).
AlternationExpression возвращает результат, объединяющий состояния всех альтернатив. Вот определение match: для этого случая:
match: inputState
| finalState |
finalState := alternative1 match: inputState.
finalState addAll: (alternative2 match: inputState).
^ finalState
Операция match: для RepetitionExpression пытается найти максимальное количество состояний, допускающих сопоставление:
match: inputState
| aState finalState |
aState := inputState.
finalState := inputState copy.
[aState isEmpty]
whileFalse:
[aState := repetition match: aState.
finalState addAll: aState].
^ finalState
На выходе этой операции мы обычно получаем больше состояний, чем на входе, поскольку RepetitionExpression может быть сопоставлено с одним, двумя или более вхождениями повторяющегося выражения во входную строку. В выходном состоянии представлены все возможные варианты, а решение о том, какое из состояний правильно, принимается последующими элементами регулярного выражения.
Наконец, операция match: для LiteralExpression сравнивает свои компоненты с каждым возможным входным потоком и оставляет только те из них, для которых попытка завершилась удачно:
match: inputState
| finalState tStream |
finalState := Set new.
inputState
do:
[:stream | tStream := stream copy.
(tStream nextAvailable:
components size
) = components
ifTrue: [finalState add: tStream]
].
^ finalState
Сообщение nextAvailable: выполняет смещение вперед по входному потоку. Это единственная операция match:, которая сдвигается по потоку. Обратите внимание: возвращаемое состояние содержит его копию, поэтому можно быть уверенным, что сопоставление с литералом никогда не изменяет входной поток. Это существенно, поскольку все альтернативы в AlternationExpression должны «видеть» одинаковые копии входного потока.
Таким образом, мы определили классы, составляющие абстрактное синтаксическое дерево. Теперь опишем, как его построить. Вместо того чтобы создавать анализатор регулярных выражений, мы определим некоторые операции в классах RegularExpression, так что вычисление выражения языка Smalltalk приведет к созданию абстрактного синтаксического дерева для соответствующего регулярного выражения. Тем самым мы будем использовать встроенный компилятор Smalltalk, как если бы это был анализатор синтаксиса регулярных выражений.
Для построения дерева нам понадобятся операции «|», «repeat» и «&» над регулярными выражениями. Определим эти операции в классе RegularExpression:
& aNode
^ SequenceExpression new
expression1: self expression2: aNode asRExp
repeat
^ RepetitionExpression new repetition: self
| aNode
^ AlternationExpression new
alternative1: self alternative2: aNode asRExp
asRExp
^ self
Операция asRExp преобразует литералы в RegularExpression. Следующие операции определены в классе String:
& aNode
^ SequenceExpression new
expression1: self asRExp expression2: aNode asRExp
repeat
^ RepetitionExpression new repetition: self
| aNode
^ AlternationExpression new
alternative1: self asRExp alternative2: aNode asRExp
asRExp
^ LiteralExpression new components: self
Если бы мы определили эти операции выше в иерархии классов, например SequenceableCollection в Smalltalk-80, IndexedCollection в Smalltalk/V, то они появились бы и в таких классах, как Array и OrderedCollection. Это позволило бы сопоставлять регулярные выражения с последовательностями объектов любого вида.
Второй пример – это система для манипулирования и вычисления булевых выражений, реализованная на C++. Терминальными символами в этом языке являются булевы переменные, то есть константы true и false. Нетерминальные символы представляют выражения, содержащие операторы and, or и not. Приведем определение грамматики:1
BooleanExp ::= VariableExp | Constant | OrExp | AndExp | NotExp |
‘(‘ BooleanExp ‘)’
AndExp ::= BooleanExp ‘and’ BooleanExp
OrExp ::= BooleanExp ‘or’ BooleanExp
NotExp ::= ‘not’ BooleanExp
Constant ::= ‘true’ | ‘false’
VariableExp ::= ‘A’ | ‘B’ | ... | ‘X’ | ‘Y’ | ‘Z’
Определим две операции над булевыми выражениями. Первая – Evaluate – вычисляет выражение в контексте, где каждой переменной присваивается истинное или ложное значение. Вторая – Replace – порождает новое булево выражение, заменяя выражением некоторую переменную. Эта операция демонстрирует, что паттерн интерпретатор можно использовать не только для вычисления выражений; в данном случае он манипулирует самим выражением.
Здесь мы подробно опишем только классы BooleanExp, VariableExp
и AndExp. Классы OrExp и NotExp аналогичны классу AndExp. Класс Constant представляет булевы константы.
В классе BooleanExp определен интерфейс всех классов, которые описывают булевы выражения:
class BooleanExp {
public:
BooleanExp();
virtual ~BooleanExp();
virtual bool Evaluate(Context&) = 0;
virtual BooleanExp* Replace(const char*, BooleanExp&) = 0;
virtual BooleanExp* Copy() const = 0;
};
Класс Context определяет отображение между переменными и булевыми значениями, которые в C++ представляются константами true и false. Интерфейс этого класса следующий:
class Context {
public:
bool Lookup(const char*) const;
void Assign(VariableExp*, bool);
};
Класс VariableExp представляет именованную переменную:
class VariableExp : public BooleanExp {
public:
VariableExp(const char*);
virtual ~VariableExp();
virtual bool Evaluate(Context&);
virtual BooleanExp* Replace(const char*, BooleanExp&);
virtual BooleanExp* Copy() const;
private:
char* _name;
};
Конструктор класса принимает в качестве аргумента имя переменной:
VariableExp::VariableExp (const char* name) {
_name = strdup(name);
}
Вычисление переменной возвращает ее значение в текущем контексте:
bool VariableExp::Evaluate (Context& aContext) {
return aContext.Lookup(_name);
}
Копирование переменной возвращает новый объект класса VariableExp:
BooleanExp* VariableExp::Copy () const {
return new VariableExp(_name);
}
Чтобы заменить переменную выражением, мы сначала проверяем, что у переменной то же имя, что было передано ранее в качестве аргумента:
BooleanExp* VariableExp::Replace (
const char* name, BooleanExp& exp
) {
if (strcmp(name, _name) == 0) {
return exp.Copy();
} else {
return new VariableExp(_name);
}
}
Класс AndExp представляет выражение, получающееся в результате применения операции логического И к двум булевым выражениям:
class AndExp : public BooleanExp {
public:
AndExp(BooleanExp*, BooleanExp*);
virtual ~AndExp();
virtual bool Evaluate(Context&);
virtual BooleanExp* Replace(const char*, BooleanExp&);
virtual BooleanExp* Copy() const;
private:
BooleanExp* _operand1;
BooleanExp* _operand2;
};
AndExp::AndExp (BooleanExp* op1, BooleanExp* op2) {
_operand1 = op1;
_operand2 = op2;
}
При решении AndExp вычисляются его операнды и результат применения
к ним операции логического И возвращается:
bool AndExp::Evaluate (Context& aContext) {
return
_operand1->Evaluate(aContext) &&
_operand2->Evaluate(aContext);
}
В классе AndExp операции Copy и Replace реализованы с помощью рекурсивных обращений к операндам:
BooleanExp* AndExp::Copy () const {
return
new AndExp(_operand1->Copy(), _operand2->Copy());
}
BooleanExp* AndExp::Replace (const char* name, BooleanExp& exp) {
return
new AndExp(
_operand1->Replace(name, exp),
_operand2->Replace(name, exp)
);
}
Определим теперь булево выражение
(true and x) or (y and (not x))
и вычислим его для некоторых конкретных значений булевых переменных x и y:
BooleanExp* expression;
Context context;
VariableExp* x = new VariableExp("X");
VariableExp* y = new VariableExp("Y");
expression = new OrExp(
new AndExp(new Constant(true), x),
new AndExp(y, new NotExp(x))
);
context.Assign(x, false);
context.Assign(y, true);
bool result = expression->Evaluate(context);
С такими значениями x и y выражение равно true. Чтобы вычислить его при других значениях переменных, достаточно просто изменить контекст.
И наконец, мы можем заменить переменную y новым выражением и повторить вычисление:
VariableExp* z = new VariableExp("Z");
NotExp not_z(z);
BooleanExp* replacement = expression->Replace("Y", not_z);
context.Assign(z, true);
result = replacement->Evaluate(context);
На этом примере проиллюстрирована важная особенность паттерна интерпретатор: «интерпретация» предложения может означать самые разные действия. Из трех операций, определенных в классе BooleanExp, Evaluate наиболее близка к нашему интуитивному представлению о том, что интерпретатор должен интерпретировать программу или выражение и возвращать простой результат.
Но и операцию Replace можно считать интерпретатором. Его контекстом является имя заменяемой переменной и подставляемое вместо него выражение, а результатом служит новое выражение. Даже операцию Copy допустимо рассматривать как интерпретатор с пустым контекстом. Трактовка операций Replace и Copy как интерпретаторов может показаться странной, поскольку это всего лишь базовые операции над деревом. Примеры в описании паттерна посетитель демонстрируют, что все три операции разрешается вынести в отдельный объект-посетитель «интерпретатор», тогда аналогия станет более очевидной.
Паттерн интерпретатор – это нечто большее, чем распределение некоторой операции по иерархии классов, составленной с помощью паттерна компоновщик. Мы рассматриваем операцию Evaluate как интерпретатор, поскольку иерархию классов BooleanExp мыслим себе как представление некоторого языка. Если бы у нас была аналогичная иерархия для представления агрегатов автомобиля, то вряд ли мы стали бы считать такие операции, как Weight (вес) и Copy (копирование), интерпретаторами, несмотря на то что они распределены по всей иерархии классов, – просто мы не воспринимаем агрегаты автомобиля как язык. Тут все дело
в точке зрения: опубликуй мы грамматику агрегатов автомобиля, операции над ними можно было трактовать как способы интерпретации соответствующего языка.
Паттерн интерпретатор широко используется в компиляторах, реализованных с помощью объектно-ориентированных языков, например в компиляторах Smalltalk. В языке SPECTalk этот паттерн применяется для интерпретации форматов входных файлов [Sza92]. В библиотеке QOCA для разрешения ограничений он применяется для вычисления ограничений [HHMV92].
Если рассматривать данный паттерн в самом общем виде (то есть как операцию, распределенную по иерархии классов, основанной на паттерне компоновщик), то почти любое применение компоновщика содержит и интерпретатор. Но применять паттерн интерпретатор лучше в тех случаях, когда иерархию классов можно представлять себе как описание языка.
Компоновщик: абстрактное синтаксическое дерево – это пример применения паттерна компоновщик.
Приспособленец показывает варианты разделения терминальных символов в абстрактном синтаксическом дереве.
Итератор: интерпретатор может пользоваться итератором для обхода структуры.
Посетителя можно использовать для инкапсуляции в одном классе поведения каждого узла абстрактного синтаксического дерева.
Итератор – паттерн поведения объектов.
Предоставляет способ последовательного доступа ко всем элементам составного объекта, не раскрывая его внутреннего представления.
Cursor (курсор).
Составной объект, скажем список, должен предоставлять способ доступа к своим элементам, не раскрывая их внутреннюю структуру. Более того, иногда требуется обходить список по-разному, в зависимости от решаемой задачи. Но вряд ли вы захотите засорять интерфейс класса List операциями для различных вариантов обхода, даже если все их можно предвидеть заранее. Кроме того, иногда нужно, чтобы в один и тот же момент было определено несколько активных обходов списка.
Все это позволяет сделать паттерн итератор. Основная его идея в том, чтобы за доступ к элементам и способ обхода отвечал не сам список, а отдельный объект-итератор. В классе Iterator определен интерфейс для доступа к элементам списка. Объект этого класса отслеживает текущий элемент, то есть он располагает информацией, какие элементы уже посещались.
Например, класс List мог бы предусмотреть класс ListIterator.
Прежде чем создавать экземпляр класса ListIterator, необходимо иметь список, подлежащий обходу. С объектом ListIterator вы можете последовательно посетить все элементы списка. Операция CurrentItem возвращает текущий элемент списка, операция First инициализирует текущий элемент первым элементом списка, Next делает текущим следующий элемент, а IsDone проверяет, не оказались ли мы за последним элементом, если да, то обход завершен.
Отделение механизма обхода от объекта List позволяет определять итераторы, реализующие различные стратегии обхода, не перечисляя их в интерфейсе класса List. Например, FilteringListIterator мог бы предоставлять доступ только к тем элементам, которые удовлетворяют условиям фильтрации.
Заметим: между итератором и списком имеется тесная связь, клиент должен иметь информацию, что он обходит именно список, а не какую-то другую агрегированную структуру. Поэтому клиент привязан к конкретному способу агрегирования. Было бы лучше, если бы мы могли изменять класс агрегата, не трогая код клиента. Это можно сделать, обобщив концепцию итератора и рассмотрев полиморфную итерацию.
Например, предположим, что у нас есть еще класс SkipList, реализующий список. Список с пропусками (skiplist) [Pug90] – это вероятностная структура данных, по характеристикам напоминающая сбалансированное дерево. Нам нужно научиться писать код, способный работать с объектами как класса List, так
и класса SkipList.
Определим класс AbstractList, в котором объявлен общий интерфейс для манипулирования списками. Еще нам понадобится абстрактный класс Iterator, определяющий общий интерфейс итерации. Затем мы смогли бы определить конкретные подклассы класса Iterator для различных реализаций списка. В результате механизм итерации оказывается не зависящим от конкретных агрегированных классов.
Остается понять, как создается итератор. Поскольку мы хотим написать код, не зависящий от конкретных подклассов List, то нельзя просто инстанцировать конкретный класс. Вместо этого мы поручим самим объектам-спискам создавать для себя подходящие итераторы, вот почему потребуется операция CreateIterator, посредством которой клиенты смогут запрашивать объект-итератор.
CreateIterator – это пример использования паттерна фабричный метод. В данном случае он служит для того, чтобы клиент мог запросить у объекта-списка подходящий итератор. Применение фабричного метода приводит к появлению двух иерархий классов – одной для списков, другой для итераторов. Фабричный метод CreateIterator «связывает» эти две иерархии.
Используйте паттерн итератор:
• для доступа к содержимому агрегированных объектов без раскрытия их внутреннего представления;
• для поддержки нескольких активных обходов одного и того же агрегированного объекта;
• для предоставления единообразного интерфейса с целью обхода различных агрегированных структур (то есть для поддержки полиморфной итерации).
• Iterator – итератор:
– определяет интерфейс для доступа и обхода элементов;
• ConcreteIterator – конкретный итератор:
– реализует интерфейс класса Iterator;
– следит за текущей позицией при обходе агрегата;
• Aggregate – агрегат:
– определяет интерфейс для создания объекта-итератора;
• ConcreteAggregate – конкретный агрегат:
– реализует интерфейс создания итератора и возвращает экземпляр подходящего класса ConcreteIterator.
ConcreteIterator отслеживает текущий объект в агрегате и может вычислить идущий за ним.
У паттерна итератор есть следующие важные особенности:
• поддерживает различные виды обхода агрегата. Сложные агрегаты можно обходить по-разному. Например, для генерации кода и семантических проверок нужно обходить деревья синтаксического разбора. Генератор кода может обходить дерево во внутреннем или прямом порядке. Итераторы упрощают изменение алгоритма обхода – достаточно просто заменить один экземпляр итератора другим. Для поддержки новых видов обхода можно определить
и подклассы класса Iterator ;
• итераторы упрощают интерфейс класса Aggregate. Наличие интерфейса для обхода в классе Iterator делает излишним дублирование этого интерфейса в классе Aggregate. Тем самым интерфейс агрегата упрощается;
• одновременно для данного агрегата может быть активно несколько обходов. Итератор следит за инкапсулированным в нем самом состоянием обхода. Поэтому одновременно разрешается осуществлять несколько обходов агрегата.
Существует множество вариантов реализации итератора. Ниже перечислены наиболее употребительные. Решение о том, какой способ выбрать, часто зависит от управляющих структур, поддерживаемых языком программирования. Некоторые языки (например, CLU [LG86]) даже поддерживают данный паттерн напрямую.
• какой участник управляет итерацией. Важнейший вопрос состоит в том, что управляет итерацией: сам итератор или клиент, который им пользуется. Если итерацией управляет клиент, то итератор называется внешним, в противном случае – внутренним.1 Клиенты, применяющие внешний итератор, должны явно запрашивать у итератора следующий элемент, чтобы двигаться дальше по агрегату. Напротив, в случае внутреннего итератора клиент передает итератору некоторую операцию, а итератор уже сам применяет эту операцию к каждому посещенному во время обхода элементу агрегата.
Внешние итераторы обладают большей гибкостью, чем внутренние. Например, сравнить две коллекции на равенство с помощью внешнего итератора очень легко, а с помощью внутреннего – практически невозможно. Слабые стороны внутренних итераторов наиболее отчетливо проявляются в таких языках, как C++, где нет анонимных функций, замыканий (closure) и продолжений (continuation), как в Smalltalk или CLOS. Но, с другой стороны, внутренние итераторы проще в использовании, поскольку они вместо вас определяют логику обхода;
• что определяет алгоритм обхода. Алгоритм обхода можно определить не только в итераторе. Его может определить сам агрегат и использовать итератор только для хранения состояния итерации. Такого рода итератор мы называем курсором, поскольку он всего лишь указывает на текущую позицию в агрегате. Клиент вызывает операцию Next агрегата, передавая ей курсор в качестве аргумента. Операция же Next изменяет состояние курсора.2
Если за алгоритм обхода отвечает итератор, то для одного и того же агрегата можно использовать разные алгоритмы итерации, и, кроме того, проще применить один алгоритм к разным агрегатам. С другой стороны, алгоритму обхода может понадобиться доступ к закрытым переменным агрегата. Если это так, то перенос алгоритма в итератор нарушает инкапсуляцию агрегата;
• насколько итератор устойчив. Модификация агрегата в то время, как совершается его обход, может оказаться опасной. Если при этом добавляются или удаляются элементы, то не исключено, что некоторый элемент будет посещен дважды или вообще ни разу. Простое решение – скопировать агрегат и обходить копию, но обычно это слишком дорого.
Устойчивый итератор (robust) гарантирует, что ни вставки, на удаления не помешают обходу, причем достигается это без копирования агрегата. Есть много способов реализации устойчивых итераторов. В большинстве из них итератор регистрируется в агрегате. При вставке или удалении агрегат либо подправляет внутреннее состояние всех созданных им итераторов, либо организует внутреннюю информацию так, чтобы обход выполнялся правильно.
В работе Томаса Кофлера (Thomas Kofler) [Kof93] приводится подробное обсуждение реализации итераторов в каркасе ET++. Роберт Мюррей (Robert Murray) [Mur93] описывает реализацию устойчивых итераторов для класса List из библиотеки USL Standard Components;
• дополнительные операции итератора. Минимальный интерфейс класса Iterator состоит из операций First, Next, IsDone и CurrentItem.1 Но могут оказаться полезными и некоторые дополнительные операции. Например, упорядоченные агрегаты могут предоставлять операцию Previous, позиционирующую итератор на предыдущий элемент. Для отсортированных или индексированных коллекций интерес представляет операция SkipTo, которая позиционирует итератор на объект, удовлетворяющий некоторому критерию;
• использование полиморфных итераторов в C++. С полиморфными итераторами связаны определенные накладные расходы. Необходимо, чтобы объект-итератор создавался в динамической памяти фабричным методом. Поэтому использовать их стоит только тогда, когда есть необходимость в полиморфизме. В противном случае применяйте конкретные итераторы, которые вполне можно распределять в стеке.
У полиморфных итераторов есть и еще один недостаток: за их удаление отвечает клиент. Здесь открывается большой простор для ошибок, так как очень легко забыть об освобождении распределенного из кучи объекта-итератора после завершения работы с ним. Особенно велика вероятность этого, если у операции есть несколько точек выхода. А в случае возбуждения исключения память, занимаемая объектом-итератором, вообще никогда не будет освобождена.
Эту ситуацию помогает исправить паттерн заместитель. Вместо настоящего итератора мы используем его заместителя, память для которого выделена в стеке. Заместитель уничтожает итератор в своем деструкторе. Поэтому, как только заместитель выходит из области действия, вместе с ним уничтожается и настоящий итератор. Заместитель гарантирует выполнение надлежащей очистки даже при возникновении исключений. Это пример применения хорошо известной в C++ техники, которая называется «выделение ресурса – это инициализация» [ES90]. В разделе «Пример кода» она проиллюстрирована подробнее;
• итераторы могут иметь привилегированный доступ. Итератор можно рассматривать как расширение создавший его агрегат. Итератор и агрегат тесно связаны. В C++ такое отношение можно выразить, сделав итератор другом своего агрегата. Тогда не нужно определять в агрегате операции, единственная цель которых – позволить итераторам эффективно выполнить обход.
Однако наличие такого привилегированного доступа может затруднить определение новых способов обхода, так как потребуется изменить интерфейс агрегата, добавив в него нового друга. Для того чтобы решить эту проблему, класс Iterator может включать защищенные операции для доступа к важным, но не являющимся открытыми членам агрегата. Подклассы класса Iterator (и только его подклассы) могут воспользоваться этими защищенными операциями для получения привилегированного доступа к агрегату;
• итераторы для составных объектов. Реализовать внешние агрегаты для рекурсивно агрегированных структур (таких, например, которые возникают
в результате применения паттерна компоновщик) может оказаться затруднительно, поскольку описание положения в структуре иногда охватывает несколько уровней вложенности. Поэтому, чтобы отследить позицию текущего объекта, внешний итератор должен хранить путь через составной объект Composite. Иногда проще воспользоваться внутренним итератором. Он может запомнить текущую позицию, рекурсивно вызывая себя самого, так что путь будет неявно храниться в стеке вызовов.
Если узлы составного объекта Composite имеют интерфейс для перемещения от узла к его братьям, родителям и потомкам, то лучшее решение дает итератор курсорного типа. Курсору нужно следить только за текущим узлом, а для обхода составного объекта он может положиться на интерфейс этого узла.
Составные объекты часто нужно обходить несколькими способами. Самые распространенные – это обход в прямом, обратном и внутреннем порядке,
а также обход в ширину. Каждый вид обхода можно поддержать отдельным итератором;
• пустые итераторы. Пустой итератор NullIterator – это вырожденный итератор, полезный при обработке граничных условий. По определению, NullIterator всегда считает, что обход завершен, то есть его операция IsDone неизменно возвращает истину.
Применение пустого итератора может упростить обход древовидных структур (например, объектов Composite). В каждой точке обхода мы запрашиваем у текущего элемента итератор для его потомков. Элементы-агрегаты, как обычно, возвращают конкретный итератор. Но листовые элементы возвращают экземпляр NullIterator. Это позволяет реализовать обход всей структуры единообразно.
Рассмотрим простой класс списка List, входящего в нашу базовую библиотеку (см. приложение C) и две реализации класса Iterator: одну для обхода списка от начала к концу, а другую – от конца к началу (в базовой библиотеке поддержан только первый способ). Затем мы покажем, как пользоваться этими итераторами
и как избежать зависимости от конкретной реализации. После этого изменим дизайн, дабы гарантировать корректное удаление итераторов. А в последнем примере мы проиллюстрируем внутренний итератор и сравним его с внешним.
• интерфейсы классов List и Iterator. Сначала обсудим ту часть интерфейса класса List, которая имеет отношение к реализации итераторов. Полный интерфейс см. в приложении C:
template <class Item>
class List {
public:
List(long size = DEFAULT_LIST_CAPACITY);
long Count() const;
Item& Get(long index) const;
// ...
};
В открытом интерфейсе класса List предусмотрен эффективный способ поддержки итераций. Его достаточно для реализации обоих видов обхода. Поэтому нет необходимости предоставлять итераторам привилегированный доступ к внутренней структуре данных. Иными словами, классы итераторов не являются друзьями класса List. Определим абстрактный класс Iterator,
в котором будет объявлен интерфейс итератора:
template <class Item>
class Iterator {
public:
virtual void First() = 0;
virtual void Next() = 0;
virtual bool IsDone() const = 0;
virtual Item CurrentItem() const = 0;
protected:
Iterator();
};
• реализации подклассов класса Iterator. Класс ListIterator является подклассом Iterator:
template <class Item>
class ListIterator : public Iterator<Item> {
public:
ListIterator(const List<Item>* aList);
virtual void First();
virtual void Next();
virtual bool IsDone() const;
virtual Item CurrentItem() const;
private:
const List<Item>* _list;
long _current;
};
Реализация класса ListIterator не вызывает затруднений. В нем хранится экземпляр List и индекс _current, указывающий текущую позицию
в списке:
template <class Item>
ListIterator<Item>::ListIterator (
const List<Item>* aList
) : _list(aList), _current(0) {
}
Операция First позиционирует итератор на первый элемент списка:
template <class Item>
void ListIterator<Item>::First () {
_current = 0;
}
Операция Next делает текущим следующий элемент:
template <class Item>
void ListIterator<Item>::Next () {
_current++;
}
Операция IsDone проверяет, относится ли индекс к элементу внутри списка:
template <class Item>
bool ListIterator<Item>::IsDone () const {
return _current >= _list->Count();
}
Наконец, операция CurrentItem возвращает элемент, соответствующий текущему индексу. Если итерация уже завершилась, то мы возбуждаем исключение IteratorOutOfBounds:
template <class Item>
Item ListIterator<Item>::CurrentItem () const {
if (IsDone()) {
throw IteratorOutOfBounds;
}
return _list->Get(_current);
}
Реализация обратного итератора ReverseListIterator аналогична рассмотренной, только его операция First позиционирует _current на конец списка, а операция Next делает текущим предыдущий элемент;
• использование итераторов. Предположим, что имеется список объектов Employee (служащий) и мы хотели бы напечатать информацию обо всех содержащихся в нем служащих. Класс Employee поддерживает печать с помощью операции Print. Для печати списка определим операцию PrintEmployees, принимающую в качестве аргумента итератор. Она пользуется этим итератором для обхода и печати содержимого списка:
void PrintEmployees (Iterator<Employee*>& i) {
for (i.First(); !i.IsDone(); i.Next()) {
i.CurrentItem()->Print();
}
}
Поскольку у нас есть итераторы для обхода списка от начала к концу и от конца к началу, то мы можем повторно воспользоваться той же самой операцией для печати списка служащих в обоих направлениях:
List<Employee*>* employees;
// ...
ListIterator<Employee*> forward(employees);
ReverseListIterator<Employee*> backward(employees);
PrintEmployees(forward);
PrintEmployees(backward);
• как избежать зависимости от конкретной реализации списка. Рассмотрим, как повлияла бы на код итератора реализация класса List в виде списка
с пропусками. Подкласс SkipList класса List должен предоставить итератор SkipListIterator, реализующий интерфейс класса Iterator. Для эффективной реализации итерации у SkipListIterator должен быть не только индекс. Но поскольку SkipListIterator согласуется с интерфейсом класса Iterator, то операцию PrintEmployees можно использовать и тогда, когда служащие хранятся в списке типа SkipList:
SkipList<Employee*>* employees;
// ...
SkipListIterator<Employee*> iterator(employees);
PrintEmployees(iterator);
Оптимальное решение в данной ситуации – вообще не привязываться к конкретной реализации списка, например SkipList. Мы можем рассмотреть абстрактный класс AbstractList ради стандартизации интерфейса списка для различных реализаций. Тогда и List, и SkipList окажутся подклассами AbstractList.
Для поддержки полиморфной итерации класс AbstractList определяет фабричный метод CreateIterator, замещаемый в подклассах, которые возвращают подходящий для себя итератор:
template <class Item>
class AbstractList {
public:
virtual Iterator<Item>* CreateIterator() const = 0;
// ...
};
Альтернативный вариант – определение общего подмешиваемого класса Traversable, в котором определен интерфейс для создания итератора. Для поддержки полиморфных итераций агрегированные классы могут являться потомками Traversable.
Класс List замещает CreateIterator, для того чтобы возвратить объект ListIterator:
template <class Item>
Iterator<Item>* List<Item>::CreateIterator () const {
return new ListIterator<Item>(this);
}
Теперь мы можем написать код для печати служащих, который не будет зависеть от конкретного представления списка:
// мы знаем только, что существует класс AbstractList
AbstractList<Employee*>* employees;
// ...
Iterator<Employee*>* iterator = employees->CreateIterator();
PrintEmployees(*iterator);
delete iterator;
• как гарантировать удаление итераторов? Заметим, что CreateIterator возвращает только что созданный в динамической памяти объект-итератор. Ответственность за его удаление лежит на нас. Если мы забудем это сделать, то возникнет утечка памяти. Для того чтобы упростить задачу клиентам, мы введем класс IteratorPtr, который замещает итератор. Он уничтожит объект Iterator при выходе из области определения.
Объект класса IteratorPtr всегда распределяется в стеке.1 C++ автоматически вызовет его деструктор, который уничтожит реальный итератор.
В классе IteratorPtr операторы operator-> и operator* перегружены так, что объект этого класса можно рассматривать как указатель на итератор. Функции-члены класса IteratorPtr встраиваются, поэтому при их вызове накладных расходов нет:
template <class Item>
class IteratorPtr {
public:
IteratorPtr(Iterator<Item>* i): _i(i) { }
~IteratorPtr() { delete _i; }
Iterator<Item>* operator->() { return _i; }
Iterator<Item>& operator*() { return *_i; }
private:
// запретить копирование и присваивание, чтобы
// избежать многократных удалений _i
IteratorPtr(const IteratorPtr&);
IteratorPtr& operator=(const IteratorPtr&);
private:
Iterator<Item>* _i;
};
IteratorPtr позволяет упростить код печати:
AbstractList<Employee*>* employees;
// ...
IteratorPtr<Employee*> iterator(employees->CreateIterator());
PrintEmployees(*iterator);
• внутренний ListIterator. В последнем примере рассмотрим, как можно было бы реализовать внутренний или пассивный класс ListIterator. Теперь итератор сам управляет итерацией и применяет к каждому элементу некоторую операцию.
Нерешенным остается вопрос о том, как параметризовать итератор той операцией, которую мы хотим применить к каждому элементу. C++ не поддерживает ни анонимных функций, ни замыканий, которые предусмотрены для этой цели в других языках. Существует, по крайней мере, два варианта: передать указатель на функцию (глобальную или статическую) или породить подклассы. В первом случае итератор вызывает переданную ему операцию в каждой точке обхода. Во втором случае итератор вызывает операцию, которая замещена в подклассе и обеспечивает нужное поведение.
Ни один из вариантов не идеален. Часто во время обхода нужно аккумулировать некоторую информацию, а функции для этого плохо подходят – пришлось бы использовать статические переменные для запоминания состояния. Подкласс класса Iterator предоставляет удобное место для хранения аккумулированного состояния – переменную экземпляра. Но создавать подкласс для каждого вида обхода слишком трудоемко.
Вот набросок реализации второго варианта с использованием подклассов. Назовем внутренний итератор ListTraverser:
template <class Item>
class ListTraverser {
public:
ListTraverser(List<Item>* aList);
bool Traverse();
protected:
virtual bool ProcessItem(const Item&) = 0;
private:
ListIterator<Item> _iterator;
};
ListTraverser принимает экземпляр List в качестве параметра. Внутри себя он использует внешний итератор ListIterator для выполнения обхода. Операция Traverse начинает обход и вызывает для каждого элемента операцию ProcessItem. Внутренний итератор может закончить обход, вернув false из ProcessItem. Traverse сообщает о преждевременном завершении обхода:
template <class Item>
ListTraverser<Item>::ListTraverser (
List<Item>* aList
) : _iterator(aList) { }
template <class Item>
bool ListTraverser<Item>::Traverse () {
bool result = false;
for (
_iterator.First();
!_iterator.IsDone();
_iterator.Next()
) {
result = ProcessItem(_iterator.CurrentItem());
if (result == false) {
break;
}
}
return result;
}
Воспользуемся итератором ListTraverser для печати первых десяти служащих из списка. С этой целью надо породить подкласс от ListTraverser и определить в нем операцию ProcessItem. Подсчитывать число напечатанных служащих будем в переменной экземпляра _count:
class PrintNEmployees : public ListTraverser<Employee*> {
public:
PrintNEmployees(List<Employee*>* aList, int n) :
ListTraverser<Employee*>(aList),
_total(n), _count(0) { }
protected:
bool ProcessItem(Employee* const&);
private:
int _total;
int _count;
};
bool PrintNEmployees::ProcessItem (Employee* const& e) {
_count++;
e->Print();
return _count < _total;
}
Вот как PrintNEmployees печатает первые 10 служащих:
List<Employee*>* employees;
// ...
PrintNEmployees pa(employees, 10);
pa.Traverse();
Обратите внимание, что в коде клиента нет цикла итерации. Всю логику обхода можно использовать повторно. В этом и состоит основное преимущество внутреннего итератора. Работы, правда, немного больше, чем для внешнего итератора, так как нужно определять новый класс. Сравните с программой, где применяется внешний итератор:
ListIterator<Employee*> i(employees);
int count = 0;
for (i.First(); !i.IsDone(); i.Next()) {
count++;
i.CurrentItem()->Print();
if (count >= 10) {
break;
}
}
Внутренние итераторы могут инкапсулировать разные виды итераций. Например, FilteringListTraverser инкапсулирует итерацию, при которой обрабатываются лишь элементы, удовлетворяющие определенному условию:
template <class Item>
class FilteringListTraverser {
public:
FilteringListTraverser(List<Item>* aList);
bool Traverse();
protected:
virtual bool ProcessItem(const Item&) = 0;
virtual bool TestItem(const Item&) = 0;
private:
ListIterator<Item> _iterator;
};
Интерфейс такой же, как у ListTraverser, если не считать новой функции-члена TestItem, которая и реализует проверку условия. В подклассах TestItem замещается для задания конкретного условия.
Посредством операции Traverse выясняется, нужно ли продолжать обход, основываясь на результате проверки:
template <class Item>
void FilteringListTraverser<Item>::Traverse () {
bool result = false;
for (
_iterator.First();
!_iterator.IsDone();
_iterator.Next()
) {
if (TestItem(_iterator.CurrentItem())) {
result = ProcessItem(_iterator.CurrentItem());
if (result == false) {
break;
}
}
}
return result;
}
В качестве варианта можно было определить функцию Traverse так, чтобы она сообщала хотя бы об одном встретившемся элементе, который удовлетворяет условию.1
Итераторы широко распространены в объектно-ориентированных системах. В том или ином виде они есть в большинстве библиотек коллекций классов.
Вот пример из библиотеки компонентов Грейди Буча [Boo94], популярной библиотеки, поддерживающей классы коллекций. В ней имеется реализация очереди фиксированной (ограниченной) и динамически растущей длины (неограниченной). Интерфейс очереди определен в абстрактном классе Queue. Для поддержки полиморфной итерации по очередям с разной реализацией итератор написан с использованием интерфейса абстрактного класса Queue. Преимущество такого подхода очевидно – отсутствует необходимость в фабричном методе, который запрашивал бы у очереди соответствующий ей итератор. Однако, чтобы итератор можно было реализовать эффективно, интерфейс абстрактного класса Queue должен быть мощным.
В языке Smalltalk необязательно определять итераторы так явно. В стандартных классах коллекций (Bag, Set, Dictionary, OrderedCollection, String и т.д.) определен метод do:, выполняющий функции внутреннего итератора, который принимает блок (то есть замыкание). Каждый элемент коллекции привязывается к локальной переменной в блоке, а затем блок выполняется. Smalltalk также включает набор классов Stream, которые поддерживают похожий на итератор интерфейс. ReadStream – это, по существу, класс Iterator и внешний итератор для всех последовательных коллекций. Для непоследовательных коллекций типа Set и Dictionary нет стандартных итераторов.
Полиморфные итераторы и выполняющие очистку заместители находятся
в контейнерных классах ET++ [WGM88]. Курсороподобные итераторы используются в классах каркаса графических редакторов Unidraw [VL90].
В системе ObjectWindows 2.0 [Bor94] имеется иерархия классов итераторов для контейнеров. Контейнеры разных типов можно обходить одним и тем же способом. Синтаксис итераторов в ObjectWindows основан на перегрузке постфиксного оператора инкремента ++ для перехода к следующему элементу.
Компоновщик: итераторы довольно часто применяются для обхода рекурсивных структур, создаваемых компоновщиком.
Фабричный метод: полиморфные итераторы поручают фабричным методам инстанцировать подходящие подклассы класса Iterator.
Итератор может использовать хранитель для сохранения состояния итерации и при этом содержит его внутри себя.
Посредник – паттерн поведения объектов.
Определяет объект, инкапсулирующий способ взаимодействия множества объектов. Посредник обеспечивает слабую связанность системы, избавляя объекты от необходимости явно ссылаться друг на друга и позволяя тем самым независимо изменять взаимодействия между ними.
Объектно-ориентированное проектирование способствует распределению некоторого поведения между объектами. Но при этом в получившейся структуре объектов может возникнуть много связей или (в худшем случае) каждому объекту придется иметь информацию обо всех остальных.
Несмотря на то что разбиение системы на множество объектов в общем случае повышает степень повторного использования, однако изобилие взаимосвязей приводит к обратному эффекту. Если взаимосвязей слишком много, тогда система подобна монолиту и маловероятно, что объект сможет работать без поддержки других объектов. Более того, существенно изменить поведение системы практически невозможно, поскольку оно распределено между многими объектами. Если вы предпримете подобную попытку, то для настройки поведения системы вам придется определять множество подклассов.
Рассмотрим реализацию диалоговых окон в графическом интерфейсе пользователя. Здесь располагается ряд виджетов: кнопки, меню, поля ввода и т.д., как показано на рисунке.
Часто между разными виджетами в диалоговом окне существуют зависимости. Например, если одно из полей ввода пустое, то определенная кнопка недоступна. При выборе из списка может измениться содержимое поля ввода. И наоборот, ввод текста в некоторое поле может автоматически привести к выбору одного или нескольких элементов списка. Если в поле ввода присутствует какой-то текст, то могут быть активизированы кнопки, позволяющие произвести определенное действие над этим текстом, например изменить либо удалить его.
В разных диалоговых окнах зависимости между виджетами могут быть различными. Поэтому, несмотря на то что во всех окнах встречаются однотипные виджеты, просто взять и повторно использовать готовые классы виджетов не удастся, придется производить настройку с целью учета зависимостей. Индивидуальная настройка каждого виджета – утомительное занятие, ибо участвующих классов слишком много.
Всех этих проблем можно избежать, если инкапсулировать коллективное поведение в отдельном объекте-посреднике. Посредник отвечает за координацию взаимодействий между группой объектов. Он избавляет входящие в группу объекты от необходимости явно ссылаться друг на друга. Все объекты располагают информацией только о посреднике, поэтому количество взаимосвязей сокращается.
Так, класс FontDialogDirector может служить посредником между виджетами в диалоговом окне. Объект этого класса «знает» обо всех виджетах в окне
и координирует взаимодействие между ними, то есть выполняет функции центра коммуникаций.
На следующей диаграмме взаимодействий показано, как объекты кооперируются друг с другом, реагируя на изменение выбранного элемента списка.
Последовательность событий, в результате которых информация о выбранном элемента списка передается в поле ввода, следующая:
1. Список информирует распорядителя о происшедших в нем изменениях.
2. Распорядитель получает от списка выбранный элемент.
3. Распорядитель передает выбранный элемент полю ввода.
4. Теперь, когда поле ввода содержит какую-то информацию, распорядитель активизирует кнопки, позволяющие выполнить определенное действие (например, изменить шрифт на полужирный или курсив).
Обратите внимание на то, как распорядитель осуществляет посредничество между списком и полем ввода. Виджеты общаются друг с другом не напрямую,
а через распорядитель. Им вообще не нужно владеть информацией друг о друге, они осведомлены лишь о существовании распорядителя. А коль скоро поведение локализовано в одном классе, то его несложно модифицировать или сделать совершенно другим путем расширения или замены этого класса.
Абстракцию FontDialogDirector можно было бы интегрировать в библиотеку классов так, как показано на рисунке.
DialogDirector – это абстрактный класс, который определяет поведение диалогового окна в целом. Клиенты вызывают его операцию ShowDialog для отображения окна на экране. CreateWidgets – это абстрактная операция для создания виджетов в диалоговом окне. WidgetChanged – еще одна абстрактная операция; с ее помощью виджеты сообщают распорядителю об изменениях. Подклассы DialogDirector замещают операции CreateWidgets (для создания нужных виджетов) и WidgetChanged (для обработки извещений об изменениях).
Используйте паттерн посредник, когда
• имеются объекты, связи между которыми сложны и четко определены. Получающиеся при этом взаимозависимости не структурированы и трудны для понимания;
• нельзя повторно использовать объект, поскольку он обменивается информацией со многими другими объектами;
• поведение, распределенное между несколькими классами, должно поддаваться настройке без порождения множества подклассов.
Типичная структура объектов.
• Mediator (DialogDirector) – посредник:
– определяет интерфейс для обмена информацией с объектами Colleague;
• ConcreteMediator (FontDialogDirector) – конкретный посредник:
– реализует кооперативное поведение, координируя действия объектов Colleague;
– владеет информацией о коллегах и подсчитывает их;
• Классы Colleague (ListBox, EntryField) – коллеги:
– каждый класс Colleague «знает» о своем объекте Mediator;
– все коллеги обмениваются информацией только с посредником, так как при его отсутствии им пришлось бы общаться между собой напрямую.
Коллеги посылают запросы посреднику и получают запросы от него. Посредник реализует кооперативное поведение путем переадресации каждого запроса подходящему коллеге (или нескольким коллегам).
У паттерна посредник есть следующие достоинства и недостатки:
• снижает число порождаемых подклассов. Посредник локализует поведение, которое в противном случае пришлось бы распределять между несколькими объектами. Для изменения поведения нужно породить подклассы только от класса посредника Mediator, классы коллег Colleague можно использовать повторно без каких бы то ни было изменений;
• устраняет связанность между коллегами. Посредник обеспечивает слабую связанность коллег. Изменять классы Colleague и Mediator можно независимо друг от друга;
• упрощает протоколы взаимодействия объектов. Посредник заменяет дисциплину взаимодействия «все со всеми» дисциплиной «один со всеми», то есть один посредник взаимодействует со всеми коллегами. Отношения вида «один ко многим» проще для понимания, сопровождения и расширения;
• абстрагирует способ кооперирования объектов. Выделение механизма посредничества в отдельную концепцию и инкапсуляция ее в одном объекте позволяет сосредоточиться именно на взаимодействии объектов, а не на их индивидуальном поведении. Это дает возможность прояснить имеющиеся
в системе взаимодействия;
• централизует управление. Паттерн посредник переносит сложность взаимодействия в класс-посредник. Поскольку посредник инкапсулирует протоколы, то он может быть сложнее отдельных коллег. В результате сам посредник становится монолитом, который трудно сопровождать.
Имейте в виду, что при реализации паттерна посредник может происходить:
• избавление от абстрактного класса Mediator. Если коллеги работают только с одним посредником, то нет необходимости определять абстрактный класс Mediator. Обеспечиваемая классом Mediator абстракция позволяет коллегам работать с разными подклассами класса Mediator и наоборот;
• обмен информацией между коллегами и посредником. Коллеги должны обмениваться информацией со своим посредником только тогда, когда возникает представляющее интерес событие. Одним из подходов к реализации посредника является применение паттерна наблюдатель. Тогда классы коллег действуют как субъекты, посылающие извещения посреднику о любом изменении своего состояния. Посредник реагирует на них, сообщая об этом другим коллегам.
Другой подход: в классе Mediator определяется специализированный интерфейс уведомления, который позволяет коллегам обмениваться информацией более свободно. В Smalltalk/V для Windows применяется некоторая форма делегирования: общаясь с посредником, коллега передает себя в качестве аргумента, давая посреднику возможность идентифицировать отправителя. Об этом подходе рассказывается в разделе «Пример кода», а о реализации в Smalltalk/V – в разделе «Известные применения».
Для создания диалогового окна, обсуждавшегося в разделе «Мотивация», воспользуемся классом DialogDirector. Абстрактный класс DialogDirector определяет интерфейс распорядителей:
class DialogDirector {
public:
virtual ~DialogDirector();
virtual void ShowDialog();
virtual void WidgetChanged(Widget*) = 0;
protected:
DialogDirector();
virtual void CreateWidgets() = 0;
};
Widget – это абстрактный базовый класс для всех виджетов. Он располагает информацией о своем распорядителе:
class Widget {
public:
Widget(DialogDirector*);
virtual void Changed();
virtual void HandleMouse(MouseEvent& event);
// ...
private:
DialogDirector* _director;
};
Changed вызывает операцию распорядителя WidgetChanged. С ее помощью виджеты информируют своего распорядителя о происшедших с ними изменениях:
void Widget::Changed () {
_director->WidgetChanged(this);
}
В подклассах DialogDirector переопределена операция WidgetChanged для воздействия на нужные виджеты. Виджет передает ссылку на самого себя в качестве аргумента WidgetChanged, чтобы распорядитель имел информацию об изменившемся виджете. Подклассы DialogDirector переопределяют исключительно виртуальную функцию CreateWidgets для размещения в диалоговом окне нужных виджетов.
ListBox, EntryField и Button – это подклассы Widget для специализированных элементов интерфейса. В классе ListBox есть операция GetSelection для получения текущего множества выделенных элементов, а в классе EntryField – операция SetText для помещения текста в поле ввода:
class ListBox : public Widget {
public:
ListBox(DialogDirector*);
virtual const char* GetSelection();
virtual void SetList(List<char*>* listItems);
virtual void HandleMouse(MouseEvent& event);
// ...
};
class EntryField : public Widget {
public:
EntryField(DialogDirector*);
virtual void SetText(const char* text);
virtual const char* GetText();
virtual void HandleMouse(MouseEvent& event);
// ...
};
Операция Changed вызывается при нажатии кнопки Button (простой виджет). Это происходит в операции обработки событий мыши HandleMouse:
class Button : public Widget {
public:
Button(DialogDirector*);
virtual void SetText(const char* text);
virtual void HandleMouse(MouseEvent& event);
// ...
};
void Button::HandleMouse (MouseEvent& event) {
// ...
Changed();
}
Класс FontDialogDirector является посредником между всеми виджетами в диалоговом окне. FontDialogDirector – это подкласс класса DialogDirector:
class FontDialogDirector : public DialogDirector {
public:
FontDialogDirector();
virtual ~FontDialogDirector();
virtual void WidgetChanged(Widget*);
protected:
virtual void CreateWidgets();
private:
Button* _ok;
Button* _cancel;
ListBox* _fontList;
EntryField* _fontName;
};
FontDialogDirector отслеживает все виджеты, которые ранее поместил в диалоговое окно. Переопределенная в нем операция CreateWidgets создает виджеты и инициализирует ссылки на них:
void FontDialogDirector::CreateWidgets () {
_ok = new Button(this);
_cancel = new Button(this);
_fontList = new ListBox(this);
_fontName = new EntryField(this);
// поместить в список названия шрифтов
// разместить все виджеты в диалоговом окне
}
Операция WidgetChanged обеспечивает правильную совместную работу виджетов:
void FontDialogDirector::WidgetChanged (
Widget* theChangedWidget
) {
if (theChangedWidget == _fontList) {
_fontName->SetText(_fontList->GetSelection());
} else if (theChangedWidget == _ok) {
// изменить шрифт и уничтожить диалоговое окно
// ...
} else if (theChangedWidget == _cancel) {
// уничтожить диалоговое окно
}
}
Сложность операции WidgetChanged возрастает пропорционально сложности окна диалога. Создание очень больших диалоговых окон нежелательно и по другим причинам, но в других приложениях сложность посредника может свести на нет его преимущества.
И в ET++ [WGM88], и в библиотеке классов THINK C [Sym93b] применяются похожие на нашего распорядителя объекты для осуществления посредничества между виджетами в диалоговых окнах.
Архитектура приложения в Smalltalk/V для Windows основана на структуре посредника [LaL94]. В этой среде приложение состоит из окна Window, которое содержит набор панелей. В библиотеке есть несколько предопределенных объектов-панелей Pane, например: TextPane, ListBox, Button и т.д. Их можно использовать без подклассов. Разработчик приложения порождает подклассы только от класса ViewManager (диспетчер видов), отвечающего за обмен информацией между панелями. ViewManager – это посредник, каждая панель «знает» своего диспетчера, который считается «владельцем» панели. Панели не ссылаются друг на друга напрямую.
На изображенной диаграмме объектов показан мгновенный снимок работающего приложения.
В Smalltalk/V для обмена информацией между объектами Pane и ViewManager используется механизм событий. Панель генерирует событие для получения данных от своего посредника или для информирования его о чем-то важном. С каждым событием связан символ (например, #select), который однозначно его идентифицирует. Диспетчер видов регистрирует вместе с панелью селектор метода, который является обработчиком события. Из следующего фрагмента кода видно, как объект ListPane создается внутри подкласса ViewManager и как ViewManager регистрирует обработчик события #select:
self addSubpane: (ListPane new
paneName: ‘myListPane’;
owner: self;
when: #select perform: #listSelect:).
При координации сложных обновлений также требуется паттерн посредник. Примером может служить класс ChangeManager, упомянутый в описании паттерна наблюдатель. Этот класс осуществляет посредничество между субъектами и наблюдателями, чтобы не делать лишних обновлений. Когда объект изменяется, он извещает ChangeManager, который координирует обновление и информирует все необходимые объекты.
Аналогичным образом посредник применяется в графических редакторах Unidraw [VL90], где используется класс CSolver, следящий за соблюдением ограничений связанности между коннекторами. Объекты в графических редакторах могут быть визуально соединены между собой различными способами. Коннекторы полезны в приложениях, которые автоматически поддерживают связанность, например в редакторах диаграмм и в системах проектирования электронных схем. Класс CSolver является посредником между коннекторами. Он разрешает ограничения связанности и обновляет позиции коннекторов так, чтобы отразить изменения.
Фасад отличается от посредника тем, что абстрагирует некоторую подсистему объектов для предоставления более удобного интерфейса. Его протокол однонаправленный, то есть объекты фасада направляют запросы классам подсистемы, но не наоборот. Посредник же обеспечивает совместное поведение, которое объекты-коллеги не могут или не «хотят» реализовывать, и его протокол двунаправленный.
Коллеги могут обмениваться информацией с посредником посредством паттерна наблюдатель.
Хранитель – паттерн поведения объектов.
Не нарушая инкапсуляции, фиксирует и выносит за пределы объекта его внутреннее состояние так, чтобы позднее можно было восстановить в нем объект.
Token (лексема).
Иногда необходимо тем или иным способом зафиксировать внутреннее состояние объекта. Такая потребность возникает, например, при реализации контрольных точек и механизмов отката, позволяющих пользователю отменить пробную операцию или восстановить состояние после ошибки. Его необходимо где-то сохранить, чтобы позднее восстановить в нем объект. Но обычно объекты инкапсулируют все свое состояние или хотя бы его часть, делая его недоступным для других объектов, так что сохранить состояние извне невозможно. Раскрытие же состояния явилось бы нарушением принципа инкапсуляции и поставило бы под угрозу надежность
и расширяемость приложения.
Рассмотрим, например, графический редактор, который поддерживает связанность объектов. Пользователь может соединить два прямоугольника линией, и они останутся в таком положении при любых перемещениях. Редактор сам перерисовывает линию, сохраняя связанность конфигурации.
Система разрешения ограничений – хорошо известный способ поддержания связанности между объектами. Ее функции могут выполняться объектом класса ConstraintSolver, который регистрирует вновь создаваемые соединения и генерирует описывающие их математические уравнения. А когда пользователь каким-то образом модифицирует диаграмму, объект решает эти уравнения. Результаты вычислений объект ConstraintSolver использует для перерисовки графики так, чтобы были сохранены все соединения.
Поддержка отката операций в приложениях не так проста, как может показаться на первый взгляд. Очевидный способ откатить операцию перемещения – это сохранить расстояние между старым
и новым положением, а затем переместить объект на такое же расстояние назад. Однако при этом не гарантируется, что все объекты окажутся там же, где находились. Предположим, что в способе расположения соединительной линии есть некоторая свобода. Тогда, переместив прямоугольник на прежнее место, мы можем не добиться желаемого эффекта.
Открытого интерфейса ConstraintSolver иногда не хватает для точного отката всех изменений смежных объектов. Механизм отката должен работать в тесном взаимодействии с ConstraintSolver для восстановления предыдущего состояния, но необходимо также позаботиться о том, чтобы внутренние детали ConstraintSolver не были доступны этому механизму.
Паттерн хранитель поможет решить данную проблему. Хранитель – это объект, в котором сохраняется внутреннее состояния другого объекта – хозяина хранителя. Для работы механизма отката нужно, чтобы хозяин предоставил хранитель, когда возникнет необходимость записать контрольную точку состояния хозяина. Только хозяину разрешено помещать в хранитель информацию и извлекать ее оттуда, для других объектов хранитель непрозрачен.
В примере графического редактора, который обсуждался выше, в роли хозяина может выступать объект ConstraintSolver. Процесс отката характеризуется такой последовательностью событий:
1. Редактор запрашивает хранитель у объекта ConstraintSolver в процессе выполнения операции перемещения.
2. ConstraintSolver создает и возвращает хранитель, в данном случае экземпляр класса SolverState. Хранитель SolverState содержит структуры данных, описывающие текущее состояние внутренних уравнений и переменных ConstraintSolver.
3. Позже, когда пользователь отменяет операцию перемещения, редактор возвращает SolverState объекту ConstraintSolver.
4. Основываясь на информации, которая хранится в объекте SolverState, ConstraintSolver изменяет свои внутренние структуры, возвращая уравнения и переменные в первоначальное состояние.
Такая организация позволяет объекту ConstraintSolver «знакомить» другие объекты с информацией, которая ему необходима для возврата в предыдущее состояние, не раскрывая в то же время свою структуру и представление.
Используйте паттерн хранитель, когда:
• необходимо сохранить мгновенный снимок состояния объекта (или его части), чтобы впоследствии объект можно было восстановить в том же состоянии;
• прямое получение этого состояния раскрывает детали реализации и нарушает инкапсуляцию объекта.
• Memento (SolverState) – хранитель:
– сохраняет внутреннее состояние объекта Originator. Объем сохраняемой информации может быть различным и определяется потребностями хозяина;
– запрещает доступ всем другим объектам, кроме хозяина. По существу,
у хранителей есть два интерфейса. «Посыльный» Caretaker «видит» лишь «узкий» интерфейс хранителя – он может только передавать хранителя другим объектам. Напротив, хозяину доступен «широкий» интерфейс, который обеспечивает доступ ко всем данным, необходимым для восстановления в прежнем состоянии. Идеальный вариант – когда только хозяину, создавшему хранитель, открыт доступ к внутреннему состоянию последнего;
• Originator (ConstraintSolver) – хозяин:
– создает хранитель, содержащего снимок текущего внутреннего состояния;
– использует хранитель для восстановления внутреннего состояния;
• Caretaker (механизм отката) – посыльный:
– отвечает за сохранение хранителя;
– не производит никаких операций над хранителем и не исследует его внутреннее содержимое.
• посыльный запрашивает хранитель у хозяина, некоторое время держит его у себя, а затем возвращает хозяину, как видно на представленной диаграмме взаимодействий.
Иногда этого не происходит, так как последнему не нужно восстанавливать прежнее состояние;
• хранители пассивны. Только хозяин, создавший хранитель, имеет доступ
к информации о состоянии.
Характерные особенности паттерна хранитель:
• сохранение границ инкапсуляции. Хранитель позволяет избежать раскрытия информации, которой должен распоряжаться только хозяин, но которую тем не менее необходимо хранить вне последнего. Этот паттерн экранирует объекты от потенциально сложного внутреннего устройства хозяина, не изменяя границы инкапсуляции;
• упрощение структуры хозяина. При других вариантах дизайна, направленного на сохранение границ инкапсуляции, хозяин хранит внутри себя версии внутреннего состояния, которое запрашивали клиенты. Таким образом, вся ответственность за управление памятью лежит на хозяине. При перекладывании заботы о запрошенном состоянии на клиентов упрощается структура хозяина, а клиентам дается возможность не информировать хозяина
о том, что они закончили работу;
• значительные издержки при использовании хранителей. С хранителями могут быть связаны заметные издержки, если хозяин должен копировать большой объем информации для занесения в память хранителя или если клиенты создают и возвращают хранителей достаточно часто. Если плата за инкапсуляцию и восстановление состояния хозяина велика, то этот паттерн не всегда подходит (см. также обсуждение инкрементности в разделе «Реализация»);
• определение «узкого» и «широкого» интерфейсов. В некоторых языках сложно гарантировать, что только хозяин имеет доступ к состоянию хранителя;
• скрытая плата за содержание хранителя. Посыльный отвечает за удаление хранителя, однако не располагает информацией о том, какой объем информации о состоянии скрыт в нем. Поэтому нетребовательный к ресурсам посыльный может расходовать очень много памяти при работе с хранителем.
При реализации паттерна хранитель следует иметь в виду:
• языковую поддержку. У хранителей есть два интерфейса: «широкий» для хозяев и «узкий» для всех остальных объектов. В идеале язык реализации должен поддерживать два уровня статического контроля доступа. В C++ это возможно, если объявить хозяина другом хранителя и сделать закрытым «широкий» интерфейс последнего (с помощью ключевого слова private). Открытым (public) остается только «узкий» интерфейс. Например:
class State;
class Originator {
public:
Memento* CreateMemento();
void SetMemento(const Memento*);
// ...
private:
State* _state; // внутренние структуры данных
// ...
};
class Memento {
public:
// узкий открытый интерфейс
virtual ~Memento();
private:
// закрытые члены доступны только хозяину Originator
friend class Originator;
Memento();
void SetState(State*);
State* GetState();
// ...
private:
State* _state;
// ...
};
• сохранение инкрементых изменений. Если хранители создаются и возвращаются своему хозяину в предсказуемой последовательности, то хранитель может сохранить лишь изменения во внутреннем состоянии хозяина.
Например, допускающие отмену команды в списке истории могут пользоваться хранителями для восстановления первоначального состояния (см. описание паттерна команда). Список истории предназначен только для отмены и повтора команд. Это означает, что хранители могут работать лишь
с изменениями, сделанными командой, а не с полным состоянием объекта.
В примере из раздела «Мотивация» объект, отменяющий ограничения, может содержать только такие внутренние структуры, которые изменяются
с целью сохранить линию, соединяющую прямоугольники, а не абсолютные позиции всех объектов.
Приведенный пример кода на языке C++ иллюстрирует рассмотренный выше пример класса ConstraintSolver для разрешения ограничений. Мы используем объекты MoveCommand (см. паттерн команда) для выполнения и отмены переноса графического объекта из одного места в другое. Графический редактор вызывает операцию Execute объекта-команды, чтобы переместить объект, и команду Unexecute, чтобы отменить перемещение. В команде хранятся координаты места назначения, величина перемещения и экземпляр класса ConstraintSolverMemento – хранителя, содержащего состояние объекта ConstraintSolver:
class Graphic;
// базовый класс графических объектов
class MoveCommand {
public:
MoveCommand(Graphic* target, const Point& delta);
void Execute();
void Unexecute();
private:
ConstraintSolverMemento* _state;
Point _delta;
Graphic* _target;
};
Ограничения связанности устанавливаются классом ConstraintSolver. Его основная функция-член, называемая Solve, отменяет ограничения, регистрируемые операцией AddConstraint. Для поддержки отмены действий состояние объекта ConstraintSolver можно разместить в экземпляре класса ConstraintSolverMemento с помощью операции CreateMemento. В предыдущее состояние объект ConstraintSolver возвращается посредством операции SetMemento. ConstraintSolver является примером паттерна одиночка:
class ConstraintSolver {
public:
static ConstraintSolver* Instance();
void Solve();
void AddConstraint(
Graphic* startConnection, Graphic* endConnection
);
void RemoveConstraint(
Graphic* startConnection, Graphic* endConnection
);
ConstraintSolverMemento* CreateMemento();
void SetMemento(ConstraintSolverMemento*);
private:
// нетривиальное состояние и операции
// для поддержки семантики связанности
};
class ConstraintSolverMemento {
public:
virtual ~ConstraintSolverMemento();
private:
friend class ConstraintSolver;
ConstraintSolverMemento();
// закрытое состояние Solver
};
С такими интерфейсами мы можем реализовать функции-члены Execute
и Unexecute в классе MoveCommand следующим образом:
void MoveCommand::Execute () {
ConstraintSolver* solver = ConstraintSolver::Instance();
_state = solver->CreateMemento(); // создание хранителя
_target->Move(_delta);
solver->Solve();
}
void MoveCommand::Unexecute () {
ConstraintSolver* solver = ConstraintSolver::Instance();
_target->Move(-_delta);
solver->SetMemento(_state); // восстановление состояния хозяина
solver->Solve();
}
Execute запрашивает хранитель ConstraintSolverMemento перед началом перемещения графического объекта. Unexecute возвращает объект на прежнее место, восстанавливает состояние Solver и обращается к последнему с целью отменить ограничения.
Предыдущий пример основан на поддержке связанности в каркасе Unidraw
с помощью класса CSolver [VL90].
В коллекциях языка Dylan [App92] для итерации предусмотрен интерфейс, напоминающий паттерн хранитель. Для этих коллекций существует понятие состояния объекта, которое является хранителем, представляющим состояние итерации. Представление текущего состояния каждой коллекции может быть любым, но оно полностью скрыто от клиентов. Решение, используемое в языке Dylan, можно написать на C++ следующим образом:
template <class Item>
class Collection {
public:
Collection();
IterationState* CreateInitialState();
void Next(IterationState*);
bool IsDone(const IterationState*) const;
Item CurrentItem(const IterationState*) const;
IterationState* Copy(const IterationState*) const;
void Append(const Item&);
void Remove(const Item&);
// ...
};
Операция CreateInitialState возвращает инициализированный объект IterationState для коллекции. Операция Next переходит к следующему объекту в порядке итерации, по сути дела, она увеличивает на единицу индекс итерации. Операция IsDone возвращает true, если в результате выполнения Next мы оказались за последним элементом коллекции. Операция CurrentItem разыменовывает объект состояния и возвращает тот элемент коллекции, на который он ссылается. Copy возвращает копию данного объекта состояния. Это имеет смысл, когда необходимо оставить закладку в некотором месте, пройденном во время итерации.
Если есть класс ItemType, то обойти коллекцию, составленную из его экземпляров, можно так:1
class ItemType {
public:
void Process();
// ...
};
Collection<ItemType*> aCollection;
IterationState* state;
state = aCollection.CreateInitialState();
while (!aCollection.IsDone(state)) {
aCollection.CurrentItem(state)->Process();
aCollection.Next(state);
}
delete state;
У интерфейса итерации, основанного на паттерне хранитель, есть два преимущества:
• с одной коллекцией может быть связано несколько активных состояний (то же самое верно и для паттерна итератор);
• не требуется нарушать инкапсуляцию коллекции для поддержки итерации. Хранитель интерпретируется только самой коллекцией, больше никто к нему доступа не имеет. При других подходах приходится нарушать инкапсуляцию, объявляя классы итераторов друзьями классов коллекций (см. описание паттерна итератор). В случае с хранителем ситуация противоположная: класс коллекции Collection является другом класса IteratorState.
В библиотеке QOCA для разрешения ограничений в хранителях содержится информация об изменениях. Клиент может получить хранитель, характеризующий текущее решение системы ограничений. В хранителе находятся только те переменные ограничений, которые были преобразованы со времени последнего решения. Обычно при каждом новом решении изменяется лишь небольшое подмножество переменных Solver. Но этого достаточно, чтобы вернуть Solver
к предыдущему решению; для отката к более ранним решениям необходимо иметь все промежуточные хранители. Поэтому передавать хранители в произвольном порядке нельзя. QOCA использует механизм ведения истории для возврата к прежним решениям.
Команда: команды помещают информацию о состоянии, необходимую для отмены выполненных действий, в хранители.
Итератор: хранители можно использовать для итераций, как было показано выше.
Наблюдатель – паттерн поведения объектов.
Определяет зависимость типа «один ко многим» между объектами таким образом, что при изменении состояния одного объекта все зависящие от него оповещаются об этом и автоматически обновляются.
Dependents (подчиненные), Publish-Subscribe (издатель-подписчик).
В результате разбиения системы на множество совместно работающих классов появляется необходимость поддерживать согласованное состояние взаимосвязанных объектов. Но не хотелось бы, чтобы за согласованность надо было платить жесткой связанностью классов, так как это в некоторой степени уменьшает возможности повторного использования.
Например, во многих библиотеках для построения графических интерфейсов пользователя презентационные аспекты интерфейса отделены от данных приложения [KP88, LVC89, P+88, WGM88]. С классами, описывающими данные и их представление, можно работать автономно. Электронная таблица и диаграмма не имеют информации друг о друге, поэтому вы вправе использовать их по отдельности. Но ведут они себя так, как будто «знают» друг о друге. Когда пользователь работает с таблицей, все изменения немедленно отражаются на диаграмме, и наоборот.
При таком поведении подразумевается, что и электронная таблица, и диаграмма зависят от данных объекта и поэтому должны уведомляться о любых изменениях в его состоянии. И нет никаких причин, ограничивающих количество зависимых объектов; для работы с одними и теми же данными может существовать любое число пользовательских интерфейсов.
Паттерн наблюдатель описывает, как устанавливать такие отношения. Ключевыми объектами в нем являются субъект и наблюдатель. У субъекта может быть сколько угодно зависимых от него наблюдателей. Все наблюдатели уведомляются об изменениях в состоянии субъекта. Получив уведомление, наблюдатель опрашивает субъекта, чтобы синхронизировать с ним свое состояние.
Такого рода взаимодействие часто называется отношением издатель-подписчик. Субъект издает или публикует уведомления и рассылает их, даже не имея информации о том, какие объекты являются подписчиками. На получение уведомлений может подписаться неограниченное количество наблюдателей.
Используйте паттерн наблюдатель в следующих ситуациях:
• когда у абстракции есть два аспекта, один из которых зависит от другого. Инкапсуляции этих аспектов в разные объекты позволяют изменять и повторно использовать их независимо;
• когда при модификации одного объекта требуется изменить другие и вы не знаете, сколько именно объектов нужно изменить;
• когда один объект должен оповещать других, не делая предположений об уведомляемых объектах. Другими словами, вы не хотите, чтобы объекты были тесно связаны между собой.
• Subject – субъект:
– располагает информацией о своих наблюдателях. За субъектом может «следить» любое число наблюдателей;
– предоставляет интерфейс для присоединения и отделения наблюдателей;
• Observer – наблюдатель:
– определяет интерфейс обновления для объектов, которые должны быть уведомлены об изменении субъекта;
• ConcreteSubject – конкретный субъект:
– сохраняет состояние, представляющее интерес для конкретного наблюдателя ConcreteObserver;
– посылает информацию своим наблюдателям, когда происходит изменение;
• ConcreteObserver – конкретный наблюдатель:
– хранит ссылку на объект класса ConcreteSubject;
– сохраняет данные, которые должны быть согласованы с данными субъекта;
– реализует интерфейс обновления, определенный в классе Observer, чтобы поддерживать согласованность с субъектом.
• объект ConcreteSubject уведомляет своих наблюдателей о любом изменении, которое могло бы привести к рассогласованности состояний наблюдателя и субъекта;
• после получения от конкретного субъекта уведомления об изменении объект ConcreteObserver может запросить у субъекта дополнительную
информацию, которую использует для того, чтобы оказаться в состоянии, согласованном с состоянием субъекта.
На диаграмме взаимодействий показаны отношения между субъектом и двумя наблюдателями.
Отметим, что объект Observer, который инициирует запрос на изменение, откладывает свое обновление до получения уведомления от субъекта. Операция Notify не всегда вызывается субъектом. Ее может вызвать и наблюдатель, и посторонний объект. В разделе «Реализация» обсуждаются часто встречающиеся варианты.
Паттерн наблюдатель позволяет изменять субъекты и наблюдатели независимо друг от друга. Субъекты разрешается повторно использовать без участия наблюдателей, и наоборот. Это дает возможность добавлять новых наблюдателей без модификации субъекта или других наблюдателей.
Рассмотрим некоторые достоинства и недостатки паттерна наблюдатель:
• абстрактная связанность субъекта и наблюдателя. Субъект имеет информацию лишь о том, что у него есть ряд наблюдателей, каждый из которых подчиняется простому интерфейсу абстрактного класса Observer. Субъекту неизвестны конкретные классы наблюдателей. Таким образом, связи между субъектами и наблюдателями носят абстрактный характер и сведены к минимуму.
Поскольку субъект и наблюдатель не являются тесно связанными, то они могут находиться на разных уровнях абстракции системы. Субъект более низкого уровня может уведомлять наблюдателей, находящихся на верхних уровнях, не нарушая иерархии системы. Если бы субъект и наблюдатель представляли собой единое целое, то получающийся объект либо пересекал бы границы уровней (нарушая принцип их формирования), либо должен был находиться на каком-то одном уровне (компрометируя абстракцию уровня);
• поддержка широковещательных коммуникаций. В отличие от обычного запроса для уведомления, посылаемого субъектом, не нужно задавать определенного получателя. Уведомление автоматически поступает всем подписавшимся на него объектам. Субъекту не нужна информация о количестве таких объектов, от него требуется всего лишь уведомить своих наблюдателей. Поэтому мы можем в любое время добавлять и удалять наблюдателей. Наблюдатель сам решает, обработать полученное уведомление или игнорировать его;
• неожиданные обновления. Поскольку наблюдатели не располагают информацией друг о друге, им неизвестно и о том, во что обходится изменение субъекта. Безобидная, на первый взгляд, операция над субъектом может вызвать целый ряд обновлений наблюдателей и зависящих от них объектов. Более того, нечетко определенные или плохо поддерживаемые критерии зависимости могут стать причиной непредвиденных обновлений, отследить которые очень сложно.
Эта проблема усугубляется еще и тем, что простой протокол обновления не содержит никаких сведений о том, что именно изменилось в субъекте. Без дополнительного протокола, помогающего выяснить характер изменений, наблюдатели будут вынуждены проделать сложную работу для косвенного получения такой информации.
В этом разделе обсуждаются вопросы, относящиеся к реализации механизма зависимостей:
• отображение субъектов на наблюдателей. С помощью этого простейшего способа субъект может отследить всех наблюдателей, которым он должен посылать уведомления, то есть хранить на них явные ссылки. Однако при наличии большого числа субъектов и всего нескольких наблюдателей это может оказаться накладно. Один из возможных компромиссов в пользу экономии памяти за счет времени состоит в том, чтобы использовать ассоциативный массив (например, хэш-таблицу) для хранения отображения между субъектами и наблюдателями. Тогда субъект, у которого нет наблюдателей, не будет зря расходовать память. С другой стороны, при таком подходе увеличивается время поиска наблюдателей;
• наблюдение более чем за одним субъектом. Иногда наблюдатель может зависеть более чем от одного субъекта. Например, у электронной таблицы бывает более одного источника данных. В таких случаях необходимо расширить интерфейс Update, чтобы наблюдатель мог «узнать», какой субъект прислал уведомление. Субъект может просто передать себя в качестве параметра операции Update, тем самым сообщая наблюдателю, что именно нужно обследовать;
• кто инициирует обновление. Чтобы сохранить согласованность, субъект
и его наблюдатели полагаются на механизм уведомлений. Но какой именно объект вызывает операцию Notify для инициирования обновления? Есть два варианта:
– операции класса Subject, изменившие состояние, вызывают Notify для уведомления об этом изменении. Преимущество такого подхода в том, что клиентам не надо помнить о необходимости вызывать операцию Notify субъекта. Недостаток же заключается в следующем: при выполнении каждой из нескольких последовательных операций будут производиться обновления, что может стать причиной неэффективной работы программы;
– ответственность за своевременный вызов Notify возлагается на клиента. Преимущество: клиент может отложить инициирование обновления до завершения серии изменений, исключив тем самым ненужные промежуточные обновления. Недостаток: у клиентов появляется дополнительная обязанность. Это увеличивает вероятность ошибок, поскольку клиент может забыть вызвать Notify;
• висячие ссылки на удаленные субъекты. Удаление субъекта не должно приводить к появлению висячих ссылок у наблюдателей. Избежать этого можно, например, поручив субъекту уведомлять все свои наблюдатели о своем удалении, чтобы они могли уничтожить хранимые у себя ссылки. В общем случае простое удаление наблюдателей не годится, так как на них могут
ссылаться другие объекты и под их наблюдением могут находиться другие субъекты;
• гарантии непротиворечивости состояния субъекта перед отправкой уведомления. Важно быть уверенным, что перед вызовом операции Notify состояние субъекта непротиворечиво, поскольку в процессе обновления собственного состояния наблюдатели будут опрашивать состояние субъекта.
Правило непротиворечивости очень легко нарушить, если операции одного из подклассов класса Subject вызывают унаследованные операции. Например, в следующем фрагменте уведомление отправляется, когда состояние субъекта противоречиво:
void MySubject::Operation (int newValue) {
BaseClassSubject::Operation(newValue);
// отправить уведомление
_myInstVar += newValue;
// обновить состояние подкласса (слишком поздно!)
}
Избежать этой ловушки можно, отправляя уведомления из шаблонных методов (см. описание паттерна шаблонный метод) абстрактного класса Subject. Определите примитивную операцию, замещаемую в подклассах, и обратитесь к Notify, используя последнюю операцию в шаблонном методе. В таком случае существует гарантия, что состояние объекта непротиворечиво, если операции Subject замещены в подклассах:
void Text::Cut (TextRange r) {
ReplaceRange(r); // переопределена в подклассах
Notify();
}
Кстати, всегда желательно фиксировать, какие операции класса Subject инициируют обновления;
• как избежать зависимости протокола обновления от наблюдателя: модели вытягивания и проталкивания. В реализациях паттерна наблюдатель субъект довольно часто транслирует всем подписчикам дополнительную информацию о характере изменения. Она передается в виде аргумента операции Update, и объем ее меняется в широких диапазонах.
На одном полюсе находится так называемая модель проталкивания (push model), когда субъект посылает наблюдателям детальную информацию об изменении независимо от того, нужно ли им это. На другом – модель вытягивания (pull model), когда субъект не посылает ничего, кроме минимального уведомления, а наблюдатели запрашивают детали позднее.
В модели вытягивания подчеркивается неинформированность субъекта о своих наблюдателях, а в модели проталкивания предполагается, что субъект владеет определенной информацией о потребностях наблюдателей. В случае применения модели проталкивания степень повторного их использования может снизиться, так как классы Subject предполагают о классах Observer, которые не всегда могут быть верны. С другой стороны, модель вытягивания может оказаться неэффективной, ибо наблюдателям без помощи субъекта необходимо выяснять, что изменилось;
• явное специфицирование представляющих интерес модификаций. Эффективность обновления можно повысить, расширив интерфейс регистрации субъекта, то есть предоставив возможность при регистрации наблюдателя указать, какие события его интересуют. Когда событие происходит, субъект информирует лишь тех наблюдателей, которые проявили к нему интерес. Чтобы получать конкретное событие, наблюдатели присоединяются к своим субъектам следующим образом:
void Subject::Attach(Observer*, Aspect& interest);
где interest определяет представляющее интерес событие. В момент посылки уведомления субъект передает своим наблюдателям изменившийся аспект в виде параметра операции Update. Например:
void Observer::Update(Subject*, Aspect& interest);
• инкапсуляция сложной семантики обновления. Если отношения зависимости между субъектами и наблюдателями становятся особенно сложными, то может потребоваться объект, инкапсулирующий эти отношения. Будем называть его ChangeManager (менеджер изменений). Он служит для минимизации объема работы, необходимой для того чтобы наблюдатели смогли отразить изменения субъекта. Например, если некоторая операция влечет за собой изменения в нескольких независимых субъектах, то хотелось бы, чтобы наблюдатели уведомлялись после того, как будут модифицированы все субъекты, дабы не ставить в известность одного и того же наблюдателя несколько раз.
У класса ChangeManager есть три обязанности:
– строить отображение между субъектом и его наблюдателями и предоставлять интерфейс для поддержания отображения в актуальном состоянии. Это освобождает субъектов от необходимости хранить ссылки на своих наблюдателей и наоборот;
– определять конкретную стратегию обновления;
– обновлять всех зависимых наблюдателей по запросу от субъекта.
На следующей диаграмме представлена простая реализация паттерна наблюдатель с использованием менеджера изменений ChangeManager. Имеется два специализированных менеджера. SimpleChangeManager всегда обновляет всех наблюдателей каждого субъекта, а DAGChangeManager обрабатывает направленные ациклические графы зависимостей между субъектами и их наблюдателями. Когда наблюдатель должен «присматривать» за несколькими субъектами, предпочтительнее использовать DAGChangeManager. В этом случае изменение сразу двух или более субъектов может привести к избыточным обновлениям. Объект DAGChangeManager гарантирует, что наблюдатель в любом случае получит только одно уведомление. Если обновление одного
и того же наблюдателя допускается несколько раз подряд, то вполне достаточно объекта SimpleChangeManager.
ChangeManager – это пример паттерна посредник. В общем случае есть только один объект ChangeManager, известный всем участникам. Поэтому полезен будет также и паттерн одиночка;
• комбинирование классов Subject и Observer. В библиотеках классов, которые написаны на языках, не поддерживающих множественного наследования (например, на Smalltalk), обычно не определяются отдельные классы Subject
и Observer. Их интерфейсы комбинируются в одном классе. Это позволяет определить объект, выступающий в роли одновременно субъекта
и наблюдателя, без множественного наследования. Так, в Smalltalk интерфейсы Subject и Observer определены в корневом классе Object и потому доступны вообще всем классам.
Интерфейс наблюдателя определен в абстрактном классе Observer:
class Subject;
class Observer {
public:
virtual ~Observer();
virtual void Update(Subject* theChangedSubject) = 0;
protected:
Observer();
};
При такой реализации поддерживается несколько субъектов для одного наблюдателя. Передача субъекта параметром операции Update позволяет наблюдателю определить, какой из наблюдаемых им субъектов изменился.
Таким же образом в абстрактном классе Subject определен интерфейс субъекта:
class Subject {
public:
virtual ~Subject();
virtual void Attach(Observer*);
virtual void Detach(Observer*);
virtual void Notify();
protected:
Subject();
private:
List<Observer*> *_observers;
};
void Subject::Attach (Observer* o) {
_observers->Append(o);
}
void Subject::Detach (Observer* o) {
_observers->Remove(o);
}
void Subject::Notify () {
ListIterator<Observer*> i(_observers);
for (i.First(); !i.IsDone(); i.Next()) {
i.CurrentItem()->Update(this);
}
}
ClockTimer – это конкретный субъект, который следит за временем суток. Он извещает наблюдателей каждую секунду. Класс ClockTimer предоставляет интерфейс для получения отдельных компонентов времени: часа, минуты, секунды и т.д.:
class ClockTimer : public Subject {
public:
ClockTimer();
virtual int GetHour();
virtual int GetMinute();
virtual int GetSecond();
void Tick();
};
Операция Tick вызывается через одинаковые интервалы внутренним таймером. Тем самым обеспечивается правильный отсчет времени. При этом обновляется внутреннее состояние объекта ClockTimer и вызывается операция Notify для извещения наблюдателей об изменении:
void ClockTimer::Tick () {
// обновить внутреннее представление о времени
// ...
Notify();
}
Теперь мы можем определить класс DigitalClock, который отображает время. Свою графическую функциональность он наследует от класса Widget, предоставляемого библиотекой для построения пользовательских интерфейсов. Интерфейс наблюдателя подмешивается к интерфейсу DigitalClock путем наследования от класса Observer:
class DigitalClock: public Widget, public Observer {
public:
DigitalClock(ClockTimer*);
virtual ~DigitalClock();
virtual void Update(Subject*);
// замещает операцию класса Observer
virtual void Draw();
// замещает операцию класса Widget;
// определяет способ изображения часов
private:
ClockTimer* _subject;
};
DigitalClock::DigitalClock (ClockTimer* s) {
_subject = s;
_subject->Attach(this);
}
DigitalClock::~DigitalClock () {
_subject->Detach(this);
}
Прежде чем начнется рисование часов посредством операции Update, будет проверено, что уведомление получено именно от объекта таймера:
void DigitalClock::Update (Subject* theChangedSubject) {
if (theChangedSubject == _subject) {
Draw();
}
}
void DigitalClock::Draw () {
// получить новые значения от субъекта
int hour = _subject->GetHour();
int minute = _subject->GetMinute();
// и т.д.
// нарисовать цифровые часы
}
Аналогично можно определить класс AnalogClock:
class AnalogClock : public Widget, public Observer {
public:
AnalogClock(ClockTimer*);
virtual void Update(Subject*);
virtual void Draw();
// ...
};
Следующий код создает объекты классов AnalogClock и DigitalClock, которые всегда показывают одно и то же время:
ClockTimer* timer = new ClockTimer;
AnalogClock* analogClock = new AnalogClock(timer);
DigitalClock* digitalClock = new DigitalClock(timer);
При каждом срабатывании таймера timer оба экземпляра часов обновляются и перерисовывают себя.
Первый и, возможно, самый известный пример паттерна наблюдатель появился в схеме модель/вид/контроллер (MVC) языка Smalltalk, которая представляет собой каркас для построения пользовательских интерфейсов в среде Smalltalk [KP88]. Класс Model в MVC – это субъект, а View – базовый класс для наблюдателей. В языках Smalltalk, ET++ [WGM88] и библиотеке классов THINK [Sym93b] предлагается общий механизм зависимостей, в котором интерфейсы субъекта и наблюдателя помещены в класс, являющийся общим родителем всех остальных системных классов.
Среди других библиотек для построения интерфейсов пользователя, в которых используется паттерн наблюдатель, стоит упомянуть InterViews [LVC89], Andrew Toolkit [P+88] и Unidraw [VL90]. В InterViews явно определены классы Observer и Observable (для субъектов). В библиотеке Andrew они называются видом (view) и объектом данных (data object) соответственно. Unidraw делит объекты графического редактора на части View (для наблюдателей) и Subject.
Посредник: класс ChangeManager действует как посредник между субъектами и наблюдателями, инкапсулируя сложную семантику обновления.
Одиночка: класс ChangeManager может воспользоваться паттерном одиночка, чтобы гарантировать уникальность и глобальную доступность менеджера изменений.
Состояние – паттерн поведения объектов.
Позволяет объекту варьировать свое поведение в зависимости от внутреннего состояния. Извне создается впечатление, что изменился класс объекта.
Рассмотрим класс TCPConnection, с помощью которого представлено сетевое соединение. Объект этого класса может находиться в одном из нескольких состояний: Established (установлено), Listening (прослушивание), Closed (закрыто). Когда объект TCPConnection получает запросы от других объектов, то в зависимости от текущего состояния он отвечает по-разному. Например, ответ на запрос Open (открыть) зависит от того, находится ли соединение в состоянии Closed или Established. Паттерн состояние описывает, каким образом объект TCPConnection может вести себя по-разному, находясь в различных состояниях.
Основная идея этого паттерна заключается в том, чтобы ввести абстрактный класс TCPState для представления различных состояний соединения. Этот класс объявляет интерфейс, общий для всех классов, описывающих различные рабочие состояния. В подклассах TCPState реализовано поведение, специфичное для конкретного состояния. Например, в классах TCPEstablished и TCPClosed реализовано поведение, характерное для состояний Established и Closed соответственно.
Класс TCPConnection хранит у себя объект состояния (экземпляр некоторого подкласса TCPState), представляющий текущее состояние соединения, и делегирует все зависящие от состояния запросы этому объекту. TCPConnection использует свой экземпляр подкласса TCPState для выполнения операций, свойственных только данному состоянию соединения.
При каждом изменении состояния соединения TCPConnection изменяет свой объект-состояние. Например, когда установленное соединение закрывается, TCPConnection заменяет экземпляр класса TCPEstablished экземпляром TCPClosed.
Используйте паттерн состояние в следующих случаях:
• когда поведение объекта зависит от его состояния и должно изменяться во время выполнения;
• когда в коде операций встречаются состоящие из многих ветвей условные операторы, в которых выбор ветви зависит от состояния. Обычно в таком случае состояние представлено перечисляемыми константами. Часто одна и та же структура условного оператора повторяется в нескольких операциях. Паттерн состояние предлагает поместить каждую ветвь в отдельный класс. Это позволяет трактовать состояние объекта как самостоятельный объект, который может изменяться независимо от других.
• Context (TCPConnection) – контекст:
– определяет интерфейс, представляющий интерес для клиентов;
– хранит экземпляр подкласса ConcreteState, которым определяется текущее состояние;
• State (TCPState) – состояние:
– определяет интерфейс для инкапсуляции поведения, ассоциированного
с конкретным состоянием контекста Context;
• Подклассы ConcreteState (TCPEstablished, TCPListen, TCPClosed) – конкретное состояние:
– каждый подкласс реализует поведение, ассоциированное с некоторым состоянием контекста Context.
• класс Context делегирует зависящие от состояния запросы текущему объекту ConcreteState;
• контекст может передать себя в качестве аргумента объекту State, который будет обрабатывать запрос. Это дает возможность объекту-состоянию при необходимости получить доступ к контексту;
• Context – это основной интерфейс для клиентов. Клиенты могут конфигурировать контекст объектами состояния State. Один раз сконфигурировав контекст, клиенты уже не должны напрямую связываться с объектами состояния;
• либо Context, либо подклассы ConcreteState могут решить, при каких условиях и в каком порядке происходит смена состояний.
Результаты использования паттерна состояние:
• локализует зависящее от состояния поведение и делит его на части, соответствующие состояниям. Паттерн состояние помещает все поведение, ассоциированное с конкретным состоянием, в отдельный объект. Поскольку зависящий от состояния код целиком находится в одном из подклассов класса State, то добавлять новые состояния и переходы можно просто путем порождения новых подклассов.
Вместо этого можно было бы использовать данные-члены для определения внутренних состояний, тогда операции объекта Context проверяли бы эти данные. Но в таком случае похожие условные операторы или операторы ветвления были бы разбросаны по всему коду класса Context. При этом добавление нового состояния потребовало бы изменения нескольких операций, что затруднило бы сопровождение.
Паттерн состояние позволяет решить эту проблему, но одновременно порождает другую, поскольку поведение для различных состояний оказывается распределенным между несколькими подклассами State. Это увеличивает число классов. Конечно, один класс компактнее, но если состояний много, то такое распределение эффективнее, так как в противном случае пришлось бы иметь дело с громоздкими условными операторами.
Наличие громоздких условных операторов нежелательно, равно как и наличие длинных процедур. Они слишком монолитны, вот почему модификация и расширение кода становится проблемой. Паттерн состояние предлагает более удачный способ структурирования зависящего от состояния кода. Логика, описывающая переходы между состояниями, больше не заключена
в монолитные операторы if или switch, а распределена между подклассами State. При инкапсуляции каждого перехода и действия в класс состояние становится полноценным объектом. Это улучшает структуру кода и проясняет его назначение;
• делает явными переходы между состояниями. Если объект определяет свое текущее состояние исключительно в терминах внутренних данных, то переходы между состояниями не имеют явного представления; они проявляются лишь как присваивания некоторым переменным. Ввод отдельных объектов для различных состояний делает переходы более явными. Кроме того, объекты State могут защитить контекст Context от рассогласования внутренних переменных, поскольку переходы с точки зрения контекста – это атомарные действия. Для осуществления перехода надо изменить значение только одной переменной (объектной переменной State в классе Context), а не нескольких [dCLF93];
• объекты состояния можно разделять. Если в объекте состояния State отсутствуют переменные экземпляра, то есть представляемое им состояние кодируется исключительно самим типом, то разные контексты могут разделять один и тот же объект State. Когда состояния разделяются таким образом, они являются, по сути дела, приспособленцами (см. описание паттерна приспособленец), у которых нет внутреннего состояния, а есть только поведение.
С паттерном состояние связан целый ряд вопросов реализации:
• что определяет переходы между состояниями. Паттерн состояние ничего не сообщает о том, какой участник определяет критерий перехода между состояниями. Если критерии зафиксированы, то их можно реализовать непосредственно в классе Context. Однако в общем случае более гибкий и правильный подход заключается в том, чтобы позволить самим подклассам класса State определять следующее состояние и момент перехода. Для этого в класс Context надо добавить интерфейс, позволяющий объектам State установить состояние контекста.
Такую децентрализованную логику переходов проще модифицировать и расширять – нужно лишь определить новые подклассы State. Недостаток децентрализации в том, что каждый подкласс State должен «знать» еще хотя бы об одном подклассе, что вносит реализационные зависимости между подклассами;
• табличная альтернатива. Том Каргилл (Tom Cargill) в книге C++ Programming Style [Car92] описывает другой способ структурирования кода, управляемого сменой состояний. Он использует таблицу для отображения входных данных на переходы между состояниями. С ее помощью можно определить, в какое состояние нужно перейти при поступлении некоторых входных данных. По существу, тем самым мы заменяем условный код (или виртуальные функции, если речь идет о паттерне состояние) поиском в таблице.
Основное преимущество таблиц – в их регулярности: для изменения критериев перехода достаточно модифицировать только данные, а не код. Но есть и недостатки:
– поиск в таблице часто менее эффективен, чем вызов функции (виртуальной);
– представление логики переходов в однородном табличном формате делает критерии менее явными и, стало быть, более сложными для понимания;
– обычно трудно добавить действия, которыми сопровождаются переходы между состояниями. Табличный метод учитывает состояния и переходы между ними, но его необходимо дополнить, чтобы при каждом изменении состоянии можно было выполнять произвольные вычисления.
Главное различие между конечными автоматами на базе таблиц и паттерном состояние можно сформулировать так: паттерн состояние моделирует поведение, зависящее от состояния, а табличный метод акцентирует внимание на определении переходов между состояниями;
• создание и уничтожение объектов состояния. В процессе разработки обычно приходится выбирать между:
– созданием объектов состояния, когда в них возникает необходимость,
и уничтожением сразу после использования;
– созданием их заранее и навсегда.
Первый вариант предпочтителен, когда заранее неизвестно, в какие состояния будет попадать система, и контекст изменяет состояние сравнительно редко. При этом мы не создаем объектов, которые никогда не будут использованы, что существенно, если в объектах состояния хранится много информации. Когда изменения состояния происходят часто, поэтому не хотелось бы уничтожать представляющие их объекты (ибо они могут очень скоро понадобиться вновь), следует воспользоваться вторым подходом. Время на создание объектов затрачивается только один раз, в самом начале, а на уничтожение – не затрачивается вовсе. Правда, этот подход может оказаться неудобным, так как в контексте должны храниться ссылки на все состояния, в которые система теоретически может попасть;
• использование динамического наследования. Варьировать поведение по запросу можно, меняя класс объекта во время выполнения, но в большинстве объектно-ориентированных языков это не поддерживается. Исключение составляет Self [US87] и другие основанные на делегировании языки, которые предоставляют такой механизм и, следовательно, поддерживают паттерн состояние напрямую. Объекты в языке Self могут делегировать операции другим объектам, обеспечивая тем самым некую форму динамического наследования. С изменением целевого объекта делегирования во время выполнения, по существу, изменяется и структура графа наследования. Такой механизм позволяет объектам варьировать поведение путем изменения своего класса.
В следующем примере приведен код на языке C++ с TCP-соединением из раздела «Мотивация». Это упрощенный вариант протокола TCP, в нем, конечно же, представлен не весь протокол и даже не все состояния TCP-соединений.1
Прежде всего определим класс TCPConnection, который предоставляет интерфейс для передачи данных и обрабатывает запросы на изменение состояния:
class TCPOctetStream;
class TCPState;
class TCPConnection {
public:
TCPConnection();
void ActiveOpen();
void PassiveOpen();
void Close();
void Send();
void Acknowledge();
void Synchronize();
void ProcessOctet(TCPOctetStream*);
private:
friend class TCPState;
void ChangeState(TCPState*);
private:
TCPState* _state;
};
В переменной-члене _state класса TCPConnection хранится экземпляр класса TCPState. Этот класс дублирует интерфейс изменения состояния, определенный в классе TCPConnection. Каждая операция TCPState принимает экземпляр TCPConnection как параметр, тем самым позволяя объекту TCPState получить доступ к данным объекта TCPConnection и изменить состояние соединения:
class TCPState {
public:
virtual void Transmit(TCPConnection*, TCPOctetStream*);
virtual void ActiveOpen(TCPConnection*);
virtual void PassiveOpen(TCPConnection*);
virtual void Close(TCPConnection*);
virtual void Synchronize(TCPConnection*);
virtual void Acknowledge(TCPConnection*);
virtual void Send(TCPConnection*);
protected:
void ChangeState(TCPConnection*, TCPState*);
};
TCPConnection делегирует все зависящие от состояния запросы хранимому в _state экземпляру TCPState. Кроме того, в классе TCPConnection существует операция, с помощью которой в эту переменную можно записать указатель на другой объект TCPState. Конструктор класса TCPConnection инициализирует _state указателем на состояние TCPClosed (мы определим его ниже):
TCPConnection::TCPConnection () {
_state = TCPClosed::Instance();
}
void TCPConnection::ChangeState (TCPState* s) {
_state = s;
}
void TCPConnection::ActiveOpen () {
_state->ActiveOpen(this);
}
void TCPConnection::PassiveOpen () {
_state->PassiveOpen(this);
}
void TCPConnection::Close () {
_state->Close(this);
}
void TCPConnection::Acknowledge () {
_state->Acknowledge(this);
}
void TCPConnection::Synchronize () {
_state->Synchronize(this);
}
В классе TCPState реализовано поведение по умолчанию для всех делегированных ему запросов. Он может также изменить состояние объекта TCPConnection
посредством операции ChangeState. TCPState объявляется другом класса TCPConnection, что дает ему привилегированный доступ к этой операции:
void TCPState::Transmit (TCPConnection*, TCPOctetStream*) { }
void TCPState::ActiveOpen (TCPConnection*) { }
void TCPState::PassiveOpen (TCPConnection*) { }
void TCPState::Close (TCPConnection*) { }
void TCPState::Synchronize (TCPConnection*) { }
void TCPState::ChangeState (TCPConnection* t, TCPState* s) {
t->ChangeState(s);
}
В подклассах TCPState реализовано поведение, зависящее от состояния. Соединение TCP может находиться во многих состояниях: Established (установлено), Listening (прослушивание), Closed (закрыто) и т.д., и для каждого из них есть свой подкласс TCPState. Мы подробно рассмотрим три подкласса – TCPEstablished, TCPListen и TCPClosed:
class TCPEstablished : public TCPState {
public:
static TCPState* Instance();
virtual void Transmit(TCPConnection*, TCPOctetStream*);
virtual void Close(TCPConnection*);
};
class TCPListen : public TCPState {
public:
static TCPState* Instance();
virtual void Send(TCPConnection*);
// ...
};
class TCPClosed : public TCPState {
public:
static TCPState* Instance();
virtual void ActiveOpen(TCPConnection*);
virtual void PassiveOpen(TCPConnection*);
// ...
};
В подклассах TCPState нет никакого локального состояния, поэтому их можно разделять, так что потребуется только по одному экземпляру каждого класса. Уникальный экземпляр подкласса TCPState создается обращением к статической операции Instance.1
В подклассах TCPState реализовано зависящее от состояния поведение для тех запросов, которые допустимы в этом состоянии:
void TCPClosed::ActiveOpen (TCPConnection* t) {
// послать SYN, получить SYN, ACK и т.д.
ChangeState(t, TCPEstablished::Instance());
}
void TCPClosed::PassiveOpen (TCPConnection* t) {
ChangeState(t, TCPListen::Instance());
}
void TCPEstablished::Close (TCPConnection* t) {
// послать FIN, получить ACK для FIN
ChangeState(t, TCPListen::Instance());
}
void TCPEstablished::Transmit (
TCPConnection* t, TCPOctetStream* o
) {
t->ProcessOctet(o);
}
void TCPListen::Send (TCPConnection* t) {
// послать SYN, получить SYN, ACK и т.д.
ChangeState(t, TCPEstablished::Instance());
}
После выполнения специфичных для своего состояния действий эти операции вызывают ChangeState для изменения состояния объекта TCPConnection. У него нет никакой информации о протоколе TCP. Именно подклассы TCPState определяют переходы между состояниями и действия, диктуемые протоколом.
Ральф Джонсон и Джонатан Цвейг [JZ91] характеризуют паттерн состояние и описывают его применительно к протоколу TCP.
Наиболее популярные интерактивные программы рисования предоставляют «инструменты» для выполнения операций прямым манипулированием. Например, инструмент для рисования линий позволяет пользователю щелкнуть в произвольной точке мышью, а затем, перемещая мышь, провести из этой точки линию. Инструмент для выбора позволяет выбирать некоторые фигуры. Обычно все имеющиеся инструменты размещаются в палитре. Работа пользователя заключается в том, чтобы выбрать и применить инструмент, но на самом деле поведение редактора варьируется при смене инструмента: посредством инструмента для рисования мы создаем фигуры, при помощи инструмента выбора – выбираем их и т.д. Чтобы отразить зависимость поведения редактора от текущего инструмента, можно воспользоваться паттерном состояние.
Можно определить абстрактный класс Tool, подклассы которого реализуют зависящее от инструмента поведение. Графический редактор хранит ссылку на текущий объект Tool и делегирует ему поступающие запросы. При выборе инструмента редактор использует другой объект, что приводит к изменению поведения.
Данная техника используется в каркасах графических редакторов HotDraw [Joh92] и Unidraw [VL90]. Она позволяет клиентам легко определять новые виды инструментов. В HotDraw класс DrawingController переадресует запросы
текущему объекту Tool. В Unidraw соответствующие классы называются Viewer и Tool. На приведенной ниже диаграмме классов схематично представлены интерфейсы классов Tool и DrawingController.
Описанный Джеймсом Коплиеном [Cop92] прием конверт-письмо (Envelope-Letter) также относится к паттерну состояние. Техника конверт-письмо – это способ изменить класс объекта во время выполнения. Паттерн состояние является частным случаем, в нем акцент делается на работу с объектами, поведение которых зависит от состояния.
Паттерн приспособленец подсказывает, как и когда можно разделять объекты класса State.
Объекты класса State часто бывают одиночками.
Стратегия – паттерн поведения объектов.
Определяет семейство алгоритмов, инкапсулирует каждый из них и делает их взаимозаменяемыми. Стратегия позволяет изменять алгоритмы независимо от клиентов, которые ими пользуются.
Policy (политика).
Существует много алгоритмов для разбиения текста на строки. Жестко «зашивать» все подобные алгоритмы в классы, которые в них нуждаются, нежелательно по нескольким причинам:
• клиент, которому требуется алгоритм разбиения на строки, усложняется при включении в него соответствующего кода. Таким образом, клиенты становятся более громоздкими, а сопровождать их труднее, особенно если нужно поддержать сразу несколько алгоритмов;
• в зависимости от обстоятельств стоит применять тот или иной алгоритм. Не хотелось бы поддерживать несколько алгоритмов разбиения на строки, если мы не будем ими пользоваться;
• если разбиение на строки – неотъемлемая часть клиента, то задача добавления новых и модификации существующих алгоритмов усложняется.
Всех этих проблем можно избежать, если определить классы, инкапсулирующие различные алгоритмы разбиения на строки. Инкапсулированный таким образом алгоритм называется стратегией.
Предположим, что класс Composition отвечает за разбиение на строки текста, отображаемого в окне программы просмотра, и его своевременное обновление. Стратегии разбиения на строки определяются не в классе Composition, а в подклассах абстрактного класса Compositor. Это могут быть, например, такие стратегии:
• SimpleCompositor реализует простую стратегию, выделяющую по одной строке за раз;
• TeXCompositor реализует алгоритм поиска точек разбиения на строки, принятый в редакторе TЕX. Эта стратегия пытается выполнить глобальную оптимизацию разбиения на строки, рассматривая сразу целый параграф;
• ArrayCompositor реализует стратегию расстановки переходов на новую строку таким образом, что в каждой строке оказывается одно и то же число элементов. Это полезно, например, при построчном отображении набора пиктограмм.
Объект Composition хранит ссылку на объект Compositor. Всякий раз, когда объекту Composition требуется переформатировать текст, он делегирует данную обязанность своему объекту Compositor. Клиент указывает, какой объект Compositor следует использовать, параметризуя им объект Composition.
Используйте паттерн стратегия, когда:
• имеется много родственных классов, отличающихся только поведением. Стратегия позволяет сконфигурировать класс, задав одно из возможных поведений;
• вам нужно иметь несколько разных вариантов алгоритма. Например, можно определить два варианта алгоритма, один из которых требует больше времени, а другой – больше памяти. Стратегии разрешается применять, когда варианты алгоритмов реализованы в виде иерархии классов [HO87];
• в алгоритме содержатся данные, о которых клиент не должен «знать». Используйте паттерн стратегия, чтобы не раскрывать сложные, специфичные для алгоритма структуры данных;
• в классе определено много поведений, что представлено разветвленными
условными операторами. В этом случае проще перенести код из ветвей в отдельные классы стратегий.
• Strategy (Compositor) – стратегия:
– объявляет общий для всех поддерживаемых алгоритмов интерфейс. Класс Context пользуется этим интерфейсом для вызова конкретного алгоритма, определенного в классе ConcreteStrategy;
• ConcreteStrategy (SimpleCompositor, TeXCompositor, ArrayCompositor) – конкретная стратегия:
– реализует алгоритм, использующий интерфейс, объявленный в классе Strategy;
• Context (Composition) – контекст:
– конфигурируется объектом класса ConcreteStrategy;
– хранит ссылку на объект класса Strategy;
– может определять интерфейс, который позволяет объекту Strategy получить доступ к данным контекста.
• классы Strategy и Context взаимодействуют для реализации выбранного алгоритма. Контекст может передать стратегии все необходимые алгоритму данные в момент его вызова. Вместо этого контекст может позволить обращаться к своим операциям в нужные моменты, передав ссылку на самого себя операциям класса Strategy;
• контекст переадресует запросы своих клиентов объекту-стратегии. Обычно клиент создает объект ConcreteStrategy и передает его контексту, после чего клиент «общается» исключительно с контекстом. Часто в распоряжении клиента находится несколько классов ConcreteStrategy, которые он может выбирать.
У паттерна стратегия есть следующие достоинства и недостатки:
• семейства родственных алгоритмов. Иерархия классов Strategy определяет семейство алгоритмов или поведений, которые можно повторно использовать в разных контекстах. Наследование позволяет вычленить общую для всех алгоритмов функциональность;
• альтернатива порождению подклассов. Наследование поддерживает многообразие алгоритмов или поведений. Можно напрямую породить от Context подклассы с различными поведениями. Но при этом поведение жестко «зашивается» в класс Context. Вот почему реализации алгоритма и контекста смешиваются, что затрудняет понимание, сопровождение и расширение контекста. Кроме того, заменить алгоритм динамически уже не удастся. В результате вы получите множество родственных классов, отличающихся только алгоритмом или поведением. Инкапсуляции алгоритма в отдельный класс Strategy позволяют изменять его независимо от контекста;
• с помощью стратегий можно избавиться от условных операторов. Благодаря паттерну стратегия удается отказаться от условных операторов при выборе нужного поведения. Когда различные поведения помещаются в один класс, трудно выбрать нужное без применения условных операторов. Инкапсуляция же каждого поведения в отдельный класс Strategy решает эту проблему.
Так, без использования стратегий код для разбиения текста на строки мог бы выглядеть следующим образом:
void Composition::Repair () {
switch (_breakingStrategy) {
case SimpleStrategy:
ComposeWithSimpleCompositor();
break;
case TeXStrategy:
ComposeWithTeXCompositor();
break;
// ...
}
// если необходимо, объединить результаты с имеющейся
// композицией
}
Паттерн же стратегия позволяет обойтись без оператора переключения за счет делегирования задачи разбиения на строки объекту Strategy:
void Composition::Repair () {
_compositor->Compose();
// если необходимо, объединить результаты
// с имеющейся композицией
}
Если код содержит много условных операторов, то часто это признак того, что нужно применить паттерн стратегия;
• выбор реализации. Стратегии могут предлагать различные реализации одного и того же поведения. Клиент вправе выбирать подходящую стратегию
в зависимости от своих требований к быстродействию и памяти;
• клиенты должны «знать» о различных стратегиях. Потенциальный недостаток этого паттерна в том, что для выбора подходящей стратегии клиент должен понимать, чем отличаются разные стратегии. Поэтому наверняка придется раскрыть клиенту некоторые особенности реализации. Отсюда следует, что паттерн стратегия стоит применять лишь тогда, когда различия
в поведении имеют значение для клиента;
• обмен информацией между стратегией и контекстом. Интерфейс класса Strategy разделяется всеми подклассами ConcreteStrategy – неважно, сложна или тривиальна их реализация. Поэтому вполне вероятно, что некоторые стратегии не будут пользоваться всей передаваемой им информацией, особенно простые. Это означает, что в отдельных случаях контекст создаст
и проинициализирует параметры, которые никому не нужны. Если возникнет проблема, то между классами Strategy и Context придется установить более тесную связь;
• увеличение числа объектов. Применение стратегий увеличивает число объектов в приложении. Иногда эти издержки можно сократить, если реализовать стратегии в виде объектов без состояния, которые могут разделяться несколькими контекстами. Остаточное состояние хранится в самом контексте и передается при каждом обращении к объекту-стратегии. Разделяемые стратегии не должны сохранять состояние между вызовами. В описании паттерна приспособленец этот подход обсуждается более подробно.
Рассмотрим следующие вопросы реализации:
• определение интерфейсов классов Strategy и Context. Интерфейсы классов Strategy и Context могут обеспечить объекту класса ConcreteStrategy эффективный доступ к любым данным контекста, и наоборот.
Например, Context передает данные в виде параметров операциям класса Strategy. Это разрывает тесную связь между контекстом и стратегией. При этом не исключено, что контекст будет передавать данные, которые стратегии не нужны.
Другой метод – передать контекст в качестве аргумента, в таком случае стратегия будет запрашивать у него данные, или, например, сохранить ссылку на свой контекст, так что передавать вообще ничего не придется. И в том,
и в другом случаях стратегия может запрашивать только ту информацию, которая реально необходима. Но тогда в контексте должен быть определен более развитый интерфейс к своим данным, что несколько усиливает связанность классов Strategy и Context.
Какой подход лучше, зависит от конкретного алгоритма и требований, которые он предъявляет к данным;
• стратегии как параметры шаблона. В C++ для конфигурирования класса стратегией можно использовать шаблоны. Этот способ хорош, только если стратегия определяется на этапе компиляции и ее не нужно менять во время выполнения. Тогда конфигурируемый класс (например, Context) определяется в виде шаблона, для которого класс Strategy является параметром:
template <class AStrategy>
class Context {
void Operation() { theStrategy.DoAlgorithm(); }
// ...
private:
AStrategy theStrategy;
};
Затем этот класс конфигурируется классом Strategy в момент инстанцирования:
class MyStrategy {
public:
void DoAlgorithm();
};
Context<MyStrategy> aContext;
При использовании шаблонов отпадает необходимость в абстрактном классе для определения интерфейса Strategy. Кроме того, передача стратегии в виде параметра шаблона позволяет статически связать стратегию с контекстом, вследствие чего повышается эффективность программы;
• объекты-стратегии можно не задавать. Класс Context разрешается упростить, если для него отсутствие какой бы то ни было стратегии является нормой. Прежде чем обращаться к объекту Strategy, объект Context проверяет наличие стратегии. Если да, то работа продолжается как обычно,
в противном случае контекст реализует некое поведение по умолчанию. Достоинство такого подхода в том, что клиентам вообще не нужно иметь дело со стратегиями, если их устраивает поведение по умолчанию.
Из раздела «Мотивация» мы приведем фрагмент высокоуровневого кода,
в основе которого лежат классы Composition и Compositor из библиотеки InterViews [LCI+92].
В классе Composition есть коллекция экземпляров класса Component, представляющих текстовые и графические элементы документа. Компоновщик, то есть некоторый подкласс класса Compositor, составляет из объектов-компонентов строки, реализуя ту или иную стратегию разбиения на строки. С каждым объектом ассоциирован его естественный размер, а также свойства растягиваемости
и сжимаемости. Растягиваемость определяет, насколько можно увеличивать объект по сравнению с его естественным размером, а сжимаемость – насколько можно этот размер уменьшать. Композиция передает эти значения компоновщику, который использует их, чтобы найти оптимальное место для разрыва строки.
class Composition {
public:
Composition(Compositor*);
void Repair();
private:
Compositor* _compositor;
Component* _components; // список компонентов
int _componentCount; // число компонентов
int _lineWidth; // ширина строки в композиции Composition
int* _lineBreaks; // позиции точек разрыва строки
// (измеренные в компонентах)
int _lineCount; // число строк
};
Когда возникает необходимость изменить расположение элементов, композиция запрашивает у компоновщика позиции точек разрыва строк. При этом она передает компоновщику три массива, в которых описаны естественные размеры, растягиваемость и сжимаемость компонентов. Кроме того, передается число компонентов, ширина строки и массив, в который компоновщик должен поместить позиции точек разрыва. Компоновщик возвращает число рассчитанных им точек разрыва.
Интерфейс класса Compositor позволяет композиции передать компоновщику всю необходимую ему информацию. Приведем пример передачи данных стратегии:
class Compositor {
public:
virtual int Compose(
Coord natural[], Coord stretch[], Coord shrink[],
int componentCount, int lineWidth, int breaks[]
) = 0;
protected:
Compositor();
};
Заметим, что Compositor – это абстрактный класс. В его конкретных подклассах определены различные стратегии разбиения на строки.
Композиция обращается к своему компоновщику посредством операции Repair, которая прежде всего инициализирует массивы, содержащие естественные размеры, растягиваемость и сжимаемость каждого компонента (подробности мы опускаем). Затем Repair вызывает компоновщика для получения позиций точек разрыва и, наконец, отображает документ (этот код также опущен):
void Composition::Repair () {
Coord* natural;
Coord* stretchability;
Coord* shrinkability;
int componentCount;
int* breaks;
// подготовить массивы с характеристиками компонентов
// ...
// определить, где должны быть точки разрыва
int breakCount;
breakCount = _compositor->Compose(
natural, stretchability, shrinkability,
componentCount, _lineWidth, breaks
);
// разместить компоненты с учетом точек разрыва
// ...
}
Теперь рассмотрим подклассы класса Compositor. Класс SimpleCompositor для определения позиций точек разрыва исследует компоненты по одному:
class SimpleCompositor : public Compositor {
public:
SimpleCompositor();
virtual int Compose(
Coord natural[], Coord stretch[], Coord shrink[],
int componentCount, int lineWidth, int breaks[]
);
// ...
};
Класс TeXCompositor использует более глобальную стратегию. Он рассматривает абзац целиком, принимая во внимание размеры и растягиваемость компонентов. Данный класс также пытается равномерно «раскрасить» абзац, минимизируя ширину пропусков между компонентами:
class TeXCompositor : public Compositor {
public:
TeXCompositor();
virtual int Compose(
Coord natural[], Coord stretch[], Coord shrink[],
int componentCount, int lineWidth, int breaks[]
);
// ...
};
Класс ArrayCompositor разбивает компоненты на строки, оставляя между ними равные промежутки:
class ArrayCompositor : public Compositor {
public:
ArrayCompositor(int interval);
virtual int Compose(
Coord natural[], Coord stretch[], Coord shrink[],
int componentCount, int lineWidth, int breaks[]
);
// ...
};
Не все из этих классов используют в полном объеме информацию, переданную операции Compose. SimpleCompositor игнорирует растягиваемость компонентов, принимая во внимание только их естественную ширину. TeXCompositor использует всю переданную информацию, а ArrayCompositor игнорирует ее.
При создании экземпляра класса Composition вы передаете ему компоновщик, которым собираетесь пользоваться:
Composition* quick = new Composition(new SimpleCompositor);
Composition* slick = new Composition(new TeXCompositor);
Composition* iconic = new Composition(new ArrayCompositor(100));
Интерфейс класса Compositor тщательно спроектирован для поддержки всех алгоритмов размещения, которые могут быть реализованы в подклассах. Вряд ли вам захочется изменять данный интерфейс при появлении каждого нового подкласса, поскольку это означало бы переписывание уже существующих подклассов. В общем случае именно интерфейсы классов Strategy и Context определяют, насколько хорошо паттерн стратегия соответствует своему назначению.
Библиотеки ET++ [WGM88] и InterViews используют стратегии для инкапсуляции алгоритмов разбиения на строки – так, как мы только что видели.
В системе RTL для оптимизации кода компиляторов [JML92] с помощью стратегий определяются различные схемы распределения регистров (RegisterAllocator) и политики управления потоком команд (RISCscheduler, CISCscheduler). Это позволяет гибко настраивать оптимизатор для разных целевых машинных архитектур.
Каркас ET++ SwapsManager предназначен для построения программ, рассчитывающих цены для различных финансовых инструментов [EG92]. Ключевыми абстракциями для него являются Instrument (инструмент) и YieldCurve (кривая дохода). Различные инструменты реализованы как подклассы класса Instrument. YieldCurve рассчитывает коэффициенты дисконтирования, на основе которых вычисляется текущее значение будущего движения ликвидности. Оба класса делегируют часть своего поведения объектам-стратегиям класса Strategy. В каркасе присутствует семейство конкретных стратегий для генерирования движения ликвидности, оценки оборотов и вычисления коэффициентов дисконтирования. Можно создавать новые механизмы расчетов, конфигурируя классы Instrument и YieldCurve другими объектами конкретных стратегий. Этот подход поддерживает как использование существующих реализаций стратегий в различных сочетаниях, так и определение новых.
В библиотеке компонентов Грейди Буча [BV90] стратегии используются как аргументы шаблонов. В классах коллекций поддерживаются три разновидности стратегий распределения памяти: управляемая (распределение из пула), контролируемая (распределение и освобождение защищены замками) и неуправляемая (стандартное распределение памяти). Стратегия передается классу коллекции
в виде аргумента шаблона в момент его инстанцирования. Например, коллекция UnboundedCollection, в которой используется неуправляемая стратегия, инстанцируется как UnboundedCollection<MyItemType*, Unmanaged>.
RApp – это система для проектирования топологии интегральных схем [GA89, AG90]. Задача RApp – проложить провода между различными подсистемами на схеме. Алгоритмы трассировки в RApp определены как подклассы абстрактного класса Router, который является стратегией.
В библиотеке ObjectWindows фирмы Borland [Bor94] стратегии используются в диалоговых окнах для проверки правильности введенных пользователем данных. Например, можно контролировать, что число принадлежит заданному диапазону, а в данном поле должны быть только цифры. Не исключено, что при проверке корректности введенной строки потребуется поиск данных в справочной таблице.
Для инкапсуляции стратегий проверки в ObjectWindows используются объекты класса Validator – частный случай паттерна стратегия. Поля для ввода данных делегируют стратегию контроля необязательному объекту Validator. Клиент при необходимости присоединяет таких проверяющих к полю (пример необязательной стратегии). В момент закрытия диалогового окна поля «просят» своих контролеров проверить правильность данных. В библиотеке имеются классы контролеров для наиболее распространенных случаев, например RangeValidator для проверки принадлежности числа диапазону. Но клиент может легко определить и собственные стратегии проверки, порождая подклассы от класса Validator.
Приспособленец: объекты-стратегии в большинстве случаев подходят как приспособленцы.
Шаблонный метод – паттерн поведения классов.
Шаблонный метод определяет основу алгоритма и позволяет подклассам переопределить некоторые шаги алгоритма, не изменяя его структуру в целом.
Рассмотрим каркас приложения, в котором имеются классы Application
и Document. Класс Application отвечает за открытие существующих документов, хранящихся во внешнем формате, например в виде файла. Объект класса Document представляет информацию документа после его прочтения из файла.
Приложения, построенные на базе этого каркаса, могут порождать подклассы от классов Application и Document, отвечающие конкретным потребностям. Например, графический редактор определит подклассы DrawApplication
и DrawDocument, а электронная таблица – подклассы SpreadsheetApplication и SpreadsheetDocument.
В абстрактном классе Application определен алгоритм открытия и считывания документа в операции OpenDocument:
void Application::OpenDocument (const char* name) {
if (!CanOpenDocument(name)) {
// работа с этим документом невозможна
return;
}
Document* doc = DoCreateDocument();
if (doc) {
_docs->AddDocument(doc);
AboutToOpenDocument(doc);
doc->Open();
doc->DoRead();
}
}
Операция OpenDocument определяет все шаги открытия документа. Она проверяет, можно ли открыть документ, создает объект класса Document, добавляет его к набору документов и считывает документ из файла.
Операцию вида OpenDocument мы будем называть шаблонным методом, описывающим алгоритм в терминах абстрактных операций, которые замещены в подклассах для получения нужного поведения. Подклассы класса Application выполняют проверку возможности открытия (CanOpenDocument) и создания документа (DoCreateDocument). Подклассы класса Document считывают документ (DoRead). Шаблонный метод определяет также операцию, которая позволяет подклассам Application получить информацию о том, что документ вот-вот будет открыт (AboutToOpenDocument). Определяя некоторые шаги алгоритма с помощью абстрактных операций, шаблонный метод фиксирует их последовательность, но позволяет реализовать их в подклассах классов Application и Document.
Паттерн шаблонный метод следует использовать:
• чтобы однократно использовать инвариантные части алгоритма, оставляя реализацию изменяющегося поведения на усмотрение подклассов;
• когда нужно вычленить и локализовать в одном классе поведение, общее для всех подклассов, дабы избежать дублирования кода. Это хороший пример техники «вынесения за скобки с целью обобщения», описанной в работе Уильяма Опдайка (William Opdyke) и Ральфа Джонсона (Ralph Johnson) [OJ93]. Сначала идентифицируются различия в существующем коде, а затем они выносятся в отдельные операции. В конечном итоге различающиеся фрагменты кода заменяются шаблонным методом, из которого вызываются новые операции;
• для управления расширениями подклассов. Можно определить шаблонный метод так, что он будет вызывать операции-зацепки (hooks) – см. раздел «Результаты» – в определенных точках, разрешив тем самым расширение только в этих точках.
• AbstractClass (Application) – абстрактный класс:
– определяет абстрактные примитивные операции, замещаемые в конкретных подклассах для реализации шагов алгоритма;
– реализует шаблонный метод, определяющий скелет алгоритма. Шаблонный метод вызывает примитивные операции, а также операции, определенные в классе AbstractClass или в других объектах;
• ConcreteClass (MyApplication) – конкретный класс:
– реализует примитивные операции, выполняющие шаги алгоритма способом, который зависит от подкласса.
ConcreteClass предполагает, что инвариантные шаги алгоритма будут выполнены в AbstractClass.
Шаблонные методы – один из фундаментальных приемов повторного использования кода. Они особенно важны в библиотеках классов, поскольку предоставляют возможность вынести общее поведение в библиотечные классы.
Шаблонные методы приводят к инвертированной структуре кода, которую иногда называют принципом Голливуда, подразумевая часто употребляемую в этой киноимперии фразу «Не звоните нам, мы сами позвоним» [Swe85]. В данном случае это означает, что родительский класс вызывает операции подкласса, а не наоборот.
Шаблонные методы вызывают операции следующих видов:
• конкретные операции (либо из класса ConcreteClass, либо из классов клиента);
• конкретные операции из класса AbstractClass (то есть операции, полезные всем подклассам);
• примитивные операции (то есть абстрактные операции);
• фабричные методы (см. паттерн фабричный метод);
• операции-зацепки (hook operations), реализующие поведение по умолчанию, которое может быть расширено в подклассах. Часто такая операция по умолчанию не делает ничего.
Важно, чтобы в шаблонном методе четко различались операции-зацепки (которые можно замещать) и абстрактные операции (которые нужно замещать). Чтобы повторно использовать абстрактный класс с максимальной эффективностью, авторы подклассов должны понимать, какие операции предназначены для замещения.
Подкласс может расширить поведение некоторой операции, заместив ее и явно вызвав эту операцию из родительского класса:
void DerivedClass::Operation () {
ParentClass::Operation();
// Расширенное поведение класса DerivedClass
}
К сожалению, очень легко забыть о необходимости вызывать унаследованную операцию. У нас есть возможность трансформировать такую операцию в шаблонный метод с целью предоставить родителю контроль над тем, как подклассы расширяют его. Идея в том, чтобы вызывать операцию-зацепку из шаблонного метода в родительском классе. Тогда подклассы смогут переопределить именно эту операцию:
void ParentClass::Operation () {
// Поведение родительского класса ParentClass
HookOperation();
}
В родительском классе ParentClass операция HookOperation не делает ничего:
void ParentClass::HookOperation () { }
Но она замещена в подклассах, которые расширяют поведение:
void DerivedClass::HookOperation () {
// расширение в производном классе
}
Стоит рассказать о трех аспектах, касающихся реализации:
• использование контроля доступа в C++. В этом языке примитивные операции, которые вызывает шаблонный метод, можно объявить защищенными членами. Тогда гарантируется, что вызывать их сможет только сам шаблонный метод. Примитивные операции, которые обязательно нужно замещать, объявляются как чисто виртуальные функции. Сам шаблонный метод замещать не надо, так что его можно сделать невиртуальной функцией-членом;
• сокращение числа примитивных операций. Важной целью при проектировании шаблонных методов является всемерное сокращение числа примитивных операций, которые должны быть замещены в подклассах. Чем больше операций нужно замещать, тем утомительнее становится программирование клиента;
• соглашение об именах. Выделить операции, которые необходимо заместить, можно путем добавления к их именам некоторого префикса. Например,
в каркасе MacApp для приложений на платформе Macintosh [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 () { }
Чтобы она что-то рисовала, подклассы переопределяют ее:
void MyView::DoDisplay () {
// изобразить содержимое вида
}
Шаблонные методы настолько фундаментальны, что встречаются почти в каждом абстрактном классе. В работах Ребекки Вирфс-Брок и др. [WBWW90, WBJ90] подробно обсуждаются шаблонные методы.
Фабричные методы часто вызываются из шаблонных. В примере из раздела «Мотивация» шаблонный метод OpenDocument вызывал фабричный метод DoCreateDocument.
Стратегия: шаблонные методы применяют наследование для модификации части алгоритма. Стратегии используют делегирование для модификации алгоритма в целом.
Посетитель – паттерн поведения объектов.
Описывает операцию, выполняемую с каждым объектом из некоторой структуры. Паттерн посетитель позволяет определить новую операцию, не изменяя классы этих объектов.
Рассмотрим компилятор, который представляет программу в виде абстрактного синтаксического дерева. Над такими деревьями он должен выполнять операции «статического семантического» анализа, например проверять, что все переменные определены. Еще ему нужно генерировать код. Аналогично можно было бы определить операции контроля типов, оптимизации кода, анализа потока выполнения, проверки того, что каждой переменной было присвоено конкретное значение перед первым использованием, и т.д. Более того, абстрактные синтаксические деревья могли бы служить для красивой печати программы, реструктурирования кода и вычисления различных метрик программы.
В большинстве таких операций узлы дерева, представляющие операторы присваивания, следует рассматривать иначе, чем узлы, представляющие переменные и арифметические выражения. Поэтому один класс будет создан для операторов присваивания, другой – для доступа к переменным, третий – для арифметических выражений и т.д. Набор классов узлов, конечно, зависит от компилируемого языка, но не очень сильно.
На представленной диаграмме показана часть иерархии классов Node. Проблема здесь в том, что если раскидать все операции по классам различных узлов, то получится система, которую трудно понять, сопровождать и изменять. Вряд ли кто-нибудь разберется в программе, если код, отвечающий за проверку типов, будет перемешан с кодом, реализующим красивую печать или анализ потока выполнения. Кроме того, добавление любой новой операции потребует перекомпиляции всех классов. Оптимальный вариант – наличие возможности добавлять операции по отдельности и отсутствие зависимости классов узлов от применяемых к ним операций.
И того, и другого можно добиться, если поместить взаимосвязанные операции из каждого класса в отдельный объект, называемый посетителем, и передавать его элементам абстрактного синтаксического дерева по мере обхода. «Принимая» посетителя, элемент посылает ему запрос, в котором содержится, в частности, класс элемента. Кроме того, в запросе присутствует в виде аргумента и сам элемент. Посетителю в данной ситуации предстоит выполнить операцию над элементом, ту самую, которая наверняка находилась бы в классе элемента.
Например, компилятор, который не использует посетителей, мог бы проверить тип процедуры, вызвав операцию TypeCheck для представляющего ее абстрактного синтаксического дерева. Каждый узел дерева должен был реализовать операцию TypeCheck путем рекурсивного вызова ее же для своих компонентов (см. приведенную выше диаграмму классов). Если же компилятор проверяет тип процедуры посредством посетителей, то ему достаточно создать объект класса TypeCheckingVisitor и вызвать для дерева операцию Accept, передав ей этот объект в качестве аргумента. Каждый узел должен был реализовать Accept путем обращения к посетителю: узел, соответствующий оператору присваивания, вызывает операцию посетителя VisitAssignment, а узел, ссылающийся на переменную, – операцию VisitVariableReference. То, что раньше было операцией TypeCheck в классе AssignmentNode, стало операцией VisitAssignment
в классе TypeCheckingVisitor.
Чтобы посетители могли заниматься не только проверкой типов, нам необходим абстрактный класс NodeVisitor, являющийся родителем для всех посетителей синтаксического дерева. Приложение, которому нужно вычислять метрики программы, определило бы новые подклассы NodeVisitor, так что нам не пришлось бы добавлять зависящий от приложения код в классы узлов. Паттерн посетитель инкапсулирует операции, выполняемые на каждой фазе компиляции,
в классе Visitor, ассоциированном с этой фазой.
Применяя паттерн посетитель, вы определяете две иерархии классов: одну для элементов, над которыми выполняется операция (иерархия Node), а другую – для посетителей, описывающих те операции, которые выполняются над элементами (иерархия NodeVisitor). Новая операция создается путем добавления подкласса в иерархию классов посетителей. До тех пор пока грамматика языка остается постоянной (то есть не добавляются новые подклассы Node), новую функциональность можно получить путем определения новых подклассов NodeVisitor.
Используйте паттерн посетитель, когда:
• в структуре присутствуют объекты многих классов с различными интерфейсами и вы хотите выполнять над ними операции, зависящие от конкретных классов;
• над объектами, входящими в состав структуры, надо выполнять разнообразные, не связанные между собой операции и вы не хотите «засорять» классы такими операциями. Посетитель позволяет объединить родственные операции, поместив их в один класс. Если структура объектов является общей для нескольких приложений, то паттерн посетитель позволит в каждое приложение включить только относящиеся к нему операции;
• классы, устанавливающие структуру объектов, изменяются редко, но новые операции над этой структурой добавляются часто. При изменении классов, представленных в структуре, нужно будет переопределить интерфейсы всех посетителей, а это может вызвать затруднения. Поэтому если классы меняются достаточно часто, то, вероятно, лучше определить операции прямо в них.
• Visitor (NodeVisitor) – посетитель:
– объявляет операцию Visit для каждого класса ConcreteElement
в структуре объектов. Имя и сигнатура этой операции идентифицируют класс, который посылает посетителю запрос Visit. Это позволяет посетителю определить, элемент какого конкретного класса он посещает. Владея такой информацией, посетитель может обращаться к элементу напрямую через его интерфейс;
• ConcreteVisitor (TypeCheckingVisitor) – конкретный посетитель:
– реализует все операции, объявленные в классе Visitor. Каждая операция реализует фрагмент алгоритма, определенного для класса соответствующего объекта в структуре. Класс ConcreteVisitor предоставляет контекст для этого алгоритма и сохраняет его локальное состояние. Часто в этом состоянии аккумулируются результаты, полученные в процессе обхода структуры;
• Element (Node) – элемент:
– определяет операцию Accept, которая принимает посетителя в качестве аргумента;
• ConcreteElement (AssignmentNode, VariableRefNode) – конкретный элемент:
– реализует операцию Accept, принимающую посетителя как аргумент;
• ObjectStructure (Program) – структура объектов:
– может перечислить свои элементы;
– может предоставить посетителю высокоуровневый интерфейс для посещения своих элементов;
– может быть как составным объектом (см. паттерн компоновщик), так
и коллекцией, например списком или множеством.
• клиент, использующий паттерн посетитель, должен создать объект класса ConcreteVisitor, а затем обойти всю структуру, посетив каждый ее элемент.
• при посещении элемента последний вызывает операцию посетителя, соответствующую своему классу. Элемент передает этой операции себя в качестве аргумента, чтобы посетитель мог при необходимости получить доступ
к его состоянию.
На представленной диаграмме взаимодействий показаны отношения между объектом, структурой, посетителем и двумя элементами.
Некоторые достоинства и недостатки паттерна посетитель:
• упрощает добавление новых операций. С помощью посетителей легко добавлять операции, зависящие от компонентов сложных объектов. Для определения
новой операции над структурой объектов достаточно просто ввести нового посетителя. Напротив, если функциональность распределена по нескольким классам, то для определения новой операции придется изменить каждый класс;
• объединяет родственные операции и отсекает те, которые не имеют к ним отношения. Родственное поведение не разносится по всем классам, присутствующим в структуре объектов, оно локализовано в посетителе. Не связанные друг с другом функции распределяются по отдельным подклассам класса Visitor. Это способствует упрощению как классов, определяющих элементы, так и алгоритмов, инкапсулированных в посетителях. Все относящиеся
к алгоритму структуры данных можно скрыть в посетителе;
• добавление новых классов ConcreteElement затруднено. Паттерн посетитель усложняет добавление новых подклассов класса Element. Каждый новый конкретный элемент требует объявления новой абстрактной операции в классе Visitor, которую нужно реализовать в каждом из существующих классов ConcreteVisitor. Иногда большинство конкретных посетителей могут унаследовать операцию по умолчанию, предоставляемую классом Visitor, что скорее исключение, чем правило.
Поэтому при решении вопроса о том, стоит ли использовать паттерн посетитель, нужно прежде всего посмотреть, что будет изменяться чаще: алгоритм, применяемый к объектам структуры, или классы объектов, составляющих эту структуру. Вполне вероятно, что сопровождать иерархию классов Visitor будет нелегко, если новые классы ConcreteElement добавляются часто. В таких случаях проще определить операции прямо в классах, представленных в структуре. Если же иерархия классов Element стабильна, но постоянно расширяется набор операций или модифицируются алгоритмы, то паттерн посетитель поможет лучше управлять такими изменениями;
• посещение различных иерархий классов. Итератор (см. описание паттерна итератор) может посещать объекты структуры по мере ее обхода, вызывая операции объектов. Но итератор не способен работать со структурами, состоящими из объектов разных типов. Так, интерфейс класса Iterator, рассмотренный на стр. 255, может всего лишь получить доступ к объектам типа Item:
template <class Item>
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 замещают операции 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); }
};
Класс CompositeElement мог бы реализовать операцию Accept следующим образом:
class CompositeElement : public Element {
public:
virtual void Accept(Visitor&);
private:
List<Element*>* _children;
};
void CompositeElement::Accept (Visitor& v) {
ListIterator<Element*> 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. Двойная диспетчеризация позволяет посетителю запрашивать разные операции для каждого класса элемента.1
Поэтому возникает необходимость в паттерне посетитель: выполняемая операция зависит и от типа посетителя, и от типа посещаемого элемента. Вместо статической привязки операций к интерфейсу класса Element мы можем консолидировать эти операции в классе Visitor и использовать Accept для привязки их во время выполнения. Расширение интерфейса класса Element сводится к определению нового подкласса Visitor, а не к модификации многих подклассов Element;
• какой участник несет ответственность за обход структуры. Посетитель должен обойти каждый элемент структуры объектов. Вопрос в том, как туда попасть. Ответственность за обход можно возложить на саму структуру объектов, на посетителя или на отдельный объект-итератор (см. паттерн итератор).
Чаще всего структура объектов отвечает за обход. Коллекция просто обходит все свои элементы, вызывая для каждого операцию Accept. Составной объект обычно обходит самого себя, «заставляя» операцию Accept посетить потомков текущего элемента и рекурсивно вызвать Accept для каждого
из них.
Другое решение – воспользоваться итератором для посещения элементов. В C++ можно применить внутренний или внешний итератор, в зависимости от того, что доступно и более эффективно. В Smalltalk обычно работают
с внутренним итератором на основе метода do: и блока. Поскольку внутренние итераторы реализуются самой структурой объектов, то работа с ними во многом напоминает предыдущее решение, когда за обход отвечает структура. Основное различие заключается в том, что внутренний итератор не приводит к двойной диспетчеризации: он вызывает операцию посетителя с элементом в качестве аргумента, а не операцию элемента с посетителем в качестве аргумента. Однако использовать паттерн посетитель с внутренним итератором легко в том случае, когда операция посетителя вызывает операцию элемента без рекурсии.
Можно даже поместить алгоритм обхода в посетитель, хотя закончится это дублированием кода обхода в каждом классе ConcreteVisitor для каждого агрегата ConcreteElement. Основная причина такого решения – необходимость реализовать особо сложную стратегию обхода, зависящую от результатов операций над объектами структуры. Этот случай рассматривается в разделе «Пример кода».
Поскольку посетители обычно ассоциируются с составными объектами, то для иллюстрации паттерна посетитель мы воспользуемся классами Equipment, определенными в разделе «Пример кода» из описания паттерна компоновщик. Для определения операций, создающих инвентарную опись материалов и вычисляющих полную стоимость агрегата, нам понадобится паттерн посетитель. Классы Equipment настолько просты, что применять паттерн посетитель в общем-то излишне, но на этом примере демонстрируются основные особенности его реализации.
Приведем еще раз объявление класса Equipment из описания паттерна компоновщик. Мы добавили операцию 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 возвращают такие атрибуты единицы оборудования, как энергопотребление и стоимость. В подклассах эти операции переопределены в соответствии с конкретными типами оборудования (рама, дисководы
и электронные платы).
В абстрактном классе всех посетителей оборудования имеются виртуальные функции для каждого подкласса (см. ниже). По умолчанию эти функции ничего не делают:
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<Equipment*> i(_parts);
!i.IsDone();
i.Next()
) {
i.CurrentItem()->Accept(visitor);
}
visitor.VisitChassis(this);
}
Подклассы EquipmentVisitor определяют конкретные алгоритмы, применяемые к структуре оборудования. Так, PricingVisitor вычисляет стоимость всей конструкции, для чего суммирует нетто-цены простых компонентов (например, гибкие диски) и цену со скидкой составных компонентов (например, рамы
и шины):
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;
};
Посетитель 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 << "Инвентарная опись "
<< component->Name()
<< visitor.GetInventory();
Далее мы покажем, как на языке Smalltalk реализовать пример из описания паттерна интерпретатор с помощью паттерна посетитель. Как и в предыдущем случае, этот пример настолько мал, что паттерн посетитель практически бесполезен, но служит неплохой иллюстрацией основных принципов. Кроме того, демонстрируется ситуация, в которой обход выполняет посетитель.
Структура объектов (регулярные выражения) представлена четырьмя классами, в каждом из которых существует метод accept:, принимающий посетитель
в качестве аргумента. В классе SequenceExpression метод accept: выглядит так:
accept: aVisitor
^ aVisitor visitSequence: self
Метод accept: в классах RepeatExpression, AlternationExpression и LiteralExpression посылает сообщения visitRepeat:, visitAlternation: и visitLiteral: соответственно.
Все четыре класса должны иметь функции доступа, к которым может обратиться посетитель. Для SequenceExpression это expression1 и expression2; для 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
В компиляторе Smalltalk-80 имеется класс посетителя, который называется ProgramNodeEnumerator. В основном он применяется в алгоритмах анализа исходного текста программы и не используется ни для генерации кода, ни для красивой печати, хотя мог бы.
IRIS Inventor [Str93] – это библиотека для разработки приложений трехмерной графики. Библиотека представляет собой трехмерную сцену в виде иерархии узлов, каждый из которых соответствует либо геометрическому объекту, либо его атрибуту. Для операций типа изображения сцены или обработки события ввода необходимо по-разному обходить эту иерархию. В Inventor для этого служат посетители, которые называются действиями (actions). Есть различные посетители для изображения, обработки событий, поиска, сохранения и определения ограничивающих прямоугольников.
Чтобы упростить добавление новых узлов, в библиотеке Inventor реализована схема двойной диспетчеризации на C++. Для этого служит информация о типе, доступная во время выполнения, и двумерная таблица, строки которой представляют посетителей, а колонки – классы узлов. В каждой ячейке хранится указатель на функцию, связанную с парой посетитель-класс узла.
Марк Линтон (Mark Linton) ввел термин «посетитель» (Visitor) в спецификацию библиотеки для построения приложений X Consortium’s Fresco Application Toolkit [LP93].
Компоновщик: посетители могут использоваться для выполнения операции над всеми объектами структуры, определенной с помощью паттерна компоновщик.
Интерпретатор: посетитель может использоваться для выполнения интерпретации.
Инкапсуляция вариаций – элемент многих паттернов поведения. Если определенная часть программы подвержена периодическим изменениям, эти паттерны позволяют определить объект для инкапсуляции такого аспекта. Другие части программы, зависящие от данного аспекта, могут кооперироваться с ним. Обычно паттерны поведения определяют абстрактный класс, с помощью которого описывается инкапсулирующий объект. Своим названием паттерн как раз и обязан этому объекту. 1 Например:
• объект стратегия инкапсулирует алгоритм;
• объект состояние инкапсулирует поведение, зависящее от состояния;
• объект посредник инкапсулирует протокол общения между объектами;
• объект итератор инкапсулирует способ доступа и обхода компонентов составного объекта.
Перечисленные паттерны описывают подверженные изменениям аспекты программы. В большинстве паттернов фигурируют два вида объектов: новый объект (или объекты), который инкапсулирует аспект, и существующий объект (или объекты), который пользуется новыми. Если бы не паттерн, то функциональность новых объектов пришлось бы делать неотъемлемой частью существующих. Например, код объекта-стратегии, вероятно, был бы «зашит» в контекст стратегии, а код объекта-состояния был бы реализован непосредственно в контексте состояния.
Но не все паттерны поведения разбивают функциональность таким образом. Например, паттерн цепочка обязанностей связан с произвольным числом объектов (то есть цепочкой), причем все они могут уже существовать в системе.
Цепочка обязанностей иллюстрирует еще одно различие между паттернами поведения: не все они определяют статические отношения взаимосвязи между классами. В частности, цепочка обязанностей показывает, как организовать обмен информацией между заранее неизвестным числом объектов. В других паттернах участвуют объекты, передаваемые в качестве аргументов.
В нескольких паттернах участвует объект, всегда используемый только как аргумент. Одним из них является посетитель. Объект-посетитель – это аргумент полиморфной операции Accept, принадлежащей посещаемому объекту. Посетитель никогда не рассматривается как часть посещаемых объектов, хотя традиционным альтернативным вариантом этому паттерну служит распределение кода посетителя между классами объектов, входящих в структуру.
Другие паттерны определяют объекты, выступающие в роли волшебных палочек, которые передаются от одного владельца к другому и активизируются
в будущем. К этой категории относятся команда и хранитель. В паттерне команда такой «палочкой» является запрос, а в хранителе она представляет внутреннее состояние объекта в определенный момент. И там, и там «палочка» может иметь сложную внутреннюю структуру, но клиент об этом ничего не «знает». Но даже здесь есть различия. В паттерне команда важную роль играет полиморфизм, поскольку выполнение объекта-команды –полиморфная операция. Напротив, интерфейс хранителя настолько «узок», что его можно передавать лишь как значение. Поэтому вполне вероятно, что хранитель не предоставляет полиморфных операций своим клиентам.
Паттерны посредник и наблюдатель конкурируют между собой. Различие между ними в том, что наблюдатель распределяет обмен информацией за счет объектов наблюдатель и субъект, а посредник, наоборот, инкапсулирует взаимодействие между другими объектами.
В паттерне наблюдатель участники наблюдатель и субъект должны кооперироваться, чтобы поддержать ограничение. Паттерны обмена информацией определяются тем, как связаны между собой наблюдатели и субъекты; у одного субъекта обычно бывает много наблюдателей, а иногда наблюдатель субъекта сам является субъектом наблюдения со стороны другого объекта. В паттерне посредник ответственность за поддержание ограничения возлагается исключительно на посредника.
Нам кажется, что повторно использовать наблюдатели и субъекты проще, чем посредники. Паттерн наблюдатель способствует разделению и ослаблению связей между наблюдателем и субъектом, что приводит к появлению сравнительно мелких классов, более приспособленных для повторного использования.
С другой стороны, потоки информации в посреднике проще для понимания, нежели в наблюдателе. Наблюдатели и субъекты обычно связываются вскоре после создания, и понять, каким же образом организована их связь, в последующих частях программы довольно трудно. Если вы знаете паттерн наблюдатель, то понимаете важность того, как именно связаны наблюдатели и субъекты, и представляете, какие связи надо искать. Однако из-за присущей наблюдателю косвенности разобраться в системе все же нелегко.
В языке Smalltalk наблюдатели можно параметризовать сообщениями, применяемыми для доступа к состоянию субъекта, поэтому степень их повторного использования даже выше, чем в C++. Вот почему в Smalltalk паттерн наблюдатель более привлекателен, чем в C++. Следовательно, программист, пишущий на Smalltalk, нередко использует наблюдатель там, где программист на C++ применил бы посредник.
Когда взаимодействующие объекты напрямую ссылаются друг на друга, они становятся зависимыми, а это может отрицательно сказаться на повторном
использовании системы и разбиении ее на уровни. Паттерны команда, наблюдатель, посредник и цепочка обязанностей указывают разные способы разделения получателей и отправителей запросов. Каждый способ имеет свои достоинства и недостатки.
Паттерн команда поддерживает разделение за счет объекта-команды, который определяет привязку отправителя к получателю.
Паттерн команда предоставляет простой интерфейс для выдачи запроса (операцию Execute). Заключение связи между отправителем и получателем в самостоятельный объект позволяет отправителю работать с разными получателями. Он отделяет отправителя от получателей, облегчая тем самым повторное использование. Кроме того, объект-команду можно повторно использовать для параметризации получателя различными отправителями. Номинально паттерн команда требует определения подкласса для каждой связи отправитель-получатель, хотя имеются способы реализации, при которых удается избежать порождения подклассов.
Паттерн наблюдатель отделяет отправителей (субъектов) от получателей (наблюдателей) путем определения интерфейса для извещения о происшедших
с субъектом изменениях. По сравнению с командой в наблюдателе связь между отправителем и получателем слабее, поскольку у субъекта может быть много наблюдателей и их число даже может меняться во время выполнения.
Интерфейсы субъекта и наблюдателя в паттерне наблюдатель предназначены для передачи информации об изменениях. Стало быть, этот паттерн лучше всего подходит для разделения объектов в случае, когда между ними есть зависимость по данным.
Паттерн посредник разделяет объекты, заставляя их ссылаться друг на друга косвенно, через объект-посредник.
Объект-посредник распределяет запросы между объектами-коллегами и централизует обмен информацией между ними. Таким образом, коллеги могут «общаться» между собой только с помощью интерфейса посредника. Поскольку этот интерфейс фиксирован, посредник может реализовать собственную схему диспетчеризации для большей гибкости. Разрешается кодировать запросы и упаковывать аргументы так, что коллеги смогут запрашивать выполнение операций из заранее неизвестного множества.
Паттерн посредник часто способствует уменьшению числа подклассов в системе, поскольку централизует весь обмен информацией в одном классе, вместо того чтобы распределять его по подклассам.
Наконец, паттерн цепочка обязанностей отделяет отправителя от получателя за счет передачи запроса по цепочке потенциальных получателей.
Поскольку интерфейс между отправителями и получателями фиксирован, то цепочка обязанностей также может нуждаться в специализированной схеме диспетчеризации. Поэтому она обладает теми же недостатками с точки зрения безопасности типов, что и посредник. Цепочка обязанностей – это хороший способ разделить отправителя и получателя в случае, если она уже является частью структуры системы, а один объект из группы может принять на себя обязанность обработать запрос. Данный паттерн повышает гибкость и за счет того, что цепочку можно легко изменить или расширить.
Как правило, паттерны поведения дополняют и усиливают друг друга. Например, класс в цепочке обязанностей, скорее всего, будет содержать хотя бы один шаблонный метод. Он может пользоваться примитивными операциями, чтобы определить, должен ли объект обработать запрос сам, а также в случае необходимости выбрать объект, которому следует переадресовать запрос. Цепочка может применять паттерн команда для представления запросов в виде объектов. Зачастую интерпретатор пользуется паттерном состояние для определения контекстов синтаксического разбора. Иногда Итератор обходит агрегат, а посетитель выполняет операцию для каждого его элемента.
Паттерны поведения хорошо сочетаются и с другими паттернами. Например, система, в которой применяется паттерн компоновщик, время от времени использует посетитель для выполнения операций над компонентами, а также задействует цепочку обязанностей, чтобы обеспечить компонентам доступ к глобальным свойствам через их родителя. Бывает, что в системе применяется и паттерн декоратор для переопределения некоторых свойств частей композиции. А паттерн наблюдатель может связать структуры разных объектов, тогда как паттерн состояние позволит компонентам варьировать свое поведение при изменении состояния. Сама композиция может быть создана с применением строителя и рассматриваться как прототип какой-то другой частью системы.
Хорошо продуманные объектно-ориентированные системы внешне похожи на собрание многочисленных паттернов, но вовсе не потому, что их проектировщики мыслили именно такими категориями. Композиция на уровне паттернов, а не классов или объектов, позволяет добиться той же синергии, но с меньшими усилиями.