В данной главе рассматривается применение паттернов на примере проектирования визуального редактора документов Lexi1, построенного по принципу «что видишь, то и получаешь» (WYSIWYG). Мы увидим, как с помощью паттернов можно решать проблемы проектирования, характерные для Lexi и аналогичных приложений. Здесь описывается опыт работы с восемью паттернами.
На рис. 2.1 изображен пользовательский интерфейс редактора Lexi. WYSIWYG-представление документа занимает большую прямоугольную область в центре. В документе могут произвольно сочетаться текст и графика, отформатированные разными способами. Вокруг документа – привычные выпадающие меню и полосы прокрутки, а также значки с номерами для перехода на нужную страницу документа.
Рассмотрим семь задач, характерных для дизайна Lexi:
• структура документа. Выбор внутреннего представления документа отражается практически на всех аспектах дизайна. Для редактирования, форматирования, отображения и анализа текста необходимо уметь обходить это представление. Способ организации информации играет решающую роль при дальнейшем проектировании;
• форматирование. Как в Lexi организованы текст и графика в виде строк и колонок? Какие объекты отвечают за реализацию стратегий форматирования? Взаимодействие данных стратегий с внутренним представлением документа;
• создание привлекательного интерфейса пользователя. В состав пользовательского интерфейса Lexi входят полосы прокрутки, рамки и оттененные выпадающие меню. Вполне вероятно, что количество и состав элементов интерфейса будут изменяться по мере его развития. Поэтому важно иметь возможность легко добавлять и удалять элементы оформления, не затрагивая приложение;
• поддержка стандартов внешнего облика программы. Lexi должен без серьезной модификации адаптироваться к стандартам внешнего облика программ, например, таким как Motif или Presentation Manager (PM);
• поддержка оконных систем. В оконных системах стандарты внешнего облика обычно различаются. По возможности дизайн Lexi должен быть независимым от оконной системы;
• операции пользователя. Пользователи управляют работой Lexi с помощью элементов интерфейса, в том числе кнопок и выпадающих меню. Функции, которые вызываются из интерфейса, разбросаны по всей программе. Разработать единообразный механизм для доступа к таким «рассеянным» функциям и для отмены уже выполненных операций довольно трудно;
• проверка правописания и расстановка переносов. Поддержка в Lexi таких аналитических операций, как проверка правописания и определение мест переноса. Как минимизировать число классов, которые придется модифицировать при добавлении новой аналитической операции?
Ниже обсуждаются указанные проблемы проектирования. Для каждой из них определены некоторые цели и ограничения на способы их достижения. Прежде чем предлагать решение, мы подробно остановимся на целях и ограничениях. На примере проблемы и ее решения демонстрируется применение одного или нескольких паттернов проектирования. Обсуждение каждой проблемы завершается краткой характеристикой паттерна.
Рис. 2.1. Пользовательский интерфейс Lexi
Документ – это всего лишь организованное некоторым способом множество базовых графических элементов: символов, линий, многоугольников и других геометрических фигур. Все они несут в себе полную информацию о содержании документа. И все же автор часто представляет себе эти элементы не в графическом виде, а в терминах физической структуры документа – строк, колонок, рисунков, таблиц и других подструктур.1 Эти подструктуры, в свою очередь, составлены из более мелких и т.д.
Пользовательский интерфейс Lexi должен позволять пользователям непосредственно манипулировать такими подструктурами. Например, пользователю следует дать возможность обращаться с диаграммой как с неделимой единицей, а не как с набором отдельных графических примитивов, предоставить средства ссылаться на таблицу как на единое целое, а не как на неструктурированное хранилище текста и графики. Это делает интерфейс простым и интуитивно понятным. Чтобы придать реализации Lexi аналогичные свойства, мы выберем такое внутреннее представление, которое в точности соответствует физической структуре документа.
В частности, внутреннее представление должно поддерживать:
• отслеживание физической структуры документа, то есть разбиение текста и графики на строки, колонки, таблицы и т.д.;
• генерирование визуального представления документа;
• отображение позиций экрана на элементы внутреннего представления. Это позволит определить, что имел в виду пользователь, когда указал на что-то в визуальном представлении.
Помимо данных целей имеются и ограничения. Во-первых, текст и графику следует трактовать единообразно. Интерфейс приложения должен позволять свободно помещать текст внутрь графики и наоборот. Не следует считать графику частным случаем текста или текст – частным случаем графики, поскольку это в конечном итоге привело бы к появлению избыточных механизмов форматирования и манипулирования. Одного набора механизмов должно хватить и для текста, и для графики.
Во-вторых, в нашей реализации не может быть различий во внутреннем представлении отдельного элемента и группы элементов. При одинаковой работе Lexi с простыми и сложными элементами можно будет создавать документы со структурой любой сложности. Например, десятым элементом на пересечении пятой строки и второй колонки мог бы быть как один символ, так и сложно устроенная диаграмма со многими внутренними компонентами. Но, коль скоро мы уверены, что этот элемент имеет возможность изображать себя на экране и сообщать свои размеры, его внутренняя сложность не имеет никакого отношения к тому, как и в каком месте страницы он появляется.
Однако второе ограничение противоречит необходимости анализировать текст на предмет выявления орфографических ошибок и расстановки переносов. Во многих случаях нам безразлично, является ли элемент строки простым или сложным объектом. Но иногда вид анализа зависит от анализируемого объекта. Так, вряд ли имеет смысл проверять орфографию многоугольника или пытаться переносить его с одной строки на другую. При проектировании внутреннего представлении надо учитывать эти и другие потенциально конфликтующие ограничения.
На практике для представления иерархически структурированной информации часто применяется прием, называемый рекурсивной композицией. Он позволяет строить все более сложные элементы из простых. Рекурсивная композиция дает нам способ составить документ из простых графических элементов. Сначала мы можем линейно расположить множество символов и графики слева направо для формирования одной строки документа. Затем несколько строк можно объединить в колонку, несколько колонок – в страницу и т.д. (см. рис. 2.2.).
Рис. 2.2. Рекурсивная композиция текста и графики
Данную физическую структуру можно представить, введя отдельный объект для каждого существенного элемента. К таковым относятся не только видимые элементы вроде символов и графики, но и структурные элементы – строки и колонки. В результате получается структура объекта, изображенная на рис. 2.3.
Представляя объектом каждый символ и графический элемент документа, мы обеспечиваем гибкость на самых нижних уровнях дизайна Lexi. С точки зрения отображения, форматирования и вкладывания друг в друга единообразно трактуются текст и графика. Мы сможем расширить Lexi для поддержки новых наборов символов, не затрагивая никаких других функций. Объектная структура Lexi точно отражает физическую структуру документа.
Рис. 2.3. Структура объекта для рекурсивной композиции текста и графики
У описанного подхода есть два важных следствия. Первое очевидно: для объектов нужны соответствующие классы. Второе, менее очевидное, состоит в том, что у этих классов должны быть совместимые интерфейсы, поскольку мы хотим унифицировать работу с ними. Для обеспечения совместимости интерфейсов в таком языке, как C++, применяется наследование.
Абстрактный класс Glyph (глиф) определяется для всех объектов, которые могут присутствовать в структуре документа1. Его подклассы определяют как примитивные графические элементы (скажем, символы и изображения), так и структурные элементы (строки и колонки). На рис. 2.4 изображена достаточно обширная часть иерархии класса Glyph, а в таблице 2.1 более подробно представлен базовый интерфейс этого класса в нотации C++2.
Рис. 2.4. Частичная иерархия класса Glyph
Таблица 2.1. Базовый интерфейс класса Glyph
У глифов есть три основные функции. Они имеют информацию о своих предках и потомках, а также о том, как нарисовать себя на экране и сколько места они занимают.
Подклассы класса Glyph переопределяют операцию Draw, выполняющую перерисовку себя в окне. При вызове Draw ей передается ссылка на объект Window. В классе Window определены графические операции для прорисовки в окне на экране текста и основных геометрических фигур. Например, в подклассе Rectangle операция Draw могла определяться так:
void Rectangle::Draw (Window* w) {
w->DrawRect(_x0, _y0, _x1, _y1);
}
где _x0, _y0, _x1 и _y1 – это данные-члены класса Rectangle, определяющие два противоположных угла прямоугольника, а DrawRect – операция из класса Window, рисующая на экране прямоугольник.
Глифу-родителю часто бывает нужно «знать», сколько места на экране занимает глиф-потомок, чтобы расположить его и остальные глифы в строке без перекрытий (как показано на рис. 2.2). Операция Bounds возвращает прямоугольную область, занимаемую глифом, точнее, противоположные углы наименьшего прямоугольника, содержащего глиф. В подклассах класса Glyph эта операция переопределена в соответствии с природой конкретного элемента.
Операция Intersects возвращает признак, показывающий, лежит ли заданная точка в пределах глифа. Всякий раз, когда пользователь щелкает мышью где-то в документе, Lexi вызывает эту операцию, чтобы определить, какой глиф или глифовая структура оказалась под курсором мыши. Класс Rectangle переопределяет эту операцию для вычисления пересечения точки с прямоугольником.
Поскольку у глифов могут быть потомки, то нам необходим единый интерфейс для добавления, удаления и обхода потомков. Например, потомки класса Row – это глифы, расположенные в данной строке. Операция Insert вставляет глиф в позицию, заданную целочисленным индексом.1 Операция Remove удаляет заданный глиф, если он действительно является потомком.
Операция Child возвращает потомка с заданным индексом (если таковой существует). Глифы типа Row, у которых действительно есть потомки, должны пользоваться операцией Child, а не обращаться к структуре данных потомка напрямую. В таком случае при изменении структуры данных, скажем, с массива на связанный список не придется модифицировать операции вроде Draw, которые обходят всех потомков. Аналогично операция Parent предоставляет стандартный интерфейс для доступа к родителю глифа, если таковой имеется. В Lexi глифы хранят ссылку на своего родителя, а Parent просто возвращает эту ссылку.
Рекурсивная композиция пригодна не только для документов. Мы можем воспользоваться ей для представления любых потенциально сложных иерархических структур. Паттерн компоновщик инкапсулирует сущность рекурсивной композиции объектно-ориентированным способом. Сейчас самое время обратиться к разделу об этом паттерне и изучить его, имея в виду только что рассмотренный сценарий.
Мы решили, как представлять физическую структуру документа. Далее нужно разобраться с тем, как сконструировать конкретную физическую структуру, соответствующую правильно отформатированному документу. Представление и форматирование – это разные аспекты проектирования. Описание внутренней структуры не дает возможности добраться до определенной подструктуры. Ответственность за это лежит в основном на Lexi. Редактор разбивает текст на строки, строки – на колонки и т.д., учитывая при этом пожелания пользователя. Так, пользователь может изменить ширину полей, размер отступа и положение точек табуляции, установить одиночный или двойной междустрочный интервал, а также задать много других параметров форматирования.1 В процессе форматирования это учитывается.
Кстати говоря, мы сузим значение термина «форматирование», понимая под этим лишь разбиение на строки. Будем считать слова «форматирование» и «разбиение на строки взаимозаменяемыми. Обсуждаемая техника в равной мере применима и к разбиению строк на колонки, и к разбиению колонок на страницы.
Таблица 2.2. Базовый интерфейс класса Compositor
С учетом всех ограничений и деталей процесс форматирования с трудом поддается автоматизации. К этой проблеме есть много подходов, и у разных алгоритмов форматирования имеются свои сильные и слабые стороны. Поскольку Lexi – это WYSIWYG-редактор, важно соблюдать компромисс между качеством и скоростью форматирования. В общем случае желательно, чтобы редактор реагировал достаточно быстро и при этом внешний вид документа оставался приемлемым. На достижение этого компромисса влияет много факторов, и не все из них удастся установить на этапе компиляции. Например, можно предположить, что пользователь готов смириться с замедленной реакцией в обмен на лучшее качество форматирования. При таком предположении нужно было бы применять совершенно другой алгоритм форматирования. Есть также компромисс между временем и памятью, скорее, имеющий отношение к реализации: время форматирования можно уменьшить, если хранить в памяти больше информации.
Поскольку алгоритмы форматирования обычно оказываются весьма сложными, желательно, чтобы они были достаточно замкнутыми, а еще лучше – полностью независимыми от структуры документа. Оптимальный вариант – добавление нового вида глифа вовсе не затрагивает алгоритм форматирования. С другой стороны, при добавлении нового алгоритма форматирования не должно возникать необходимости в модификации существующих глифов.
Учитывая все вышесказанное, мы должны постараться спроектировать Lexi так, чтобы алгоритм форматирования легко можно было заменить, по крайней мере, на этапе компиляции, если уж не во время выполнения. Допустимо изолировать алгоритм и одновременно сделать его легко замещаемым путем инкапсулирования в объекте. Точнее, мы определим отдельную иерархию классов для объектов, инкапсулирующих алгоритмы форматирования. Для корневого класса иерархии определим интерфейс, который поддерживает широкий спектр алгоритмов, а каждый подкласс будет реализовывать этот интерфейс в виде конкретного алгоритма форматирования. Тогда удастся ввести подкласс класса Glyph, который будет автоматически структурировать своих потомков с помощью переданного ему объекта-алгоритма.
Мы определим класс Compositor (композитор) для объектов, которые могут инкапсулировать алгоритм форматирования. Интерфейс (см. табл. 2.2) по-зволяет объекту этого класса узнать, какие глифы надо форматировать и когда. Форматируемые композитором глифы являются потомками специального подкласса класса Glyph, который называется Composition (композиция). Композиция при создании получает объект некоторого подкласса Compositor (специализированный для конкретного алгоритма разбиения на строки) и в нужные моменты предписывает композитору форматировать глифы, по мере того как пользователь изменяет документ. На рис. 2.5 изображены отношения между классами Composition и Compositor.
Рис. 2.5. Отношения классов Composition и Compositor
Неформатированный объект Composition содержит только видимые глифы, составляющие основное содержание документа. В нем нет глифов, определяющих физическую структуру документа, например Row и Column. В таком состоянии композиция находится сразу после создания и инициализации глифами, которые должна отформатировать. Во время форматирования композиция вызывает операцию Compose своего объекта Compositor. Композитор обходит всех потомков композиции и вставляет новые глифы Row и Column в соответствии со своим алгоритмом разбиения на строки.1 На рис. 2.6 показана получающаяся объектная структура. Глифы, созданные и вставленные в эту структуру композитором, закрашены на рисунке серым цветом.
Рис. 2.6. Объектная структура, отражающая алгоритм разбиения на строки, выбираемый композитором
Каждый подкласс класса Compositor может реализовывать свой собственный алгоритм форматирования. Например, класс SimpleCompositor мог бы осуществлять быстрый проход, не обращая внимания на такую экзотику, как «цвет» документа. Под «хорошим цветом» понимается равномерное распределение текста и пустого пространства. Класс TeXCompositor мог бы реализовывать полный алгоритм TЕX [Knu84], учитывающий наряду со многими другими вещами и цвет, но за счет увеличения времени форматирования.
Наличие классов Compositor и Composition обеспечивает четкое отделение кода, поддерживающего физическую структуру документа, от кода различных алгоритмов форматирования. Мы можем добавить новые подклассы к классу Compositor, не трогая классов глифов, и наоборот. Фактически допустимо подменить алгоритм разбиения на строки во время выполнения, добавив одну-единственную операцию SetCompositor к базовому интерфейсу класса Composition.
Инкапсуляция алгоритма в объект – это назначение паттерна стратегия. Основными участниками паттерна являются объекты-стратегии, инкапсулирующие различные алгоритмы, и контекст, в котором они работают. Композиторы представляют варианты стратегий; они инкапсулируют алгоритмы форматирования. Композиция – это контекст для стратегии композитора.
Ключ к применению паттерна стратегия – спроектировать интерфейсы стратегии и контекста, достаточно общие для поддержки широкого диапазона алгоритмов. Не должно возникать необходимости изменять интерфейс стратегии или контекста для поддержки нового алгоритма. В нашем примере поддержка доступа к потомкам, их вставки и удаления, предоставляемая базовыми интерфейсами класса Glyph, достаточно общая, чтобы подклассы класса Compositor могли изменять физическую структуру документа независимо от того, с помощью каких алгоритмов это делается. Аналогично интерфейс класса Compositor дает композициям все, что им необходимо для инициализации форматирования.
Рассмотрим два усовершенствования пользовательского интерфейса Lexi. Первое добавляет рамку вокруг области редактирования текста, чтобы четко обозначить страницу текста, второе – полосы прокрутки, позволяющие пользователю просматривать разные части страницы. Чтобы упростить добавление и удаление таких элементов оформления (особенно во время выполнения), мы не должны использовать наследование. Максимальной гибкости можно достичь, если другим объектам пользовательского интерфейса даже не будет известно о том, какие еще есть элементы оформления. Это позволит добавлять и удалять декорации, не изменяя других классов.
В программировании оформление пользовательского интерфейса означает расширение существующего кода. Использование для этой цели наследования не дает возможности реорганизовать интерфейс во время выполнения. Не менее серьезной проблемой является комбинаторный рост числа классов в случае широкого использования наследования.
Можно было бы добавить рамку к классу Composition, породив от него новый подкласс BorderedComposition. Точно так же можно было бы добавить и интерфейс прокрутки, породив подкласс ScrollableComposition. Если же мы хотим иметь и рамку, и полосу прокрутки, следовало бы создать подкласс BorderedScrollableComposition, и так далее. Если довести эту идею до логического завершения, то пришлось бы создавать отдельный подкласс для каждой возможной комбинации элементов оформления. Это решение быстро перестает работать, когда количество разнообразных декораций растет.
С помощью композиции объектов можно найти куда более приемлемый и гибкий механизм расширения. Но из каких объектов составлять композицию? Поскольку известно, что мы оформляем существующий глиф, то и сам элемент оформления могли бы сделать объектом (скажем, экземпляром класса Border). Следовательно, композиция может быть составлена из глифа и рамки. Следующий шаг – решить, что является агрегатом, а что – компонентом. Допустимо считать, что рамка содержит глиф, и это имеет смысл, так как рамка окружает глиф на экране. Можно принять и противоположное решение – поместить рамку внутрь глифа, но тогда пришлось бы модифицировать соответствующий подкласс класса Glyph, чтобы он «знал» о наличии рамки. Первый вариант – включение глифа в рамку – позволяет поместить весь код для отображения рамки в классе Border, оставив остальные классы без изменения.
Как выглядит класс Border? Тот факт, что у рамки есть визуальное представление, наталкивает на мысль, что она должна быть глифом, то есть подклассом класса Glyph. Но есть и более настоятельные причины поступить именно таким образом: клиентов не должно волновать, есть у глифов рамки или нет. Все глифы должны трактоваться единообразно. Когда клиент сообщает простому глифу без рамки о необходимости нарисовать себя, тот делает это, не добавляя никаких элементов оформления. Если же этот глиф заключен в рамку, то клиент не должен обрабатывать рамку как-то специально; он просто предписывает составному глифу выполнить отображение точно так же, как и простому глифу в предыдущем случае. Отсюда следует, что интерфейс класса Border должен соответствовать интерфейсу класса Glyph. Чтобы гарантировать это, мы и делаем Border подклассом Glyph.
Все это подводит нас к идее прозрачного обрамления (transparent enclosure), где комбинируются понятия о композиции с одним потомком (однокомпонентные), и о совместимых интерфейсах. В общем случае клиенту неизвестно, имеет ли он дело с компонентом или его обрамлением (то есть родителем), особенно если обрамление просто делегирует все операции своему единственному компоненту. Но обрамление может также и расширять поведение компонента, выполняя дополнительные действия либо до, либо после делегирования (а возможно, и до, и после). Обрамление может также добавить компоненту состояние. Как именно, будет показано ниже.
Концепцию прозрачного обрамления можно применить ко всем глифам, оформляющим другие глифы. Чтобы конкретизировать эту идею, определим подкласс класса Glyph, называемый MonoGlyph. Он будет выступать в роли абстрактного класса для глифов-декораций вроде рамки (см. рис. 2.7). В классе MonoGlyph хранится ссылка на компонент, которому он и переадресует все запросы. При этом MonoGlyph по определению становится абсолютно прозрачным для клиентов. Вот как моноглиф реализует операцию Draw:
void MonoGlyph::Draw (Window* w) {
_component->Draw(w);
}
Рис. 2.7. Отношения класса MonoGlyph с другими классами
Подклассы MonoGlyph замещают по меньшей мере одну из таких операций переадресации. Например, Border::Draw сначала вызывает операцию родительского класса MonoGlyph::Draw, чтобы компонент выполнил свою часть работы, то есть нарисовал все, кроме рамки. Затем Border::Draw рисует рамку, вызывая свою собственную закрытую операцию DrawBorder, детали которой мы опустим:
void Border::Draw (Window* w) {
MonoGlyph::Draw(w);
DrawBorder(w);
}
Обратите внимание, что Border::Draw, по сути дела, расширяет операцию родительского класса, чтобы нарисовать рамку. Это не то же самое, что простая замена операции: в таком случае MonoGlyph::Draw не вызывалась бы.
На рис. 2.7 показан другой подкласс класса MonoGlyph. Scroller – это MonoGlyph, который рисует свои компоненты на экране в зависимости от положения двух полос прокрутки, добавляющихся в качестве элементов оформления. Когда Scroller отображает свой компонент, графическая система обрезает его по границам окна. Отсеченные части компонента, оказавшиеся за пределами видимой части окна, не появляются на экране.
Теперь у нас есть все, что необходимо для добавления рамки и прокрутки к области редактирования текста в Lexi. Мы помещаем имеющийся экземпляр класса Composition в экземпляр класса Scroller, чтобы добавить интерфейс прокрутки, а результат композиции еще раз погружаем в экземпляр класса Border. Получившийся объект показан на рис. 2.8.
Рис. 2.8. Объектная структура после добавления элементов оформления
Обратите внимание, что мы могли изменить порядок композиции на обратный, сначала добавив рамку, а потом погрузив результат в Scroller. В таком случае рамка прокручивалась бы вместе с текстом. Может быть, это то, что вам нужно, а может, и нет. Важно лишь, что прозрачное обрамление легко позволяет экспериментировать с разными вариантами, освобождая клиента от знания деталей кода, добавляющего декорации.
Отметим, что рамка допускает композицию только с одним глифом, не более того. Этим она отличается от рассмотренных выше композиций, где родительскому объекту позволялось иметь сколько угодно потомков. Здесь же заключение чего-то в рамку предполагает, что это «что-то» имеется в единственном экземпляре. Мы могли бы приписать некоторую семантику декорации более одного объекта, но тогда пришлось бы вводить множество видов композиций с оформлением: оформление строки, колонки и т.д. Это не дает ничего нового, так как у нас уже есть классы для такого рода композиций. Поэтому для композиции лучше использовать уже существующие классы, а новые добавлять для оформления результата. Отделение декорации от других видов композиции одновременно упрощает классы, реализующие разные элементы оформления, и уменьшает их количество. Кроме того, мы избавлены от необходимости дублировать уже имеющуюся функциональность.
Паттерн декоратор абстрагирует отношения между классами и объектами, необходимые для поддержки оформления с помощью техники прозрачного обрамления. Термин «оформление» на самом деле применяется в более широком смысле, чем мы видели выше. В паттерне декоратор под ним понимается нечто, что возлагает на объект новые обязанности. Можно, например, представить себе оформление абстрактного дерева синтаксического разбора семантическими действиями, конечного автомата – новыми состояниями или сети, состоящей из устойчивых объектов, – тэгами атрибутов. Декоратор обобщает подход, который мы использовали в Lexi, чтобы расширить его область применения.
При проектировании системы приходится сталкиваться с серьезной проблемой: как добиться переносимости между различными программно-аппаратными платформами. Перенос Lexi на другую платформу не должен требовать капитального перепроектирования, иначе не стоит за него и браться. Он должен быть максимально прост.
Одним из препятствий для переноса является разнообразие стандартов внешнего облика, призванных унифицировать работу с приложениями на данной платформе. Эти стандарты определяют, как приложения должны выглядеть и реагировать на действия пользователя. Хотя существующие стандарты не так уж сильно отличаются друг от друга, ни один пользователь не спутает один стандарт с другим – приложения, написанные для Motif, выглядят не совсем так, как аналогичные приложения на других платформах, и наоборот. Программа, работающая более чем на одной платформе, должна всюду соответствовать принятой стилистике пользовательского интерфейса.
Наша проектная цель – сделать так, чтобы Lexi поддерживал разные стандарты внешнего облика и чтобы легко можно было добавить поддержку нового стандарта, как только таковой появится (а это неизбежно произойдет). Хотелось бы также, чтобы наш дизайн решал и другую задачу: изменение внешнего облика Lexi во время выполнения.
Все, что мы видим и с чем можем взаимодействовать в пользовательском интерфейсе Lexi, – это визуальные глифы, скомпонованные в другие, уже невидимые глифы вроде строки (Row) и колонки (Column). Невидимые глифы содержат видимые – скажем, кнопку (Button) или символ (Character) – и правильно располагают их на экране. В руководствах по стилистическому оформлению много говорится о внешнем облике и поведении так называемых «виджетов» (widgets); это просто другое название таких видимых глифов, как кнопки, полосы прокрутки и меню, выполняющих в пользовательском интерфейсе функции элементов управления. Для представления данных виджеты могут пользоваться более простыми глифами: символами, окружностями, прямоугольниками и многоугольниками.
Мы будем предполагать, что имеется два набора классов глифов-виджетов, с помощью которых реализуются стандарты внешнего облика:
• набор абстрактных подклассов класса Glyph для каждой категории виджетов. Например, абстрактный класс ScrollBar будет дополнять интерфейс глифа с целью получения операций прокрутки общего вида, а Button – это абстрактный класс, добавляющий операции с кнопками;
• набор конкретных подклассов для каждого абстрактного подкласса, в которых реализованы стандарты внешнего облика. Так, у ScrollBar могут быть подклассы MotifScrollBar и PMScrollBar, реализующие полосы прокрутки в стиле Motif и Presentation Manager соответственно.
Lexi должен различать глифы-виджеты для разных стилей внешнего оформления. Например, когда необходимо поместить в интерфейс кнопку, редактор должен инстанцировать подкласс класса Glyph для нужного стиля кнопки (MotifButton, PMButton, MacButton и т.д.).
Ясно, что в реализации Lexi это нельзя сделать непосредственно, например, вызвав конструктор, если речь идет о языке C++. При этом была бы жестко закодирована кнопка одного конкретного стиля, значит, выбрать нужный стиль во время выполнения оказалось бы невозможно. Кроме того, мы были бы вынуждены отслеживать и изменять каждый такой вызов конструктора при переносе Lexi на другую платформу. А ведь кнопки – это лишь один элемент пользовательского интерфейса Lexi. Загромождение кода вызовами конструкторов для разных классов внешнего облика вызывает существенные неудобства при сопровождении. Стоит что-нибудь пропустить – и в приложении, ориентированном на платформу Mac, появится меню в стиле Motif.
Lexi необходим какой-то способ определить нужный стандарт внешнего облика для создания подходящих виджетов. При этом надо не только постараться избежать явных вызовов конструкторов, но и уметь без труда заменять весь набор виджетов. Этого можно добиться путем абстрагирования процесса создания объекта. Далее на примере мы продемонстрируем, что имеется в виду.
В Motif для создания экземпляра глифа полосы прокрутки обычно было достаточно написать следующий код на C++:
ScrollBar* sb = new MotifScrollBar;
Но такого кода надо избегать, если мы хотим минимизировать зависимость Lexi от стандарта внешнего облика. Предположим, однако, что sb инициализируется так:
ScrollBar* sb = guiFactory->CreateScrollBar();
где guiFactory – CreateScrollBar объект класса MotifFactory. Операция возвращает новый экземпляр подходящего подкласса ScrollBar, который соответствует желательному варианту внешнего облика, в нашем случае – Motif. С точки зрения клиентов результат тот же самый, что и при прямом обращении к конструктору MotifScrollBar. Но есть и существенное отличие: нигде в коде больше не упоминается имя Motif. Объект guiFactory абстрагирует процесс создания не только полос прокрутки для Motif, но и любых других. Более того, guiFactory не ограничен изготовлением только полос прокрутки. Его можно применять для производства любых виджетов, включая кнопки, поля ввода, меню и т.д.
Все это стало возможным, поскольку MotifFactory является подклассом GUIFactory – абстрактного класса, который определяет общий интерфейс для создания глифов-виджетов. В нем есть такие операции, как CreateScrollBar и CreateButton, для инстанцирования различных видов виджетов. Подклассы GUIFactory реализуют эти операции, возвращая глифы вроде MotifScrollBar и PMButton, которые имеют нужный внешний облик и поведение. На рис. 2.9 показана иерархия классов для объектов guiFactory.
Рис. 2.9. Иерархия классов GUIFactory
Мы говорим, что фабрики изготавливают объекты. Продукты, изготовленные фабриками, связаны друг с другом; в нашем случае все такие продукты – это виджеты, имеющие один и тот же внешний облик. На рис. 2.10 показаны некоторые классы, необходимые для того, чтобы фабрика могла изготавливать глифы-виджеты.
Рис. 2.10. Абстрактные классы-продукты и их конкретные подклассы
Экземпляр класса GUIFactory может быть создан любым способом. Переменная guiFactory может быть глобальной или статическим членом хорошо известного класса или даже локальной, если весь пользовательский интерфейс создается внутри одного класса или функции. Существует специальный паттерн проектирования одиночка, предназначенный для работы с такого рода объектами, существующими в единственном экземпляре. Важно, однако, чтобы фабрика guiFactory была инициализирована до того, как начнет использоваться для производства объектов, но после того, как стало известно, какой внешний облик нужен.
Когда вариант внешнего облика известен на этапе компиляции, то guiFactory можно инициализировать простым присваиванием в начале программы:
GUIFactory* guiFactory = new MotifFactory;
Если же пользователю разрешается задавать внешний облик с помощью строки-параметра при запуске, то код создания фабрики мог бы выглядеть так:
GUIFactory* guiFactory;
const char* styleName = getenv("LOOK_AND_FEEL");
// получаем это от пользователя или среды при запуске
if (strcmp(styleName, "Motif") == 0) {
guiFactory = new MotifFactory;
} else if (strcmp(styleName, "Presentation_Manager") == 0) {
guiFactory = new PMFactory;
} else {
guiFactory = new DefaultGUIFactory;
}
Есть и другие способы выбора фабрики во время выполнения. Например, можно было бы вести реестр, в котором символьные строки отображаются на объекты фабрик. Это позволяет зарегистрировать экземпляр новой фабрики, не меняя существующий код, как то требуется при предыдущем подходе. И нет нужды связывать с приложением код фабрик для всех платформ. Это существенно, поскольку связать код для MotifFactory с приложением, работающим на платформе, где Motif не поддерживается, может оказаться невозможным.
Важно, впрочем, лишь то, что после конфигурации приложения для работы с конкретной фабрикой объектов, мы получаем нужный внешний облик. Если впоследствии мы изменим решение, то сможем инициализировать guiFactory по-другому, чтобы изменить внешний облик, а затем динамически перестроим интерфейс.
Фабрики и их продукция – вот ключевые участники паттерна абстрактная фабрика. Этот паттерн может создавать семейства объектов, не инстанцируя классы явно. Применять его лучше всего, когда число и общий вид изготавливаемых объектов остаются постоянными, но между конкретными семействами продуктов имеются различия. Выбор того или иного семейства осуществляется путем инстанцирования конкретной фабрики, после чего она используется для создания всех объектов. Подставив вместо одной фабрики другую, мы можем заменить все семейство объектов целиком. В паттерне абстрактная фабрика акцент делается на создании семейств объектов, и это отличает его от других порождающих паттернов, создающих только один какой-то вид объектов.
Как должно выглядеть приложение – это лишь один из многих вопросов, встающих при переносе приложения на новую платформу. Еще одна проблема из той же серии – оконная среда, в которой работает Lexi. Данная среда создает иллюзию наличия нескольких перекрывающихся окон на одном растровом дисплее. Она распределяет между окнами площадь экрана и направляет им события клавиатуры и мыши. Сегодня существует несколько широко распространенных и во многом не совместимых между собой оконных систем (например, Macintosh, Presentation Manager, Windows, X). Мы хотели бы, чтобы Lexi работал в любой оконной среде по тем же причинам, по которым мы поддерживаем несколько стандартов внешнего облика.
На первый взгляд представляется, что перед нами еще одна возможность применить паттерн абстрактная фабрика. Но ограничения, связанные с переносом на другие оконные системы, существенно отличаются от тех, что накладывают независимость от внешнего облика.
Применяя паттерн абстрактная фабрика, мы предполагали, что удастся определить конкретный класс глифов-виджетов для каждого стандарта внешнего облика. Это означало, что можно будет произвести конкретный класс для данного стандарта (например, MotifScrollBar и MacScrollBar) от абстрактного класса (допустим, ScrollBar). Предположим, однако, что у нас уже есть несколько иерархий классов, полученных от разных поставщиков, – по одной для каждого стандарта. Маловероятно, что данные иерархии будут совместимы между собой. Поэтому отсутствуют общие абстрактные изготавливаемые классы для каждого вида виджетов (ScrollBar, Button, Menu и т.д.). А без них фабрика классов работать не может. Необходимо, чтобы иерархии виджетов имели единый набор абстрактных интерфейсов. Только тогда удастся правильно объявить операции Create... в интерфейсе абстрактной фабрики.
Для виджетов мы решили эту проблему, разработав собственные абстрактные и конкретные изготавливаемые классы. Теперь сталкиваемся с аналогичной трудностью при попытке заставить Lexi работать во всех существующих оконных средах. Именно разные среды имеют несовместимые интерфейсы программирования. Но на этот раз все сложнее, поскольку мы не можем себе позволить реализовать собственную нестандартную оконную систему.
Однако спасительный выход все же есть. Как и стандарты на внешний облик, интерфейсы оконных систем не так уж радикально отличаются друг от друга, ибо все они предназначены примерно для одних и тех же целей. Нам нужен унифицированный набор оконных абстракций, которым можно закрыть любую конкретную реализацию оконной системы.
В разделе 2.2 мы ввели класс Window для отображения на экране глифа или структуры, состоящей из глифов. Ничего не говорилось о том, с какой оконной системой работает этот объект, поскольку в действительности он вообще не связан ни с одной системой. Класс Window инкапсулирует понятие окна в любой оконной системе:
• операции для отрисовки базовых геометрических фигур;
• возможность свернуть и развернуть окно;
• возможность изменить собственные размеры;
• возможность при необходимости отобразить свое содержимое, например при развертывании или открытии ранее перекрытой части окна.
Класс Window должен покрывать функциональность окон, которая имеется
в различных оконных системах. Рассмотрим два крайних подхода:
• пересечение функциональности. Интерфейс класса Window предоставляет только операции, общие для всех оконных систем. Однако в результате мы получаем интерфейс не богаче, чем в самой слабой из рассматриваемых систем. Мы не можем воспользоваться более развитыми средствами, даже если их поддерживает большинство оконных систем (но не все);
• объединение функциональности. Создается интерфейс, который включает возможности всех существующих систем. Здесь нас подстерегает опасность получить чрезмерно громоздкий и внутренне не согласованный интерфейс. Кроме того, нам придется изменять его (а вместе с ним и Lexi) всякий раз, как только производитель переработает интерфейс своей оконной системы.
Таблица 2.3. Интерфейс класса Window
Ни то, ни другое решение «в чистом виде» не годятся, поэтому мы выберем компромиссное. Класс Window будет предоставлять удобный интерфейс, поддерживающий наиболее популярные возможности оконных систем. Поскольку редактор Lexi будет работать с классом Window напрямую, этот класс должен поддерживать и сущности, о которых Lexi известно, то есть глифы. Это означает, что интерфейс класса Window должен включать базовый набор графических операций, позволяющий глифам отображать себя в окне. В табл. 2.3 перечислены некоторые операции из интерфейса класса Window.
Window – это абстрактный класс. Его конкретные подклассы поддерживают различные виды окон, с которыми имеет дело пользователь. Например, окна приложений, сообщений, значки – это все окна, но свойства у них разные. Для учета таких различий мы можем определить подклассы ApplicationWindow, IconWindow и DialogWindow. Возникающая иерархия позволяет таким приложениям, как Lexi, создать унифицированную, интуитивно понятную абстракцию окна, не зависящую от оконной системы конкретного поставщика.
Итак, мы определили оконный интерфейс, с которым будет работать Lexi. Но где же в нем место для реальной платформенно-зависимой оконной системы? Если мы не собираемся реализовывать собственную оконную систему, то в каком-то месте наша абстракция окна должна быть выражена в терминах целевой системы. Но где именно?
Можно было бы реализовать несколько вариантов класса Window и его подклассов – по одному для каждой оконной среды. Выбор нужного варианта производится при сборке Lexi для данной платформы. Но представьте себе, с чем вы столкнетесь при сопровождении, если придется отслеживать множество разных классов с одним и тем же именем Window, но реализованных для разных оконных систем. Вместо этого мы могли бы создать зависящие от реализации подклассы каждого класса в иерархии Window, но закончилось бы это тем же самым комбинаторным ростом числа классов, о котором уже говорилось при попытке добавить элементы оформления. Кроме того, оба решения недостаточно гибки, чтобы можно было перейти на другую оконную систему уже после компиляции программы. Поэтому придется поддерживать несколько разных исполняемых файлов.
Ни тот, ни другой вариант не выглядят привлекательно, но что еще можно сделать? То же самое, что мы сделали для форматирования и декорирования, – инкапсулировать изменяющуюся сущность. В этом случае изменчивой частью является реализация оконной системы. Если инкапсулировать функциональность оконной системы в объекте, то удастся реализовать свой класс Window и его подклассы в терминах интерфейса этого объекта. Более того, если такой интерфейс сможет поддержать все интересующие нас оконные системы, то не придется изменять ни Window, ни его подклассы при переходе на другую систему. Мы сконфигурируем оконные объекты в соответствии с требованиями нужной оконной системы, просто передав им подходящий объект, инкапсулирующий оконную систему. Это можно сделать даже во время выполнения.
Мы определим отдельную иерархию классов WindowImp, в которой скроем знание о различных реализациях оконных систем. WindowImp – это абстрактный класс для объектов, инкапсулирующих системно-зависимый код. Чтобы заставить Lexi работать в конкретной оконной системе, каждый оконный объект будем конфигурировать экземпляром того подкласса WindowImp, который предназначен для этой системы. На диаграмме ниже представлены отношения между иерархиями Window и WindowImp:
Скрыв реализацию в классах WindowImp, мы сумели избежать «засорения» классов Window зависимостями от оконной системы. В результате иерархия Window получается сравнительно компактной и стабильной. В то же время мы можем расширить иерархию реализаций, если будет нужно поддержать новую оконную систему.
Подклассы WindowImp преобразуют запросы в операции, характерные для конкретной оконной системы. Рассмотрим пример из раздела 2.2. Мы определили Rectangle::Draw в терминах операции DrawRect над экземпляром класса Window:
void Rectangle::Draw (Window* w) {
w->DrawRect(_x0, _y0, _x1, _y1);
}
В реализации DrawRect по умолчанию используется абстрактная операция рисования прямоугольников, объявленная в WindowImp:
void Window::DrawRect (
Coord x0, Coord y0, Coord x1, Coord y1
) {
_imp->DeviceRect(x0, y0, x1, y1);
}
где _imp – переменная-член класса Window, в которой хранится указатель на объект WindowImp, использованный при конфигурировании Window. Реализация окна определяется тем экземпляром подкласса WindowImp, на который указывает _imp. Для XWindowImp (то есть подкласса WindowImp для оконной системы X Window System) реализация DeviceRect могла бы выглядеть так:
void XWindowImp::DeviceRect (
Coord x0, Coord y0, Coord x1, Coord y1
) {
int x = round(min(x0, x1));
int y = round(min(y0, y1));
int w = round(abs(x0 – x1));
int h = round(abs(y0 – y1));
XDrawRectangle(_dpy, _winid, _gc, x, y, w, h);
}
DeviceRect определено именно так, поскольку XDrawRectangle (функция X Windows для рисования прямоугольников) определяет прямоугольник с помощью левого нижнего угла, ширины и высоты. DeviceRect должна вычислить эти значения по переданным ей параметрам. Сначала находится левый нижний угол (поскольку (x0, y0) может обозначать любой из четырех углов прямоугольника), а затем вычисляется длина и ширина.
Подкласс PMWindowImp (подкласс WindowImp для Presentation Manager) определил бы DeviceRect по-другому:
void PMWindowImp::DeviceRect (
Coord x0, Coord y0, Coord x1, Coord y1
) {
Coord left = min(x0, x1);
Coord right = max(x0, x1);
Coord bottom = min(y0, y1);
Coord top = max(y0, y1);
PPOINTL point[4];
point[0].x = left; point[0].y = top;
point[1].x = right; point[1].y = top;
point[2].x = right; point[2].y = bottom;
point[3].x = left; point[3].y = bottom;
if (
(GpiBeginPath(_hps, 1L) == false) ||
(GpiSetCurrentPosition(_hps, &point[3]) == false) ||
(GpiPolyLine(_hps, 4L, point) == GPI_ERROR) ||
(GpiEndPath(_hps) == false)
) {
// сообщить об ошибке
} else {
GpiStrokePath(_hps, 1L, 0L);
}
}
Откуда такое отличие от версии для X? Дело в том, что в Presentation Manager (PM) нет явной операции для рисования прямоугольников, как в X. Вместо этого PM имеет более общий интерфейс для задания вершин фигуры, состоящей из нескольких отрезков (множество таких вершин называется траекторией), и для рисования границы или заливки той области, которую эти отрезки ограничивают.
Очевидно, что реализации DeviceRect для PM и X совершенно непохожи, но это не имеет никакого значения. Возможно, WindowImp скрывает различия интерфейсов оконных систем за большим, но стабильным интерфейсом. Это позволяет автору подкласса Window сосредоточиться на абстракции окна, а не на деталях оконной системы. Заодно мы получаем возможность добавлять поддержку для новых оконных систем, не изменяя классы из иерархии Window.
Важнейший вопрос, который мы еще не рассмотрели, – как сконфигурировать окно с помощью подходящего подкласса WindowImp. Другими словами, когда инициализируется переменная _imp и как узнать, какая оконная система (следовательно, и подкласс WindowImp) используется? Ведь окну необходим объект WindowImp.
Тут есть несколько возможностей, но мы остановимся на той, где используется паттерн абстрактная фабрика. Можно определить абстрактный фабричный класс WindowSystemFactory, предоставляющий интерфейс для создания различных видов системно-зависимых объектов:
class WindowSystemFactory {
public:
virtual WindowImp* CreateWindowImp() = 0;
virtual ColorImp* CreateColorImp() = 0;
virtual FontImp* CreateFontImp() = 0;
// операции "Create..." для всех видов ресурсов оконной системы
};
Далее разумно определить конкретную фабрику для каждой оконной системы:
class PMWindowSystemFactory : public WindowSystemFactory {
virtual WindowImp* CreateWindowImp()
{ return new PMWindowImp; }
// ...
};
class XWindowSystemFactory : public WindowSystemFactory {
virtual WindowImp* CreateWindowImp()
{ return new XWindowImp; }
// ...
};
Чтобы инициализировать член _imp указателем на объект WindowImp, соответствующий данной оконной системе, конструктор базового класса Window может использовать интерфейс WindowSystemFactory:
Window::Window () {
_imp = windowSystemFactory->CreateWindowImp();
}
Переменная windowSystemFactory – это известный программе экземпляр подкласса WindowSystemFactory. Она, аналогично переменной guiFactory, определяет внешний облик. И инициализировать windowSystemFactory можно точно так же.
Класс WindowImp определяет интерфейс к общим средствам оконной системы, но на его дизайн накладываются иные ограничения, нежели на интерфейс класса Window. Прикладной программист не обращается к интерфейсу WindowImp непосредственно, он имеет дело только с объектами класса Window. Поэтому интерфейс WindowImp необязательно должен соответствовать представлению программиста о мире, как то было в случае с иерархией и интерфейсом класса Window. Интерфейс WindowImp может более точно отражать сущности, которые в действительности предоставляют оконные системы, со всеми их особенностями. Он может быть ближе к идее пересечения или объединения функциональности – в зависимости от требований к целевой оконной системе.
Важно понимать, что интерфейс класса Window призван обслуживать интересы прикладного программиста, тогда как интерфейс класса WindowImp в большей степени ориентирован на оконные системы. Разделение функциональности окон между иерархиями Window и WindowImp позволяет нам независимо реализовывать и специализировать их интерфейсы. Объекты из этих иерархий взаимодействуют, позволяя Lexi работать без изменений в нескольких оконных системах.
Отношение иерархий Window и WindowImp являет собой пример паттерна мост. Идея его создания заключалась в том, чтобы предоставить возможность совместной работы отдельным иерархиям классов, даже в случае их раздельного эволюционирования. Критерии разработки, которыми мы руководствовались, заставили нас создать две различные иерархии классов: одну, поддерживающую логическое понятие окон, и другую для хранения промежуточных вариантов окон. Паттерн мост позволяет нам сохранять и совершенствовать наши логические абстракции управления окнами без необходимости привлечения программно-зависимого кода и наоборот.
Часть функциональности Lexi доступна через WYSIWYG-представление документа. Вы вводите и удаляете текст, перемещаете точку вставки и выбираете участки текста, просто указывая и щелкая мышью или нажимая клавиши. Другая часть функциональности доступна через выпадающие меню, кнопки и клавиши-ускорители. К этой категории относятся такие операции:
• создание нового документа;
• открытие, сохранение и печать существующего документа;
• вырезание выбранной части документа и вставка ее в другое место;
• изменение шрифта и стиля выбранного текста;
• изменение форматирования текста, например, установка режима выравнивания;
• завершение приложения и др.
Lexi предоставляет для этих операций различные пользовательские интерфейсы. Но мы не хотим ассоциировать конкретную операцию с определенным пользовательским интерфейсом, поскольку для выполнения одной и той же операции желательно иметь несколько интерфейсов (например, листать страницы можно с помощью кнопки или выбора из меню). Кроме того, в будущем может понадобиться изменить интерфейс.
Далее. Операции реализованы в разных классах. Нам как разработчикам хотелось бы получать доступ к функциональности классов, не создавая зависимостей между классами, отвечающими за реализацию и за пользовательский интерфейс. В противном случае получится сильно связанный код, который будет трудно понять, модернизировать и сопровождать.
Ситуация осложняется еще и тем, что Lexi должен поддерживать операции отмены и повтора1 большинства, но не всех функций. Точнее, желательно уметь отменять операции модификации документа (скажем, удаление), которые из-за оплошности пользователя могут привести к уничтожению большого объема данных. Но не следует пытаться отменить такую операцию, как сохранение чертежа или завершение приложения. Мы также не хотели бы налагать произвольные ограничения на число уровней отмены и повтора.
Ясно, что поддержка пользовательских операций распределена по всему приложению. Задача в том, чтобы найти простой и расширяемый механизм, удовлетворяющий всем вышеизложенным требованиям.
С точки зрения проектировщика выпадающее меню – это просто еще один вид вложенных глифов. От других глифов, имеющих потомков, его отличает то, что большинство содержащихся в меню глифов каким-то образом реагирует на отпускание кнопки мыши.
Предположим, что такие реагирующие глифы являются экземплярами подкласса MenuItem класса Glyph и что свою работу они выполняют в ответ на запрос клиента2. Для выполнения запроса может потребоваться вызвать одну операцию одного объекта или много операций разных объектов. Возможны и промежуточные варианты.
Мы могли бы определить подкласс класса MenuItem для каждой операции пользователя, а затем жестко закодировать каждый подкласс для выполнения соответствующего запроса. Но вряд ли это правильно: нам не нужен подкласс MenuItem для каждого запроса, точно так же, как не нужен отдельный подкласс для каждой строки в выпадающем меню. Помимо всего прочего, такой подход тесно привязывает запрос к конкретному элементу пользовательского интерфейса, поэтому выполнить тот же запрос через другой интерфейс будет нелегко.
Предположим, что на последнюю страницу документа вы можете перейти, выбрав соответствующий пункт из меню или щелкнув по значку с изображением страницы в нижней части окна Lexi (для коротких документов это удобно). Если мы ассоциируем запрос с подклассом MenuItem с помощью наследования, то должны сделать то же самое и для значка страницы, и для любого другого виджета, который способен послать подобный запрос. В результате число классов будет примерно равно произведению числа типов виджетов на число запросов.
Нам не хватает механизма параметризации пунктов меню запросами, которые они должны выполнять. Таким способом удалось бы избежать разрастания числа подклассов и обеспечить большую гибкость во время выполнения. Параметризовать MenuItem можно вызываемой функцией, но это решение неполно по трем причинам:
• в нем не учитывается проблема отмены/повтора;
• с функцией трудно ассоциировать состояние. Например, функция, изменяющая шрифт, должна «знать», какой именно это шрифт;
• функции с трудом поддаются расширению, а использовать их части тоже затруднительно.
Поэтому лучше параметризовать пункты меню объектом, а не функцией. Тогда мы сможем прибегнуть к механизму наследования для расширения и повторного использования реализации запроса. Кроме того, у нас появляется место для сохранения состояния и возможность реализовать отмену и повтор. Вот еще один пример инкапсуляции изменяющейся сущности, в данном случае – запроса. Каждый запрос мы инкапсулируем в объект-команду.
Сначала определим абстрактный класс Command, который будет предоставлять интерфейс для выдачи запроса. Базовый интерфейс включает всего одну абстрактную операцию Execute. Подклассы Command по-разному реализуют эту операцию для выполнения запросов. Некоторые подклассы могут частично или полностью делегировать работу другим объектам, а остальные выполняют запрос сами (см. рис. 2.11). Однако для запрашивающего объект Command – это всего лишь объект Command, все они рассматриваются одинаково.
Рис. 2.11. Часть иерархии класса Command
Теперь в классе MenuItem может храниться объект, инкапсулирующий запрос (рис. 2.12). Каждому объекту, представляющему пункт меню, мы передаем экземпляр того из подклассов Command, который соответствует этому пункту, точно так же, как мы задаем текст, отображаемый в пункте меню. Когда пользователь выбирает некоторый пункт меню, объект MenuItem просто вызывает операцию Execute для своего объекта Command, тем самым предлагая ему выполнить запрос. Заметим, что кнопки и другие виджеты могут пользоваться объектами Command точно так же, как и пункты меню.
Рис. 2.12. Отношение между классами MenuItem и Command
Для того чтобы отменить или повторить команду, нужна операция Unexecute в интерфейсе класса Command. Ее выполнение отменяет все, что было сделано предыдущей командой Execute. При этом используется информация, сохраненная операцией Execute. Так, при команде FontCommand операция Execute была бы должна сохранить координаты участка текста, шрифт которого изменялся, а равно и первоначальный шрифт (или шрифты). Операция Unexecute класса FontCommand должна была бы восстановить старый шрифт (или шрифты) для этого участка текста.
Иногда определять, можно ли осуществить отмену, необходимо во время выполнения. Скажем, запрос на изменение шрифта выделенного участка текста не производит никаких действий, если текст уже отображен требуемым шрифтом. Предположим, что пользователь выбрал некий текст и решил изменить его шрифт на случайно выбранный. Что произойдет в результате последующего запроса на отмену? Должно ли бессмысленное изменение приводить к столь же бессмысленной отмене? Наверное, нет. Если пользователь повторит случайное изменение шрифта несколько раз, то не следует заставлять его выполнять точно такое же число отмен, чтобы вернуться к последнему осмысленному состоянию. Если суммарный эффект выполнения последовательности команд нулевой, то нет необходимости вообще делать что-либо при запросе на отмену.
Для определения того, можно ли отменить действие команды, мы добавим к интерфейсу класса Command абстрактную операцию Reversible (обратимая), которая возвращает булево значение. Подклассы могут переопределить эту операцию и возвращать true или false в зависимости от критерия, вычисляемого во время выполнения.
Последний шаг в направлении поддержки отмены и повтора с произвольным числом уровней – определение истории команд, то есть списка ранее выполненных или отмененных команд. Концептуально история команд выглядит так:
Каждый кружок представляет один объект Command. В данном случае пользователь выполнил четыре команды. Линия с пометкой «настоящее» показывает самую последнюю выполненную (или отмененную) команду.
Для того чтобы отменить последнюю команду, мы просто вызываем операцию Unexecute для самой последней команды:
После отмены команды сдвигаем линию «настоящее» на одну команду влево. Если пользователь выполнит еще одну отмену, то произойдет откат на один шаг (см. рис. ниже).
Видно, что за счет простого повторения процедуры мы получаем произвольное число уровней отмены, ограниченное лишь длиной списка истории команд.
Чтобы повторить только что отмененную команду, произведем обратные действия. Команды справа от линии «настоящее» – те, что могут быть повторены в будущем. Для повтора последней отмененной команды мы вызываем операцию Execute для последней команды справа от линии «настоящее»:
Затем мы сдвигаем линию «настоящее» так, чтобы следующий повтор вызвал операцию Execute для следующей команды «в области будущего».
Разумеется, если следующая операция – это не повтор, а отмена, то команда слева от линии «настоящее» будет отменена. Таким образом, пользователь может перемещаться в обоих направлениях, чтобы исправить ошибки.
Рис. 2.13. Класс Iterator и его подклассы
Команды Lexi – это пример применения паттерна команда, описывающего, как инкапсулировать запрос. Этот паттерн предписывает единообразный интерфейс для выдачи запросов, с помощью которого можно сконфигурировать клиенты для обработки разных запросов. Интерфейс изолирует клиента от реализации запроса. Команда может полностью или частично делегировать реализацию запроса другим объектам либо выполнять данную операцию самостоятельно. Это идеальное решение для приложений типа Lexi, которые должны предоставлять централизованный доступ к функциональности, разбросанной по разным частям программы. Данный паттерн предлагает также механизмы отмены и повтора, надстроенные над базовым интерфейсом класса Command.
Наша последняя задача связана с анализом текста, точнее, с проверкой правописания и нахождением мест, где можно поставить перенос для улучшения форматирования.
Ограничения здесь аналогичны тем, о которых уже говорилось при обсуждении форматирования в разделе 2.3. Как и в случае с разбиением на строки, есть много способов реализовать поиск орфографических ошибок и вычисление точек переноса. Поэтому и здесь планировалась поддержка нескольких алгоритмов. Пользователь сможет выбрать тот алгоритм, который его больше устраивает по соотношению потребляемой памяти, скорости и качеству. Добавление новых алгоритмов тоже должно реализовываться просто.
Также мы хотим избежать жесткой привязки этой информации к структуре документа. В данном случае такая цель даже более важна, чем при форматировании, поскольку проверка правописания и расстановка переносов – это лишь два вида анализа текста, которые Lexi мог бы поддерживать. Со временем мы собираемся расширить аналитические возможности Lexi. Мы могли бы добавить поиск, подсчет слов, средства вычислений для суммирования значений в таблице, проверку грамматики и т.д. Но мы не хотим изменять класс Glyph и все его подклассы при каждом добавлении такого рода функциональности.
У этой задачи есть две стороны: доступ к анализируемой информации, которая разбросана по разным глифам в структуре документа и собственно выполнение анализа. Рассмотрим их по отдельности.
Для многих видов анализа необходимо рассматривать текст посимвольно. Но анализируемый текст рассеян по иерархии структур, состоящих из объектов-глифов. Чтобы исследовать текст, представленный в таком виде, нужен механизм доступа, знающий о структурах данных, в которых хранится текст. Для некоторых глифов потомки могут храниться в связанных списках, для других – в массивах, а для третьих и вовсе используются какие-то экзотические структуры. Наш механизм доступа должен справляться со всем этим.
К сожалению, для разных видов анализа методы доступа к информации могут различаться. Обычно текст сканируется от начала к концу. Но иногда требуется сделать прямо противоположное. Например, для реверсивного поиска нужно проходить по тексту в обратном, а не в прямом направлении. А при вычислении алгебраических выражений необходим внутренний порядок обхода.
Итак, наш механизм доступа должен уметь приспосабливаться к разным структурам данных и поддерживать разные способы обхода.
Пока что в нашем интерфейсе глифов для доступа к потомкам со стороны клиентов используется целочисленный индекс. Хотя для тех классов глифов, которые содержат потомков в массиве, это, может быть, и разумно, но совершенно неэффективно для глифов, пользующихся связанным списком. Роль абстракции глифов в том, чтобы скрыть структуру данных, в которой содержатся потомки. Тогда мы сможем изменить данную структуру, не затрагивая другие классы.
Поэтому только глифу разрешено знать, какую структуру он использует. Отсюда следует, что интерфейс глифов не должен отдавать предпочтение какой-то одной структуре данных. Например, не следует оптимизировать его в пользу массивов, а не связанных списков, как это делалось до сих пор.
Мы можем решить проблему и одновременно поддержать несколько разных способов обхода. Разумно включить возможности множественного доступа и обхода прямо в классы глифов и предоставить способ выбирать между ними, возможно, передавая константу, являющуюся элементом некоторого перечисления. Выполняя обход, классы передают этот параметр друг другу, чтобы гарантировать, что все они обходят структуру в одном и том же порядке. Так же должна передаваться любая информация, собранная во время обхода.
Для поддержки данного подхода мы могли бы добавить в интерфейс класса Glyph следующие абстрактные операции:
void First(Traversal kind)
void Next()
bool IsDone()
Glyph* GetCurrent()
void Insert(Glyph*)
Операции First, Next и IsDone управляют обходом. First производит инициализацию. В качестве параметра передается вид обхода в виде перечисляемой константы типа Traversal, которая может принимать такие значения, как CHILDREN (обходить только прямых потомков глифа), PREORDER (обходить всю структуру в прямом порядке), POSTORDER (в обратном порядке) или INORDER (во внутреннем порядке). Next переходит к следующему глифу в порядке обхода, а IsDone сообщает, закончился ли обход. GetCurrent заменяет операцию Child – осуществляет доступ к текущему в данном обходе глифу. Старая операция Insert переписывается, теперь она вставляет глиф в текущую позицию.
При анализе можно было бы использовать следующий код на C++ для обхода структуры глифов с корнем в g в прямом порядке:
Glyph* g;
for (g->First(PREORDER); !g->IsDone(); g->Next()) {
Glyph* current = g->GetCurrent();
// выполнить анализ
}
Обратите внимание, что мы исключили целочисленный индекс из интерфейса глифов. Не осталось ничего, что предполагало бы какой-то предпочтительный контейнер. Мы также уберегли клиенты от необходимости самостоятельно реализовывать типичные виды доступа.
Но этот подход еще не идеален. Во-первых, здесь не поддерживаются новые виды обхода, не расширяется множество значений перечисления и не добавляются новые операции. Предположим, что нам нужен вариант прямого обхода, при котором автоматически пропускаются нетекстовые глифы. Тогда пришлось бы изменить перечисление Traversal, включив в него значение TEXTUAL_PREORDER.
Но нежелательно менять уже имеющиеся объявления. Помещение всего механизма обхода в иерархию класса Glyph затрудняет модификацию и расширение без изменения многих других классов. Механизм также трудно использовать повторно для обхода других видов структур. И еще нельзя иметь более одного активного обхода над данной структурой.
Как уже не раз бывало, наилучшее решение – инкапсулировать изменяющуюся сущность в класс. В данном случае это механизмы доступа и обхода. Допустимо ввести класс объектов, называемых итераторами, единственное назначение которых – определить разные наборы таких механизмов. Можно также воспользоваться наследованием для унификации доступа к разным структурам данных и поддержки новых видов обхода. Тогда не придется изменять интерфейсы глифов или трогать реализации существующих глифов.
Мы применим абстрактный класс Iterator для определения общего интерфейса доступа и обхода. Конкретные подклассы вроде ArrayIterator и ListIterator реализуют данный интерфейс для предоставления доступа к массивам и спискам, а такие подклассы, как PreorderIterator, PostorderIterator и им подобные, реализуют разные виды обходов структур. Каждый подкласс класса Iterator содержит ссылку на структуру, которую он обходит. Экземпляры подкласса инициализируются этой ссылкой при создании. На рис. 2.13 показан класс Iterator и несколько его подклассов. Обратите внимание, что мы добавили в интерфейс класса Glyph абстрактную операцию CreateIterator для поддержки итераторов.
Интерфейс итератора предоставляет операции First, Next и IsDone для управления обходом. В классе ListIterator реализация операции First указывает на первый элемент списка, а Next перемещает итератор на следующий элемент. Операция IsDone возвращает признак, говорящий о том, перешел ли указатель за последний элемент списка. Операция CurrentItem разыменовывает итератор для возврата глифа, на который он указывает. Класс ArrayIterator делает то же самое с массивами глифов.
Теперь мы можем получить доступ к потомкам в структуре глифа, не зная ее представления:
Glyph* g;
Iterator<Glyph*>* i = g->CreateIterator();
for (i->First(); !i->IsDone(); i->Next()) {
Glyph* child = i->CurrentItem();
// выполнить действие с потомком
}
CreateIterator по умолчанию возвращает экземпляр NullIterator. NullIterator – это вырожденный итератор для глифов, у которых нет потомков, то есть листовых глифов. Операция IsDone для NullIterator всегда возвращает истину.
Подкласс глифа, имеющего потомков, замещает операцию CreateIterator так, что она возвращает экземпляр другого подкласса класса Iterator. Какого именно – зависит от структуры, в которой содержатся потомки. Если подкласс Row класса Glyph размещает потомков в списке, то его операция CreateIterator будет выглядеть примерно так:
Iterator<Glyph*>* Row::CreateIterator () {
return new ListIterator<Glyph*>(_children);
}
Для обхода в прямом и внутреннем порядке итераторы реализуют алгоритм обхода в терминах, определенных для конкретных глифов. В обоих случаях итератору передается корневой глиф той структуры, которую нужно обойти. Итераторы вызывают CreateIterator для каждого глифа в этой структуре и сохраняют возвращенные итераторы в стеке.
Например, класс PreorderIterator получает итератор от корневого глифа, инициализирует его так, чтобы он указывал на свой первый элемент, а затем помещает в стек:
void PreorderIterator::First () {
Iterator<Glyph*>* i = _root->CreateIterator();
if (i) {
i->First();
_iterators.RemoveAll();
_iterators.Push(i);
}
}
CurrentItem должна будет просто вызвать операцию CurrentItem для итератора на вершине стека:
Glyph* PreorderIterator::CurrentItem () const {
return
_iterators.Size() > 0 ?
_iterators.Top()->CurrentItem() : 0;
}
Операция Next обращается к итератору с вершины стека с тем, чтобы элемент, на который он указывает, создал свой итератор, спускаясь тем самым по структуре глифов как можно ниже (это ведь прямой порядок, не так ли?). Next устанавливает новый итератор так, чтобы он указывал на первый элемент в порядке обхода, и помещает его в стек. Затем Next проверяет последний встретившийся итератор; если его операция IsDone возвращает true, то обход текущего поддерева (или листа) закончен. В таком случае Next снимает итератор с вершины стека и повторяет всю последовательность действий, пока не найдет следующее не полностью обойденное дерево, если таковое существует. Если же необойденных деревьев больше нет, то мы закончили обход:
void PreorderIterator::Next () {
Iterator<Glyph*>* i =
_iterators.Top()->CurrentItem()->CreateIterator();
i->First();
_iterators.Push(i);
while (
_iterators.Size() > 0 && _iterators.Top()->IsDone()
) {
delete _iterators.Pop();
_iterators.Top()->Next();
}
}
Обратите внимание, что класс Iterator позволяет вводить новые виды обходов, не изменяя классы глифов, – мы просто порождаем новый подкласс и добавляем новый обход так, как проделали это для PreorderIterator. Подклассы класса Glyph пользуются тем же самым интерфейсом, чтобы предоставить клиентам доступ к своим потомкам, не раскрывая внутренней структуры данных, в которой они хранятся. Поскольку итераторы сохраняют собственную копию состояния обхода, то одновременно можно иметь несколько активных итераторов для одной и той же структуры. И, хотя в нашем примере мы занимались обходом структур глифов, ничто не мешает параметризовать класс типа PreorderIterator типом объекта структуры. В C++ мы воспользовались бы для этого шаблонами. Тогда описанный механизм итераторов можно было бы применить для обхода других структур.
Паттерн итератор абстрагирует описанную технику поддержки обхода структур, состоящих из объектов, и доступа к их элементам. Он применим не только к составным структурам, но и к группам, абстрагирует алгоритм обхода и экранирует клиентов от деталей внутренней структуры объектов, которые они обходят. Паттерн итератор – это еще один пример того, как инкапсуляция изменяющейся сущности помогает достичь гибкости и повторной используемости. Но все равно проблема итерации оказывается глубокой, поэтому паттерн итератор гораздо сложней, чем было рассмотрено выше.
Итак, теперь, когда у нас есть способ обойти структуру глифов, нужно заняться проверкой правописания и расстановкой переносов. Для обоих видов анализа необходимо аккумулировать собранную во время обхода информацию.
Прежде всего следует решить, на какую часть программы возложить ответственность за выполнение анализа. Можно было бы поручить это классам Iterator, тем самым сделав анализ неотъемлемой частью обхода. Но решение стало бы более гибким и пригодным для повторного использования, если бы обход был отделен от действий, которые при этом выполняются. Дело в том, что для одного и того же вида обхода могут выполняться разные виды анализа. Поэтому один и тот же набор итераторов можно было бы использовать для разных аналитических операций. Например, прямой порядок обхода применяется в разных случаях, включая проверку правописания, расстановку переносов, поиск в прямом направлении и подсчет слов.
Итак, анализ и обход следует разделить. Кому еще можно поручить анализ? Мы знаем, что разновидностей анализа достаточно много, и в каждом случае в те или иные моменты обхода будут выполняться различные действия. В зависимости от вида анализа некоторые глифы могут оказаться более важными, чем другие. При проверке правописания и расстановке переносов мы хотели бы рассматривать только символьные глифы и пропускать графические – линии, растровые изображения и т.д. Если мы занимаемся разделением цветов, то желательно было бы принимать во внимание только видимые, но никак не невидимые глифы. Таким образом, разные глифы должны просматриваться разными видами анализа.
Поэтому данный вид анализа должен уметь различать глифы по их типу. Очевидное решение – встроить аналитические возможности в сами классы глифов. Тогда для каждого вида анализа мы можно было добавить одну или несколько абстрактных операций в класс Glyph и реализовать их в подклассах в соответствии с той ролью, которую они играют при анализе.
Однако неприятная особенность такого подхода состоит в том, что придется изменять каждый класс глифов при добавлении нового вида анализа. В некоторых случаях проблему удается сгладить: если в анализе участвует немного классов или если большинство из них выполняют анализ одним и тем же способом, то можно поместить подразумеваемую реализацию абстрактной операции прямо в класс Glyph. Такая операция по умолчанию будет обрабатывать наиболее распространенный случай. Тогда мы смогли бы ограничиться только изменениями класса Glyph и тех его подклассов, которые отклоняются от нормы.
Но, несмотря на то что реализация по умолчанию сокращает объем изменений, принципиальная проблема остается: интерфейс класса Glyph необходимо расширять при добавлении каждого нового вида анализа. Со временем такие операции затемнят смысл этого интерфейса. Будет трудно понять, что основная цель глифа – определить и структурировать объекты, имеющие внешнее представление и форму; в интерфейсе появится много лишних деталей.
Инкапсуляция анализа
Судя по всему, стоит инкапсулировать анализ в отдельный объект, как мы уже много раз делали прежде. Можно было бы поместить механизм конкретного вида анализа в его собственный класс, а экземпляр этого класса использовать совместно с подходящим итератором. Тогда итератор «переносил» бы этот экземпляр от одного глифа к другому, а объект выполнял бы свой анализ для каждого элемента. По мере продвижения обхода анализатор накапливал бы определенную информацию (в данном случае – символы).
Принципиальный вопрос при таком подходе – как объект-анализатор различает виды глифов, не прибегая к проверке или приведениям типов? Мы не хотим, чтобы класс SpellingChecker включал такой псевдокод:
void SpellingChecker::Check (Glyph* glyph) {
Character* c;
Row* r;
Image* i;
if (c = dynamic_cast<Character*>(glyph)) {
// анализировать символ
} else if (r = dynamic_cast<Row*>(glyph)) {
// анализировать потомки r
} else if (i = dynamic_cast<Image*>(glyph)) {
// ничего не делать
}
}
Такой код опирается на специфические возможности безопасных по отношению к типам приведений. Его трудно расширять. Нужно не забыть изменить тело данной функции после любого изменения иерархии класса Glyph. В общем это как раз такой код, необходимость в котором хотелось бы устранить.
Как уйти от данного грубого подхода? Посмотрим, что произойдет, если мы добавим в класс Glyph такую абстрактную операцию:
void CheckMe(SpellingChecker&)
Определим операцию CheckMe в каждом подклассе класса Glyph следующим образом:
void GlyphSubclass::CheckMe (SpellingChecker& checker) {
checker.CheckGlyphSubclass(this);
}
где GlyphSubclass заменяется именем подкласса глифа. Заметим, что при вызове CheckMe конкретный подкласс класса Glyph известен, ведь мы же выполняем одну из его операций. В свою очередь, в интерфейсе класса SpellingChecker есть операция типа CheckGlyphSubclass для каждого подкласса класса Glyph1:
class SpellingChecker {
public:
SpellingChecker();
virtual void CheckCharacter(Character*);
virtual void CheckRow(Row*);
virtual void CheckImage(Image*);
// ... и так далее
List<char*>& GetMisspellings();
protected:
virtual bool IsMisspelled(const char*);
private:
char _currentWord[MAX_WORD_SIZE];
List<char*> _misspellings;
};
Операция проверки в классе SpellingChecker для глифов типа Character могла бы выглядеть так:
void SpellingChecker::CheckCharacter (Character* c) {
const char ch = c->GetCharCode();
if (isalpha(ch)) {
// добавить букву к _currentWord
} else {
// встретилась не-буква
if (IsMisspelled(_currentWord)) {
// добавить _currentWord в _misspellings
_misspellings.Append(strdup(_currentWord));
}
_currentWord[0] = ‘\0’;
// переустановить _currentWord для проверки
// следующего слова
}
}
Обратите внимание, что мы определили специальную операцию GetCharCode только для класса Character. Объект проверки правописания может работать со специфическими для подклассов операциями, не прибегая к проверке или приведению типов, а это позволяет нам трактовать некоторые объекты специальным образом.
Объект класса CheckCharacter накапливает буквы в буфере _currentWord. Когда встречается не-буква, например символ подчеркивания, этот объект вызывает операцию IsMisspelled для проверки орфографии слова, находящегося в _currentWord.1 Если слово написано неправильно, то CheckCharacter добавляет его в список слов с ошибками. Затем буфер _currentWord очищается для приема следующего слова. По завершении обхода можно добраться до списка слов с ошибками с помощью операции GetMisspellings.
Теперь логично обойти всю структуру глифов, вызывая CheckMe для каждого глифа и передавая ей объект проверки правописания в качестве аргумента. Тем самым текущий глиф для SpellingChecker идентифицируется и может продолжать проверку:
SpellingChecker spellingChecker;
Composition* c;
// ...
Glyph* g;
PreorderIterator i(c);
for (i.First(); !i.IsDone(); i.Next()) {
g = i.CurrentItem();
g->CheckMe(spellingChecker);
}
На следующей диаграмме показано, как взаимодействуют глифы типа Character и объект SpellingChecker.
Этот подход работает при поиске орфографических ошибок, но как он может помочь в поддержке нескольких видов анализа? Похоже, что придется добавлять операцию вроде CheckMe(SpellingChecker&) в класс Glyph и его подклассы всякий раз, как вводится новый вид анализа. Так оно и есть, если мы настаиваем на независимом классе для каждого вида анализа. Но почему бы не придать всем видам анализа одинаковый интерфейс? Это позволит нам использовать их полиморфно. И тогда мы сможем заменить специфические для конкретного вида анализа операции вроде CheckMe(SpellingChecker&)одной инвариантной операцией, принимающей более общий параметр.
Мы будем использовать термин «посетитель» для обозначения класса объектов, «посещающих» другие объекты во время обхода, дабы сделать то, что необходимо в данном контексте.1 Тогда мы можем определить класс Visitor, описывающий абстрактный интерфейс для посещения глифов в структуре:
class Visitor {
public:
virtual void VisitCharacter(Character*) { }
virtual void VisitRow(Row*) { }
virtual void VisitImage(Image*) { }
// ... и так далее
};
Конкретные подклассы Visitor выполняют разные виды анализа. Например, можно было определить подкласс SpellingCheckingVisitor для проверки правописания и подкласс HyphenationVisitor для расстановки переносов. При этом SpellingCheckingVisitor был бы реализован точно так же, как мы реализовали класс SpellingChecker выше, только имена операций отражали бы более общий интерфейс класса Visitor. Так, операция CheckCharacter называлась бы VisitCharacter.
Поскольку имя CheckMe не подходит для посетителей, которые ничего не проверяют, мы использовали бы имя Accept. Аргумент этой операции тоже пришлось бы изменить на Visitor&, чтобы отразить тот факт, что может приниматься любой посетитель. Теперь для добавления нового вида анализа нужно лишь определить новый подкласс класса Visitor, а трогать классы глифов вовсе не обязательно. Мы поддержали все возможные в будущем виды анализа, добавив лишь одну операцию в класс Glyph и его подклассы.
О выполнении проверки правописания говорилось выше. Такой же подход будет применен для аккумулирования текста в подклассе HyphenationVisitor. Но после того как операция VisitCharacter из подкласса HyphenationVisitor закончила распознавание целого слова, она ведет себя по-другому. Вместо проверки орфографии применяется алгоритм расстановки переносов, чтобы определить, в каких местах можно перенести слово на другую строку (если это вообще возможно). Затем для каждой из найденных точек в структуру вставляется разделяющий (discretionary) глиф. Разделяющие глифы являются экземплярами подкласса Glyph – класса Discretionary.
Разделяющий глиф может выглядеть по-разному в зависимости от того, является он последним символом в строке или нет. Если это последний символ, глиф выглядит как дефис, в противном случае не отображается вообще. Разделяющий глиф запрашивает у своего родителя (объекта Row), является ли он последним потомком, и делает это всякий раз, когда от него требуют отобразить себя или вычислить свои размеры. Стратегия форматирования трактует разделяющие глифы точно так же, как пропуски, считая их «кандидатами» на завершающий символ строки. На диаграмме ниже показано, как может выглядеть встроенный разделитель.
Вышеописанная процедура – пример применения паттерна посетитель. Его главными участниками являются класс Visitor и его подклассы. Паттерн посетитель абстрагирует метод, позволяющий иметь заранее неопределенное число видов анализа структур глифов без изменения самих классов глифов. Еще одна полезная особенность посетителей состоит в том, что их можно применять не только к таким агрегатам, как наши структуры глифов, но и к любым структурам, состоящим из объектов. Сюда входят множества, списки и даже направленные ациклические графы. Более того, классы, которые обходит посетитель, необязательно должны быть связаны друг с другом через общий родительский класс. А это значит, что посетители могут пересекать границы иерархий классов.
Важный вопрос, который надо задать себе перед применением паттерна посетитель, звучит так: «Какие иерархии классов наиболее часто будут изменяться?» Этот паттерн особенно удобен, если необходимо выполнять действия над объектами, принадлежащими классу со стабильной структурой. Добавление нового вида посетителя не требует изменять структуру класса, что особенно важно, когда класс большой. Но при каждом добавлении нового подкласса вы будете вынуждены обновить все интерфейсы посетителя с целью включить операцию Visit... для этого подкласса. В нашем примере это означает, что добавление подкласса Foo класса Glyph потребует изменить класс Visitor и все его подклассы, чтобы добавить операцию VisitFoo. Однако при наших проектных условиях гораздо более вероятно добавление к Lexi нового вида анализа, а не нового вида глифов. Поэтому для наших целей паттерн посетитель вполне подходит.
При проектировании Lexi мы применили восемь различных паттернов:
• компоновщик для представления физической структуры документа;
• стратегия для возможности использования различных алгоритмов форматирования;
• декоратор для оформления пользовательского интерфейса;
• абстрактная фабрика для поддержки нескольких стандартов внешнего облика;
• мост для поддержки нескольких оконных систем;
• команда для реализации отмены и повтора операций пользователя;
• итератор для обхода структур объектов;
• посетитель для поддержки неизвестного заранее числа видов анализа без усложнения реализации структуры документа.
Ни одно из этих проектных решений не ограничено документо-ориентированными редакторами вроде Lexi. На самом деле в большинстве нетривиальных приложений есть возможность воспользоваться многими из этих паттернов, быть может, для разных целей. В приложении для финансового анализа паттерн компоновщик можно было бы применить для определения инвестиционных портфелей, разбитых на субпортфели и счета разных видов. Компилятор мог бы использовать паттерн стратегия, чтобы поддержать реализацию разных схем распределения машинных регистров для целевых компьютеров с различной архитектурой. Приложения с графическим интерфейсом пользователя вполне могли бы применить паттерны декоратор и команда точно так же, как это сделали мы.
Хотя мы и рассмотрели несколько крупных проблем проектирования Lexi, но осталось гораздо больше таких, которых мы не касались. Но ведь и в книге описаны не только рассмотренные восемь паттернов. Поэтому, изучая остальные паттерны, подумайте о том, как вы могли бы применить их к Lexi. А еще лучше подумайте об их использовании в своих собственных проектах!