Архитектура программы
Следуя традиционным рекомендациям декомпозиции сверху вниз, выберем "вершину" - главную функцию нашей системы. Это должна быть, очевидно, программа execute_session, описывающая выполнение полной интерактивной сессии.
Рис. 2.3. Функциональная декомпозиция сверху-вниз
Непосредственно ниже (уровень 2) найдем операции, связанные с состояниями: определение начального и конечного состояний, структуру переходов и функцию execute_state, описывающую действия, выполняемые в каждом состоянии. На нижнем уровне 1 найдем операции, определяющие execute_state: отображение панели на экране и другие. Заметьте, что и это решение, также как и ОО-решение, описываемое чуть позже, отражает "реальный мир", в данном случае включающий состояния и элементарные операции данного мира. В этом примере и во многих других не в реальности мира состоит важная разница между ОО-подходом и другими решениями, а в том, как мы моделируем этот мир.
При написании программы execute_session попытаемся сделать наше приложение максимально независимым. (Наша нотация выбрана в соответствии с примером. Цикл repeat until заимствован из Pascal.)
execute_session is -- Выполняет полную сессию интерактивной системы local state, next: INTEGER do state := initial repeat execute_state (state, >next) -- Процедура execute_state обновляет значение next state := transition (state, next) until is_final (state) end endЭто типичный алгоритм обхода диаграммы переходов. (Те, кто писал лексический анализатор, узнают образец.) На каждом этапе мы находимся в состоянии state, вначале устанавливаемом в initial; процесс завершается, когда состояние удовлетворяет is_final. Для состояний, не являющихся заключительными, вызывается execute_state, принимающее текущее состояние и возвращающее в аргументе next выбор перехода, сделанный пользователем. Функция transition определяет следующее состояние.
Техника, используемая в процедуре execute_state, изменяющая значение одного из своих аргументов, никогда не подходит для хорошего ОО-проекта, но здесь она вполне приемлема.
Для того чтобы сделать ее явной, используется "флажок" для "out" аргумента - next со стрелкой.
Для завершения проекта следует определить процедуру execute_state, описывающую действия, выполняемые в каждом состоянии. Ее тело реализует содержимое блока начальной goto-версии.
execute_state (in s: INTEGER; out c: INTEGER) is -- Выполнить действия, связанные с состоянием s, -- возвращая в c выбор состояния, сделанный пользователем local a: ANSWER; ok: BOOLEAN do repeat display (s) read (s, a) ok := correct (s, a) if not ok then message (s, a) end until ok end process (s, a) c := next_choice (a) endЗдесь вызываются программы уровня 1 со следующими ролями:
display (s) выводит на экран панель, связанную с состоянием s;read (s, a) читает в a ответы пользователя, введенные в окнах панели состояния s;correct (s, a) возвращает true, если и только если a является приемлемым ответом; если да, то process (s, a) обрабатывает ответ a, например, обновляя базу данных или отображая некоторую информацию, если нет, то message (s, a) выводит соответствующее сообщение об ошибке.Тип ANSWER объекта, представляющего ответ пользователя, не будет уточняться. Значение a этого типа глобально представляет ввод пользователя, включающий и выбор следующего шага (ANSWER фактически во многом подобен классу, даже если остальная структура не является объектной.)
Для получения работающего приложения необходимо задать реализации программ уровня 1: display, read, correct, message и process.
Функция переходов
Первым шагом на пути улучшения нашего решения будет придание центральной роли алгоритму обхода в структуре ПО. Диаграмма переходов - это всего лишь одно из свойств нашей системы, и нет никаких оснований, чтобы она правила над всеми частями системы. Ее отделение от остального алгоритма позволит, по крайней мере, избавиться от goto. Можно ожидать и большей общности, поскольку диаграмма переходов специфична для приложения, такого как резервирование авиабилетов, в то время как алгоритм обхода графа более универсален.
Что представляет собой диаграмма переходов? Абстрактно это функция transition, имеющая два аргумента - состояние и выбор пользователя. Функция transition (s, c) возвращает новое состояние, определяемое пользовательским выбором c в состоянии s. Здесь слово "функция" используется в его математическом смысле. На программном уровне можно выбрать реализацию transition либо функцией в программистском смысле, либо структурой данных, например массивом. На данный момент решение можно отложить и рассматривать transition просто как абстрактное понятие.
В добавление к функции transition необходимо спроектировать начальное состояние initial, - точку, в которой начинаются все сессии, и одно или несколько заключительных состояний как булеву функцию is_final. И снова речь идет о функции в математическом смысле, независимо от ее возможной реализации.
Зададим функцию transition в табличной форме со строками, представляющими состояние, и столбцами, отображающими пользовательский выбор:
1 (Initial) | -1 | 0 | 5 | 2 |
2 (Flights) | -1 | 0 | 1 | 3 |
3 (Seats) | 0 | 2 | 4 | |
4 (Reserv.) | 0 | 3 | 5 | |
5 (Confirm) | 0 | 4 | 1 | |
0 (Help) | Return | |||
-1 (Final) |
Соглашения, используемые в таблице: здесь в состоянии Help с идентификатором 0 задан специальный переход Return, возвращающий в состояние, запросившее справку, задано также ровно одно финальное состояние -1. Эти соглашения не являются необходимыми для дальнейшего обсуждения, но позволяют проще сделать таблицу. |
Функциональное решение: проектирование сверху вниз
Повторяя на этом частном примере эволюцию, пройденную программированием в целом, перейдем от низкоуровневого подхода goto к иерархической структуре, создаваемой при проектировании сверху вниз. Это решение принадлежит общему стилю, известному как "структурное", хотя этот термин следует использовать с осторожностью.
ОО-решение является, конечно же, структурным, соответствуя духу "структурного программирования", как оно изначально было введено Э. Дейкстрой.
Класс приложения
Для завершения рассмотрения класса APPLICATION рассмотрим несколько возможных реализационных решений:
Будем нумеровать состояния приложения числами от 1 до n. Заметьте, эти числа не являются абсолютными свойствами состояний, они связаны с определенным приложением, поэтому в классе STATE нет атрибута "номер состояния". Вместо этого, одномерный массив associated_state, атрибут класса APPLICATION, задает состояние, связанное с заданным номером.Представим функцию переходов transition еще одним атрибутом - двумерным массивом размерности n * m, где m - число возможных пользовательских выборов при выходе из состояния.Номер начального состояния хранится в атрибуте initial и устанавливается в подпрограмме choose_initial. Для конечных состояний мы используем соглашение, что переход в псевдосостояние 0 означает завершение сессии.Процедура создания в классе APPLICATION использует процедуры создания библиотечных классов ARRAY и ARRAY2. Последний описывает двумерные массивы и построен по образцу ARRAY; его процедура создания make принимает четыре аргумента, например create a.make (1, 25, 1, 10), а его подпрограммы item и put используют два индекса - a.put (x, 1, 2). Границы двумерного массива a можно узнать, вызвав a.lower1 и так далее.Вот определение класса, использующего эти решения:
indexing description: "Интерактивные приложения, управляемые панелями" class APPLICATION creation make feature -- Initialization make (n, m: INTEGER) is -- Создает приложение с n состояниями и m возможными выборами do create transition.make (1, n, 1, m) create associated_state.make (1, n) end feature -- Access initial: INTEGER -- Номер начального состояния feature -- Basic operations execute is -- Выполняет сессию пользователя local st: STATE; st_number: INTEGER do from st_number := initial invariant 0<= st_number; st_number <= n until st_number = 0 loop st := associated_state.item (st_number) st.execute -- Вызов процедуры execute класса STATE. -- (Комментарии к этой ключевой инструкции даны в тексте.) st_number := transition.item (st_number, st.choice) end end feature -- Element change put_state (st: STATE; sn: INTEGER) is -- Ввод состояния st с индексом sn require 1 <= sn; sn <= associated_state.upper do associated_state.put (st, sn) end choose_initial (sn: INTEGER) is -- Определить состояние с номером sn в качестве начального require 1 <= sn; sn <= associated_state.upper do initial := sn end put_transition (source, target, label: INTEGER) is -- Ввести переход, помеченный label, -- из состояния с номером source в состояние target require 1 <= source; source <= associated_state.upper 0 <= target; target <= associated_state.upper 1 <= label; label <= transition.upper2 do transition.put (source, label, target) end feature {NONE} -- Implementation transition: ARRAY2 [STATE] associated_state: ARRAY [STATE] ...Другие компоненты ... invariant transition.upper1 = associated_state.upper end -- class APPLICATIONОбратите внимание на простоту и элегантность вызова st.execute. Компонент execute класса STATE является эффективным (полностью реализованным) поскольку описывает известное общее поведение состояний, но его реализация основана на вызове компонентов: read, message, correct, display, process, отложенных на уровне STATE, эффективизация которых выполняется потомками класса, такими как RESERVATION. Когда мы помещаем вызов st.execute в процедуру execute класса APPLICATION, у нас нет информации о том, какой вид состояния обозначает st, но благодаря статической типизации мы точно знаем, что это состояние. Далее включается механизм динамического связывания и в период исполнения st становится связанной с объектом конкретного вида, например RESERVATION, - тогда вызовы read, message и других царственных особ автоматически будут переключаться на нужную версию.
Значение st, полученное из associated_state, представляет полиморфную структуру данных (polymorphic data structure), содержащую объекты разных типов, все из которых согласованы (являются потомками) со STATE. Текущий индекс st_number определяет операции состояния.
Рис. 2.9. Полиморфный массив состояний
Вот как строится интерактивное приложение. Приложение должно быть представлено сущностью, скажем air_reservation, класса APPLICATION. Необходимо создать соответствующий объект:
create air_reservation.make (number_of_states, number_of_possible_choices)Далее независимо следует определить и создать состояния приложения, как сущности классов-потомков STATE, либо новые, либо уже готовые и взятые из библиотеки повторного использования. Каждое состояние s связывается с номером i в приложении:
air_reservation.put_state (s, i).Затем одно из состояний выбирается в качестве начального:
air_reservation.choose_initial (i0)Для установления перехода от состояния sn к состоянию с номером tn, с меткой l используйте вызов:
air_reservation.enter_transition (sn, tn, l)Это включает и заключительные состояния, для которых по умолчанию tn равно 0. Затем можно запустить приложение:
air_reservation.execute_session.При эволюциях системы можно в любой момент использовать те же подпрограммы для добавления состояний и переходов.
Конечно же, можно расширить класс APPLICATION, изменяя сам класс или добавляя новых потомков, включив новые функциональные возможности - удаление, моделирование или любые другие.
Критика решения
Получили ли мы удовлетворительное решение? Не совсем. Оно лучше, чем первая версия, но все еще далеко от заявленных целей повторного использования и расширяемости.
Многопанельные системы
Наша задача - спроектировать систему, представляющую некоторый общий тип интерактивных систем. В этих системах работа пользователя состоит в выполнении ряда этапов, и каждый этап поддерживается полноэкранной диалоговой панелью.
В целом процесс является простым и хорошо определенным. Сессия работы пользователя проходит через некоторое число состояний. В каждом состоянии отображается некоторая панель, содержащая вопросы к пользователю. Пользователь дает требуемые ответы, проверяемые на согласованность (вопросы повторяются, пока не будет дан корректный ответ); затем ответ обрабатывается некоторым образом, например обновляется база данных. Частью пользовательского ответа является выбор следующего шага, интерпретируемый как переход к следующему состоянию, в котором процесс повторяется.
Примером может служить система резервирования авиабилетов, где состояния представляют такие шаги обработки, как User Identification (Идентификация Пользователя), Enquiry on Flights (Запрос Рейса в нужное место и требуемую дату), Enquiry on Seats (Запрос Места на выбранный рейс), Reservation (Резервирование).
Типичная панель для состояния Enquiry on Flights (Запрос Рейса) может выглядеть как на рис. 2.1 (рисунок иллюстрирует только идею и не претендует на реализм или хороший эргономичный дизайн). Экран показан на шаге, завершающем состояние, ответы пользователя в соответствующих окнах показаны курсивом, реакция системы на эти ответы (показ доступных рейсов) дана жирным шрифтом.
Рис. 2.1. Панель "Запрос Рейса"
Сессия начинается в начальном состоянии Initial и заканчивается в заключительном состоянии Final. Мы можем представить всю структуру графом переходов, показывающим возможные состояния и переходы между ними. Ребра графа помечены целыми, соответствующими возможному выбору пользователя следующего шага при завершении состояния. На рис. 2.2 показан граф переходов системы резервирования авиабилетов.
Проблема, возникающая при проектировании и реализации таких приложений, состоит в достижении максимально возможной общности и гибкости.
В частности:
G1 Граф может быть большим. Довольно часто можно видеть приложения, включающие сотни состояний с большим числом переходов.G2 Структура системы, как правило, изменяется. Проектировщики не могут предвидеть все возможные состояния и переходы. Когда пользователи начинают работать с системой, они приходят с запросами на изменение системы и расширение ее возможностей.G3 В данной схеме нет ничего специфического для конкретного приложения. Система резервирования авиабилетов является лишь примером. Если вашей компании необходимо несколько таких систем для собственных целей или в интересах различных клиентов, то большим преимуществом было бы определить общий проект или, еще лучше, множество модулей, допускающих повторное использование в разных приложениях.
Рис. 2.2. Граф переходов в системе резервирования авиабилетов
Наследование и отложенные классы
Класс STATE описывает не частное состояние, а общее понятие состояния. Процедура execute - одна и та же для всех состояний, но другие подпрограммы зависят от состояния.
Наследование и отложенные классы идеально позволяют справиться с этими ситуациями. На уровне описания класса STATE мы знаем атрибуты и процедуру execute во всех деталях. Мы знаем также о существовании программ уровня 1 (display и др.) и их спецификации, но не их реализации. Эти программы должны быть отложенными, класс STATE, описывающий множество вариантов, а не полностью уточненную абстракцию, сам является отложенным классом. В результате имеем:
indexing description: "Состояния приложений, управляемых панелями" deferred class STATE feature -- Access choice: INTEGER -- Пользовательский выбор следующего шага input: ANSWER -- Пользовательские ответы на вопросы в данном состоянии feature -- Status report correct: BOOLEAN is -- Является ли input корректным ответом? deferred end feature -- Basic operations display is -- Отображает панель, связанную с текущим состоянием deferred end execute is -- Выполняет действия, связанные с текущим состоянием, -- и устанавливает choice - пользовательский выбор local ok: BOOLEAN do from ok := False until ok loop display; read; ok := correct if not ok then message end end process ensure ok end message is -- Вывод сообщения об ошибке, соответствующей input require not correct deferred end read is -- Получить ответы пользователя input и choice deferred end process is -- Обработка input require correct deferred end endДля описания специфических состояний следует ввести потомков класса STATE, задающих отложенную реализацию компонент.
Рис. 2.7. Иерархия классов State
Пример мог бы выглядеть следующим образом:
class ENQUIRY_ON_FLIGHTS inherit STATE feature display is do ...Специфическая процедура вывода на экран... end ...И аналогично для read, correct, message и process ... endЭта архитектура отделяет зерно от шелухи: элементы, общие для всех состояний, отделяются от элементов, специфичных для конкретного состояния. Общие элементы, такие как процедура execute, сосредоточены в классе STATE и нет необходимости в их повторном объявлении в потомках, таких ENQUIRY_ON_FLIGHTS. Принцип Открыт-Закрыт выполняется: класс STATE закрыт, поскольку он является хорошо определенным, компилируемым модулем, но он также открыт, так как можно добавлять в любое время любых его потомков.
Класс STATE является типичным представителем поведенческих классов (behavior classes, см. лекцию 14 курса "Основы объектно-ориентированного программирования") - отложенных классов, задающих общее поведение большого числа возможных объектов, реализующих то, что полностью известно на общем уровне (execute) в терминах, зависящих от каждого варианта. Наследование и отложенный механизм задают основу представления такого поведения повторно используемых компонентов.
Объектно-ориентированная архитектура
Проблемы функциональной декомпозиции сверху вниз указывают, что нужно делать, чтобы получить хорошую ОО-версию.
Обсуждение
Этот пример, надеюсь, показал впечатляющую картину той разницы, которая существует между ОО-конструированием ПО и ранними подходами. В частности, он показал преимущества, получаемые при устранении понятия главной программы. Сосредоточившись на понятии абстракции данных, забывая столь долго, пока это еще возможно, о том, что является главной функцией системы, мы получаем структуру, более подготовленную к будущим изменениям и повторному использованию в разнообразных вариантах.
Этот стабилизирующий эффект является одним из характеристических свойств Метода. Он предполагает некоторую дисциплину применения, поскольку такой способ проектирования постоянно наталкивается на сопротивление и естественное желание спросить, "А что же делает система?". Это один из тех навыков, по которому можно отличить ОО-профессионала от людей, которые не проникли в суть Метода, хотя и могут использовать ОО-язык программирования и объектную технику, но за объектным фасадом их систем по-прежнему стоит функциональная архитектура.
Как показано в этой лекции, идентифицировать ключевые абстракции часто удается, анализируя передачу данных и обращая внимание на те понятия, что чаще других используются в коммуникациях между компонентами системы. Часто это является прямым указанием на обращение ситуации - присоединение программ к абстрактным данным.
Заключительный урок этой лекции состоит в том, что следует быть осторожным и не придавать слишком большого значения тому факту, что ОО-системы выведены непосредственно путем моделирования "реального мира". Моделирующая мощь метода и в самом деле впечатляющая, и вполне приятно создавать программную архитектуру, чьи принципиальные компоненты непосредственно отражают абстракции внешней моделируемой системы. Но построить модель реального мира можно разными способами, не все они приводят к хорошей программной системе. Наша первая, goto версия была также близка к реальному миру, как и две другие, - фактически даже ближе, поскольку ее структура была построена по образцу диаграммы переходов системы, в то время как другие версии вводили промежуточные понятия. Но результат с точки зрения программной инженерии является катастрофическим.
Созданная в конечном итоге ОО-декомпозиция хороша из-за использования абстракций: STATE, APPLICATION, ANSWER - все они являются ясными, общими, управляемыми, готовыми к изменениям и повторному использованию в широкой области применения. Вы понимаете, что эти абстракции столь же реальны, как и все остальное, но новичку они могут казаться менее естественными, чем концепции, используемые в ранее изучаемых решениях.
При создании хорошего ПО следует учитывать не его близость к реальному миру, а то, насколько выбранные абстракции хороши как для моделирования внешней системы, так и для построения структуры ПО. Фактически в этом суть ОО-анализа, проектирования и реализации - работа, которую для успеха проекта необходимо выполнять хорошо как сегодня, так и завтра. Профессионала от любителя отличает умение находить правильные абстракции.
Описание полной системы
Для завершения проекта следует заняться управлением сессией. При функциональной декомпозиции эту задачу выполняла процедура execute_session - главная программа. Но мы знаем, как сделать это лучшим образом. Как ранее говорилось (см. лекцию 5 курса "Основы объектно-ориентированного программирования") главная функция системы, позиционируемая как верхняя функция в проектировании сверху вниз, - это нечто мифическое. Большие программные системы выполняют множество одинаково важных функций. И здесь основанный на АТД подход является предпочтительным. Вся система в целом рассматривается как множество абстрактных объектов, способных выполнять ряд служб (services).
Ранее мы рассмотрели одну ключевую абстракцию - STATE. Какая же абстракция в нашем рассмотрении осталась пропущенной? Ответ очевиден: центральным в нашей системе является понятие APPLICATION, описывающее специфическую интерактивную систему, подобную системе резервирования билетов. Это приводит нас к следующему классу.
Рис. 2.8. Компоненты классов State и Application
Заметьте, все не вошедшие в класс STATE программы функциональной декомпозиции стали теперь компонентами класса APPLICATION:
Execute_session - описывает, как выполнять сессию, ее имя теперь разумно упростить и называть просто execute, так как дальнейшую квалификацию обеспечивает имя класса.Initial и is_final - указывают, какие состояния имеют специальный статус в приложении. Конечно же, их разумно включить именно в класс APPLICATION, а не в класс STATE, поскольку они описывают свойства приложения, а не состояния, которое является заключительным или начальным только по отношению к приложению, а не само по себе. При повторном использовании состояние, бывшее заключительным в одном приложении вполне может не быть таковым для другого приложения.Transition - описывает переходы между состояниями приложения.Все компоненты функциональной декомпозиции нашли свое место в ОО-декомпозиции: одни в классе STATE, другие в APPLICATION. Это не должно нас удивлять.
Объектная технология, о чем многократно говорится в этой книге, является прежде всего архитектурным механизмом, в первую очередь предназначенным для организации программных элементов в согласованные структуры. Сами элементы, возможно, нижнего уровня, те же самые или похожие на элементы необъектных решений. Объектные механизмы: абстракция данных, скрытие информации, утверждения, наследование, полиморфизм, динамическое связывание позволяют сделать задачу проектирования более простой, общей и мощной.
Системе управления панелями, изучаемой в данной лекции, всегда необходимы: процедура обхода графа приложения (execute_session, теперь просто execute), чтение ввода пользователя (read), обнаружение заключительного состояния (is_final). Погружаясь в структуру, можно найти одни и те же элементы, независимо от выбранного подхода к проектированию. Что же меняется? - способ группирования элементов, создающий модульную архитектуру.
Конечно, нет необходимости ограничивать себя элементами, пришедшими из предыдущих решений. То, что для функционального решения было завершением процесса - построение функции execute и всего необходимого для ее работы - теперь становится только началом. Существует много других вещей, которые хотелось бы выполнять для подобных приложений:
добавить новое состояние;добавить новый переход;построить приложение (Многократно повторяя комбинацию предшествующих двух операций);удалить состояние, переход;сохранить законченное приложение, его состояние и переходы в базе данных;промоделировать работу приложения (например, с заглушками для программ, проверяя работу только переходов);мониторинг использования приложения.Все эти операции и другие будут в равной степени являться компонентами класса APPLICATION. Здесь нет более или менее важных программ, чем наша бывшая "главная программа", - процедура execute, ставшая теперь обычным компонентом класса, равная среди других, но не первая. Устраняя понятие вершины, мы подготавливаем систему к эволюции и повторному использованию.
Первая напрашивающаяся попытка
Давайте начнем с прямолинейной, без всяких ухищрений программной схемы. В этой версии наша система будет состоять из нескольких блоков по одному на каждое состояние системы: BEnquiry, BReservation, BCancellation и т. д. Типичный блок (выраженный не в ОО-нотации этой книги, а в специально подобранной для этого случая, хотя и удовлетворяющей некоторым синтаксическим соглашениям), выглядит следующим образом:
BEnquiry: "Отобразить панель Enquiry on flights" repeat "Чтение ответов пользователя и выбор C следующего шага" if "Ошибка в ответе" then "Вывести соответствующее сообщение" end until not "ошибки в ответе" end "Обработка ответа" case C in C0: goto Exit, C1: goto BHelp, C2: goto BReservation, ... endАналогичный вид имеют блоки для каждого состояния.
Что можно сказать об этой структуре? Ее нетрудно спроектировать, и она делает свое дело. Но с позиций программной инженерии она оставляет желать много лучшего.
Наиболее очевидная критика связана с присутствием инструкций goto (реализующих условные переходы подобно переключателю switch языка C или "Вычисляемый Goto" Fortran), из-за чего управляющая структура выглядит подобно "блюду спагетти" и чревата ошибками.
Но goto - это симптом заболевания, а не настоящая причина. Мы взяли поверхностную структуру нашей проблемы - текущую форму диаграммы переходов - и перенесли ее в алгоритм. Ветвящаяся структура программы является точным отражением графа переходов. Из-за этого наш проект становится уязвимым к любым простым и общим изменениям, о чем уже говорилось выше. Когда кто-то попросит нас добавить состояние и изменить граф переходов, нам придется менять центральную управляющую структуру системы. Нам придется забыть, конечно же, о надеждах повторного использования приложений - цели G3 из нашего списка, требующей, чтобы структура покрывала все возможные приложения подобного вида.
Это пример является отрезвляющим напоминанием, когда приходится слышать о преимуществах "моделирования реального мира" или "вывода системы из анализа реальности". В зависимости от того, как вы его описываете, реальный мир может быть простым или выглядеть непонятной кашей. Плохая модель приводит к плохому ПО. Следует рассматривать не то, насколько близко ПО к реальному миру, а насколько хорошо его описание. В конце этой лекции этому вопросу еще будет уделено внимание. |
Чтобы получить не просто систему, а хорошую систему, придется еще поработать.
Состояние как класс
Пример "состояния" является типичным. Такой тип данных, играющий всеобъемлющую роль в передаче данных между программами, является первым кандидатом на роль модуля в ОО-архитектуре, основанной на классах (абстрактно описанных типах данных).
Понятие состояния было важным в оригинальной постановке проблемы, но затем в функциональной архитектуре эта важность была утеряна, - состояние было представлено обычной переменной, передаваемой из программы в программу, как если бы это было существо низкого ранга. Мы уже видели, как оно отомстило за себя. Теперь мы готовы предоставить ему заслуженный статус. STATE должно быть классом, одним из властителей структуры в нашей новой ОО-системе.
В этом классе мы найдем все операции, характеризующие состояние: отображение соответствующего экрана (display), анализ ответа пользователя (read), проверку ответа (correct), выработку сообщения об ошибке для некорректных ответов (message), обработку корректных ответов (process). Мы должны также включить сюда execute_state, выражающее последовательность действий, выполняемых всякий раз, когда сессия достигает заданного состояния (поскольку данное имя было бы сверхквалифицированным в классе, называемом STATE, заменим его именем execute).
Возвращаясь к рисунку, отражающему функциональную декомпозицию, выделим в нем множество программ, принадлежащих классу STATE.
Рис. 2.6. Компоненты класса STATEКласс имеет следующую форму: ...class STATE feature input: ANSWER choice: INTEGER execute is do ... end display is ... read is ... correct: BOOLEAN is ... message is ... process is ... end
Компоненты input и choice являются атрибутами, остальные - подпрограммами (процедурами и функциями). В сравнении со своими двойниками при функциональной декомпозиции подпрограммы потеряли явный аргумент, задающий состояние, хотя он появится другим путем в клиентских вызовах, таких как s.execute.
В предыдущих подходах функция execute (ранее execute_state) возвращала пользовательский выбор следующего шага. Но такой стиль нарушает правила хорошего проектирования. Предпочтительнее сделать execute командой. Запрос "какой выбор сделал пользователь в последнем состоянии?" доступен благодаря атрибуту choice. Аналогично, аргумент ANSWER подпрограмм уровня 1 заменен теперь закрытым атрибутом input. Вот причина скрытия информации: клиентскому коду нет необходимости обращаться к ответам помимо интерфейса, обеспечиваемого компонентами класса.
Статичность
Хотя с первого взгляда кажется, что нам удалось отделить общность, присущую приложениям такого типа, от специфических черт конкретного приложения, в реальности различные модули все еще тесно связаны друг с другом и с выбранным приложением. Главной проблемой остается структура данных, передаваемых в системе. Рассмотрим сигнатуры (типы аргументов и результатов) наших программ:
Рис. 2.4. Сигнатура программ
Замечание, звучащее подобно жалобе, состоит в том, что роль состояний всеобъемлюща. Текущее состояние s появляется как аргумент во всех программах, спускаясь с вершины execute_session, где оно известно как state. Так что кажущаяся простота и управляемость иерархической структуры, показанной на рис. 2.3, является ложью или, более точно, фасадом. За спиной формальной элегантности функциональной декомпозиции стоит неразбериха передачи данных. Истинная картина должна включать данные.
В основе технологии лежит битва между функциями и данными (объектами) за управление архитектурой системы. В необъектных подходах функции берут вверх над данными, но затем данные начинают мстить.
Месть проявляется в форме саботажа. Атакуя основания архитектуры, данные не пропускают изменения, - пока, подобно правительству не способному руководить своей перестройкой (в оригинале - perestroikа), система не рухнет под собственной тяжестью.
Рис. 2.5. Поток данных
В этом примере структура рушится из-за необходимости различать состояния. Все программы уровня 1 должны выполнять различные действия, зависящие от состояния s: отображать панель для некоторого состояния, читать и интерпретировать ответы пользователя, определять корректность ответов, - для всех этих задач необходимо знать состояние. Программы должны будут выполнять разбор случаев в форме:
inspect s... when Initial then ... when Enquiry_on_flights then ... .... endЭто приводит к длинной и сложной структуре и, что хуже всего, к неустойчивой системе, - любое добавление состояния потребует изменения всей структуры. Имеет место типичный случай необузданного распределения знаний. Слишком много модулей системы используют одну и ту же информацию - список всех возможных состояний, являющийся предметом изменений.
Если надеяться на получение общего повторно используемого решения, то ситуация еще хуже, чем может показаться. Дело в том, что во всех программах неявно присутствует еще один аргумент - приложение - система резервирования авиабилетов или другая проектируемая система. Так что программы, такие как display, если они действительно носят общий характер, должны знать все состояния всех возможных приложений! Аналогично функция transition должна содержать графы переходов для всех приложений - совершенно нереалистическое предположение.
Закон инверсии
Что пошло не так, в чем проблема? Слишком много передач данных свидетельствует обычно об изъянах в архитектуре ПО. Устранение этих изъянов приводит непосредственно к ОО-проекту, что находит отражение в следующем правиле проектирования:
Закон инверсии Если программы пересылают друг другу много данных, поместите программы в данные. |
Ранее модули строились вокруг операций (таких как execute_session и execute_state) и данные распределялись между программами со всеми неприятными последствиями, с которыми нам пришлось столкнуться. ОО-проектирование ставит все с головы на ноги, - оно использует наиболее важные типы данных как основу модульности, присоединяя каждую программу к тому типу данных, с которым она наиболее тесно связана. Когда объекты одерживают победу, их бывшие хозяева - функции - становятся вассалами данных.
Закон инверсии является ключом получения ОО-проекта из классической функциональной (процедурной) декомпозиции, как это делается в данной лекции. Подобная необходимость возникает в случаях реверс-инженерии, когда существующая необъектная система преобразуется в объектную для обеспечения ее дальнейшей эволюции и сопровождения. К этому приему прибегают в командах, возможно, не столь хорошо знакомых с ОО-проектированием и предпочитающих начинать с привычной функциональной декомпозиции.
Конечно, желательно начинать ОО-проектирование с самого начала, - тогда не придется прибегать к инверсии. Но закон инверсии полезен не только в случае реверс-инженерии или для новичков-разработчиков. И в объектной разработке проекта на фоне объектного ландшафта могут встречать области функциональной декомпозиции. Анализ передачи данных является хорошим способом обнаружения и корректировки подобных дефектов. Если вы обнаружили в структуре, разрабатываемой как объектной, образцы передачи данных, подобные состояниям в данном примере, то это должно привлечь ваше внимание и привести в большинстве случаев к осознанию абстракции данных, не нашедшей еще должного места в проекте.
Аргументы команды
Некоторым командам нужны аргументы. Например, команде LINE_INSERTION нужен текст вставляемой строки.
Простым решением является добавление атрибута и процедуры в класс COMMAND:
argument: ANY set_argument (a: like argument) is do argument := a endТогда любой потомок - командный класс - сможет переопределить argument, задав для него подходящий тип. Чтобы справиться с множественными аргументами, достаточно выбрать массив или списочный тип. Такова была техника, принятая выше, при передаче аргументов процедуре создания объектов командных классов.
Эта техника подходит для всех простых приложений. Заметьте, библиотечный класс COMMAND в среде ISE использует другую технику, немного более сложную, но более гибкую: здесь нет атрибута argument, но процедура execute имеет аргумент в обычном для процедур смысле:
execute (command_argument: ANY) is ...Причина в том, что в графических системах удобнее позволять различным экземплярам одного и того же командного типа разделять один и тот же аргумент. Удаляя атрибут, мы получаем возможность повторно использовать тот же командный объект во многих различных контекстах, избегая создания нового командного объекта всякий раз, когда пользователь запрашивает команду.
Небольшое усложнение связано с тем, что теперь элементы списка истории уже не являются экземплярами COMMAND - они теперь должны быть экземплярами класса COMMAND_INSTANCE с атрибутами:
command_type: COMMAND argument: ANYДля серьезных систем стоит пойти на усложнение ради выигрыша в памяти и времени. В этом варианте создается один объект на каждый тип команды, а не на каждую выполняемую команду. Эта техника рекомендуется для производственных систем. Необходимо лишь изменить некоторые детали в рассмотренном ранее классе (см. У3.4).
Аспекты реализации
Давайте займемся деталями, что позволит получить лучшую из возможных реализаций.
Действия системы
Ни одна из рассмотренных частей структуры не зависела до сих пор от специфики приложения. Фактические операции приложения, основанные на структурах специфических объектов, например, структурах, представляющих текст в текстовом редакторе, - находятся где-то в другом месте. Как же осуществляется соединение?
Ответ основан на процедурах execute и undo классов command, которые должны вызывать компоненты специфические для приложения. Например, процедура execute класса LINE_DELETION должна иметь доступ к классам, специфическим для текстового редактора, чтобы вызывать компоненты, вырабатывающие текст конкретной строки, задающие ее позицию в тексте.
Результатом является четкое разделение части системы, обеспечивающей взаимодействие с пользователем, и той ее части, которая зависит от специфики приложения. Вторая часть близка к концептуальной модели приложения - обработке текстов, CAD-CAM или чему-нибудь еще. Первая часть, особенно с учетом механизма истории действий пользователя, будет, как поясняется, широко использоваться в самых разных областях приложения.
Интерфейс пользователя для откатов и повторов
Покажем, как выглядит возможный интерфейс пользователя, поддерживающий механизм undo-redo. Пример взят из ISE, но и некоторые другие наши продукты используют ту же схему.
Хотя горячие клавиши доступны для Undo и Redo, полный механизм включает показ окна истории (history window). В нем отображается список history. Однажды открытое, оно регулярно обновляется при выполнении команд. В отсутствие откатов оно выглядит так:
Рис. 3.7. Окно истории до выполнения откатов
Оно отображает список выполненных команд. При выполнении новой команды, она появится в конце списка. Текущая активная команда (отмеченная курсором) подсвечена, как показано на рисунке для "change relation label".
Для отката достаточно щелкнуть по кнопке со стрелкой
или использовать горячие клавиши (Alt-U). Если передвинуть курсор вверх (для списка это переход назад - back) то после нескольких операций Undo, окно примет вид, показанный на рис. 3.8.В этом состоянии есть выбор:
Можно выполнить еще раз операцию Undo - подсветка передвинется к предыдущей строке.Можно выполнить один или несколько раз операцию повтора Redo, используя эквивалентную комбинацию горячих клавиш или щелкнув по кнопке со стрелкой вниз . Подсветка в окне передвинется к следующей строке, а список выполнит вызов forth.Рис. 3.8. Окно истории в процессе откатов и повторов
Можно выполнить нормальную команду. Как мы знаем, из истории удалятся все команды, для которых был откат, но не было повтора; для списка это означает удаление элементов справа от курсора и вызов remove_all_right; все команды ниже подсвеченной исчезнут.
Как создается объект command
После декодирования запроса система должна создать соответствующий объект command. Инструкцию, абстрактно появившуюся как "Создать подходящий объект command и присоединить его к requested", можно теперь выразить более точно, используя инструкцию создания:
if "Запрос является LINE INSERTION" then create {LINE_INSERTION} requested.make (input_text, cursor_index) elseif "Запрос является LINE DELETION" then create {LINE_DELETION} requested.make (current_line, line_index) elseif ...Используемая здесь форма инструкции создания create {SOME_TYPE} x создает объект типа SOME_TYPE и присоединяет его к x. Тип SOME_TYPE должен соответствовать типу объявления x. Это имеет место в данном случае, так как requested имеет тип COMMAND и все классы команд являются потомками COMMAND.
Если каждый тип команды использует unique, то слегка упрощенная форма предыдущей записи может использовать inspect:
inspect request_code when Line_insertion then create {LINE_INSERTION} requested.make (input_text, cursor_position) и т.д.Обе формы являются ветвящимся множественным выбором, но они не нарушают принцип Единственного Выбора. Как отмечалось при его обсуждении, если система предусматривает выбор, то некоторая часть системы должна знать полный список альтернатив. Оба рассмотренных варианта задают точку единственного выбора. Принцип запрещает лишь распространение этого знания на большое число модулей. В данном случае нет никакой другой части системы, которой нужен был бы доступ к списку команд; каждый командный класс имеет дело лишь с одной командой.
Фактически можно получить более элегантное решение и полностью избавиться от разбора случаев. Мы увидим его в конце презентации.
Класс Command
Для нашей проблемы характерна фундаментальная абстракция данных COMMAND, представляющая любую операцию, отличающуюся от Undo и Redo. Выполнение операции это лишь один из многих компонентов, применимых к команде, - команду можно сохранить, тестировать или отменить. Так что нам понадобится класс и вот его первоначальная форма:
deferred class COMMAND feature execute is deferred end undo is deferred end endКласс COMMAND описывает абстрактное понятие команды и потому должен оставаться отложенным. Фактические типы команды будут представлены эффективными потомками этого класса, такими как:
class LINE_DELETION inherit COMMAND feature deleted_line_index: INTEGER deleted_line: STRING set_deleted_line_index (n: INTEGER) is -- Устанавливает n номер следующей удаляемой строки do deleted_line_index := n end execute is -- Удаляет строку do "Удалить строку с номером deleted_line_index" "Записать текст удаляемой строки в deleted_line" end undo is -- Восстанавливает последнюю удаляемую строку do "Поместить deleted_line в позицию deleted_line_index" end endАналогичный класс строится для каждой команды класса.
Что же представляют собой такие классы? Экземпляр LINE_DELETION, как будет показано ниже, является небольшим объектом, несущим всю необходимую информацию, связанную с выполнением команды: строку, подлежащую удалению, (deleted_line) и ее индекс в тексте (deleted_line_index). Эта информация необходима для выполнения команды undo, если она потребуется, или для повтора redo.
Рис. 3.1. Объект command
Атрибуты, такие как deleted_line и deleted_line_index, у каждой команды будут свои, но всегда они должны быть достаточными для поддержки локальных операций execute и undo. Объекты, концептуально описывающие разницу между двумя состояниями приложения: предшествующим и последующим за выполнением команды, дают возможность удовлетворить требование U3 из нашего списка - хранить только то, что строго необходимо.
Структура наследования классов выглядит следующим образом:
Рис. 3.2. Иерархия классов COMMAND
Граф показан плоским ( все потомки COMMAND находятся на одном уровне), но ничто не мешает добавить некую структуру, группируя команды по типам, где каждая категория может иметь общие специфические черты.
При определении понятия важно указать, какие характеристики оно не покрывает. Здесь концепция команды не включает Undo и Redo; например, не имеет смысла выполнять откат самого Undo (если только не иметь в виду выполнение Redo). По этой причине в обсуждении используется термин операция (operation) для Undo и Redo и слово команда (command) для операций, допускающих откат и повтор, подобных вставке строки. Нет необходимости в классе, покрывающем понятие операции, так как такие операции, как Undo, имеют только одно связанное с ними свойство - быть выполненными.
Это хороший пример ограничений упрощенного подхода к "поиску объектов", подобному известному методу "Подчеркивание существительных", идея, изучаемая в последней лекции. В спецификациях проблемы существительные command и operation одинаково важны; но одно приводит к фундаментальному классу, второе - вообще не дает класса. Только изучение абстракций в терминах применимых операций и свойств может помочь в поиске классов проектируемой ОО системы. |
Многоуровневый откат и повтор: undo и redo
В некоторых системах откат ограничен одним уровнем. Если не делать двух ошибок подряд, то этого достаточно. Но если вы пошли не по той дороге и хотите вернуться назад, то нужен многоуровневый откат.
Нет никаких причин ограничиваться одним уровнем. Как только механизм отката разработан, распространение его на несколько уровней не представляет особого труда, что и будет показано в этой лекции. И, пожалуйста, говорю уже как потенциальный пользователь, не ограничивайте число уровней отката, а если уж вынуждены это сделать, то пусть ограничение задает сам пользователь, во всяком случае по умолчанию оно должно быть не меньше 20. Затраты на откат невелики, если применять описываемую здесь технику.
Многоуровневый откат может быть избыточным. Потому необходима независимая операция повтора Redo, в которой нет нужды, если откат ограничен одним шагом.
Многоуровневый откат и повтор: UNDO-REDO
Поддержка отката произвольной глубины и сопровождающего его повтора представляет прямое расширение рассмотренной нами схемы.
Небольшие классы
Проект, описанный в этой лекции, может для типичных интерактивных систем включать достаточно много относительно небольших классов, по одному на каждую команду. Нет причин, однако, полагать, что это отражается на размере системы или ее сложности. Структура наследования классов остается простой, хотя она вовсе не должна быть плоской, - команды можно группировать по категориям.
При систематическом ОО-подходе такие вопросы возникают всякий раз, когда приходится вводить классы, представляющие действия. Хотя некоторые ОО-языки дают возможность передавать программы как аргументы в другие программы, такое свойство противоречит базисной идее Метода - функции (программы) не существуют сами по себе, - они лишь часть некоторой абстракции данных. Поэтому вместо передачи операций следует передавать объект, поставляемый вместе с операцией, например, экземпляр COMMAND, поставляемый с операцией execute.
Иногда для операций приходится писать обертывающий класс, что кажется искусственным, особенно для людей, привыкших передавать процедуры в качестве аргументов. Но мне неоднократно приходилось видеть, что класс, введенный с единственной целью инкапсуляции операции, превращался позже в полноценную абстракцию данных с добавлением операций, о которых не шла речь в первоначальном замысле. Класс COMMAND не попадает в эту категорию, он с самого начала рассматривался как абстракция данных и имел два компонента (execute and undo). Но, что типично, серьезная работа с командами приводит к осознанию необходимости других компонентов, таких как:
argument: ANY - для представления аргументов команды (как это было сделано в одной из версий проекта);help: STRING - для предоставления справки по каждой команде;компоненты, поддерживающие протоколирование и статистику вызова команд.Еще один пример взят из области численных вычислений. Рассмотрим классическую задачу вычисления интеграла. Как правило, подынтегральная функция f передается как аргумент в программу, вычисляющую интеграл. Традиционная техника представляет f как функцию, при ОО-проектировании мы обнаруживаем, что "Интегрируемая функция" является важной абстракцией со многими возможными свойствами. Для пришедших из мира C, Fortran и нисходящего проектирования необходимость написания класса в такой ситуации кажется простым программистским трюком. Возможно, первое время он неохотно будет принимать эту технику, смиряясь с ней. Продолжая проект, он скоро осознает, что интегрируемая функция - INTEGRABLE_FUNCTION - на самом деле является одной из главных абстракций проблемной области. В этом классе появятся новые полезные компоненты помимо компонента item (a: REAL): REAL, возвращающего значение функции в точке a.
То, что казалось лишь трюком, превращается в главную составляющую проекта.
Основной интерактивный шаг
Вначале посмотрим, как выглядит поддержка отката одного уровня. Обобщение на произвольное число уровней будет сделано позже.
В любой интерактивной системе в модуле, ответственном за коммуникацию с пользователем, должен быть некоторый фрагмент следующего вида:
basic_interactive_step is -- Декодирование и выполнение одного запроса пользователя do "Определить, что пользователь хочет выполнить" "Выполнить это (если возможно)" endВ традиционных структурированных системах, подобных редактору, эти операции будут частью цикла - базисного цикла программы:
from start until quit_has_been_requested_and_confirmed loop basic_interactive_step endгде более сложные системы могут использовать событийно-управляемую схему, в которой цикл является внешним по отношению к системе и управляется системной графической оболочкой. Но во всех случаях существует нечто подобное процедуре basic_interactive_step.
С учетом наших абстракций тело процедуры можно уточнить следующим образом:
"Получить последний запрос пользователя" "Декодировать запрос" if "Запрос является нормальной командой (не Undo)" then "Определить соответствующую команду в системе" "Выполнить команду" elseif "Запрос это Undo" then if "Есть обратимая команда" then "Undo последней команды" elseif "Есть команда для повтора" then "Redo последней команды" end else "Отчет об ошибочном запросе" endЗдесь реализуется соглашение, что Undo примененное сразу после Undo, означает Redo. Запрос Undo или Redo игнорируется, если нет возможности отката или повтора. В простом текстовом редакторе с клавиатурным интерфейсом, процедура "Декодировать запрос" будет анализировать ввод пользователя, отыскивая такие коды, как control-I (для вставки строки, control-D для удаления) и другие. В графическом интерфейсе будет проверяться выбор команды меню, нажатие кнопки или соответствующих клавиш.
Откаты для пользы и для забавы
В интерактивных системах эквивалентом Большой Зеленой Кнопки является операция отката Undo, дающая пользователю системы возможность отменить действие последней выполненной команды.
Исходная цель механизма отката - справиться с потенциально опасными ошибками ввода (напечатан не тот символ, нажата не та кнопка). Откат позволяет достичь большего. Помимо освобождения от нервного напряжения и боязни сделать что-то не то, он поощряет использовать стратегию "Что-Если", - стиль взаимодействия, при котором пользователи сознательно испытывают различные варианты ввода, анализируя полученные результаты, зная при этом, что всегда есть возможность вернуться к предыдущему состоянию.
Каждая хорошая интерактивная система должна обеспечивать подобный механизм. (По этой причине на клавиатуре моего компьютера есть кнопка Undo, хотя она вовсе не зеленая и не особенно большая. Жаль только, что не все разработчики ПО предусматривают ее использование.)
Поиск абстракций
Ключом ОО-решения является поиск правильных абстракций. Здесь фундаментальное понятие буквально напрашивается.
Практические проблемы
Хотя при разумных усилиях механизм undo-redo может быть встроен в любую хорошо написанную ОО-систему, лучше всего с самого начала планировать его использование. На архитектуре ПО это скажется введением класса command, что может и не придти в голову, если не думать об откате при проектировании системы.
Практичный механизм undo-redo требует учета нескольких требований. Это свойство следует включить в интерфейс пользователя. Для начала можно полагать, что множество доступных операций обогащено двумя новыми командами: Undo и Redo. (Для них может быть введена соответствующая комбинация горячих клавиш, например control-U и control-R.) Команда Undo отменяет эффект последней еще не отмененной команды; Redo повторно выполняет команду, отмененную при откате. Следует определить соглашения для попыток отката на большее число шагов, чем их было сделано первоначально, при попытках повтора, когда не было отката, - такие запросы можно игнорировать или выдавать предупреждающее сообщение. (Таков возможный взгляд на интерфейс пользователя, поддерживающий undo-redo. В конце лекции мы увидим, что возможен лучший вариант интерфейса.)
Второе, что следует учитывать, действия не всех команд могут быть отменены. В некоторых ситуациях этого нельзя сделать фактически: после выполнения команды "запуск ракет" (которую может отдать, как известно, лишь президент) или менее драматичной "отпечатать страницу" действия этих команд необратимы. В других ситуациях эффект от действия команды может быть устранен, но ценой слишком больших усилий. Например, текстовые редакторы, как правило, не позволяют отменить действия команды Save, записывающей текущее состояние документа в файл. Реализация отката должна учитывать наличие таких необратимых команд, четко указывая их статус в интерфейсе пользователя. Ограничивайте необратимые команды случаями, для которых оправдание свойства может быть сформулировано в терминах пользователя.
Контрпримером является часто используемое мной приложение, обрабатывающее документы, которое изредка сообщает, что запрашиваемую команду нельзя отменить, хотя причины этого ясны только самой программе. Интересно, что в каком-то смысле это утверждение ложно, - фактически вы можете отменить эффект команды, но не через Undo, а через команду "Вернуться к последней сохраненной версии документа". Это наблюдение приводит к следующему правилу: всякий раз, когда команду законно следует признать необратимой, не поступайте, как в приведенном выше примере, выводя сообщение "Эта команда будет необратимой". Вместо выбора двух возможностей - Continue anyway и Cancel - предоставьте пользователю три: сохранить документ и затем выполнить команду, выполнить без сохранения, отмена команды. |
p>Наконец, можно попытаться предложить общую схему "Undo, Skip, Redo", позволяющую после нескольких операций Undo пропустить некоторые команды перед включением Redo. Интерфейс пользователя, показанный в конце этой лекции, поддерживает такое расширение, но возникают концептуальные проблемы: после пропуска некоторых команд может оказаться невозможным выполнить следующую команду. Рассмотрим тривиальный пример текстового редактора и сессию некоторого пользователя с набранной одной строкой текста. Предположим, пользователь выполнил две команды:
(1) Добавить строку в конец текста. (2) Удалить вторую строку.После отмены обеих команд пользователь захотел пропустить выполнение первой и повторно выполнить только вторую (skip (1) и redo (2)). К несчастью, в этом состоянии выполнение команды (2) бессмысленно, поскольку нет второй строки. Эта проблема не столько интерфейса, сколько реализации: команда "Удалить вторую строку" была применима к структуре объекта, полученного в результате выполнения команды (1), ее применение к структуре, предшествующей выполнению (1), может быть невозможным или приводить к непредсказуемым результатам.
Представление списка истории
Для списка истории был задан абстрактный тип SOME_LIST, обладающий компонентами: put, empty, before, is_first, is_last, back, forth, item и remove_all_right. (Есть также on_item, выраженный в терминах empty и before, и not_last, выраженный в терминах empty и is_last.)
Большинство из списочных классов базовой библиотеки можно использовать для реализации SOME_LIST; например, класс TWO_WAY_LIST или одного из потомков класса CIRCULAR_LIST. Для получения независимой версии рассмотрим специально подобранный класс BOUNDED_LIST. В отличие от ссылочной реализации списков, подобных TWO_WAY_LIST, этот класс основан на массиве, так что он хранит лишь ограниченное число команд в истории. Пусть remembered будет максимальным числом хранимых команд. Если используется в системе подобное свойство, то запомните (если не хотите получить гневное письмо от меня как от пользователя вашей системы): этот максимум должен задаваться пользователем либо во время сессии, либо в профиле пользователя. По умолчанию он должен выбираться никак не менее 20.
Список BOUNDED_LIST может использовать массив с циклическим управлением, позволяющий использовать ранее занятые элементы, когда число команд переваливает за максимум remembered. Эта техника является общей для представления ограниченных очередей. Массив в этом случае представляется в виде баранки:
Рис. 3.6. Ограниченный циклический список, реализуемый массивом
Размером capacity массива является remembered + 1; это соглашение означает фиксирование одной из позиций (последней с индексом capacity), оно необходимо для различения пустого и полностью заполненного списка. Занятые позиции помечены двумя целочисленными атрибутами: oldest - является позицией самой старой запомненной команды, и next - первая свободная позиция (для следующей команды). Атрибут index указывает текущую позицию курсора.
Вот как выглядит реализация компонентов. Для put(c), вставляющей команду c в конец списка, имеем:
representation.put (x, next); --где representation это имя массива next:= (next\\ remembered) + 1 index:= nextгде операция \\ представляет остаток от деления нацело. Значение empty истинно, если и только если next = oldest; значение is_first истинно, если и только если index = oldest; и before истинно, если и только если (index\\ remembered) + 1 = oldest. Телом forth является:
index:= (index\\ remembered) + 1 а телом back: index:= ((index + remembered - 2) \\ remembered) + 1
Терм +remembered математически избыточен, но он включен из-за отсутствия стандартного соглашения для операции взятия остатка в случае отрицательных операндов. |
Запрос item возвращает элемент в позиции курсора - representation @ index, - элемент массива с индексом index. Наконец, процедура remove_all_right, удаляющая все элементы справа от курсора, реализована так:
next:= (index remembered) + 1Предвычисленные командные объекты
Еще до выполнения команды следует получить, а иногда и создать соответствующий командный объект. Для абстрактно написанной инструкции "Создать подходящий командный объект и присоединить его к requested" была предложена схема реализации:
inspect request_code when Line_insertion then create {LINE_INSERTION} requested.make (...) и т.д. (одна ветвь для каждого типа команды)Как отмечалось, здесь нет нарушения принципа Единственного Выбора: фактически это и есть точка выбора - единственное место в системе, знающее, какое множество команд поддерживается. Но к этому времени у нас выработалось здоровое отвращение к инструкциям if или inspect, содержащим много ветвей. Давайте попытаемся избавиться от них, хотя их присутствие кажется на первый взгляд неизбежным.
Мы создадим широко применимый образец проектирования, который может быть назван множество предвычисленных полиморфных экземпляров (precomputing a polymorphic instance set).
Идея достаточно проста - создать раз и навсегда полиморфную структуру данных, содержащую по одному экземпляру каждого варианта, затем, когда нужен новый объект, просто получаем его из соответствующего входа в структуру.
Хотя для этого возможны различные структуры, например списки, мы будем использовать массив ARRAY [COMMAND], позволяющий идентифицировать каждый тип команды целым в интервале 1 и до command_count - числом типов команд. Объявим:
commands: ARRAY [COMMAND]и инициализируем его элементы так, чтобы i-й элемент (1 <= i <= n) ссылался на экземпляр класса потомка COMMAND, соответствующего коду i; например, создадим экземпляр LINE_DELETION, свяжем его с первым элементом массива, так что удаление строки будет иметь код 1.
Рис. 3.5. Массив шаблонов команд
Подобная техника может быть применена к полиморфному массиву associated_state, используемому в ОО-решении предыдущей лекции для приложения, управляемого панелями. |
Массив commands дает еще один пример мощи полиморфных структур данных. Его инициализация тривиальна:
create commands.make (1, command_count) create {LINE_INSERTION} requested.make; commands.put (requested, 1) create {STRING_REPLACE} requested.make; commands.put (requested, 2) ... И так для каждого типа команд ...Заметьте, при этом подходе процедуры создания не должны иметь аргументов; если командный класс имеет атрибуты, то следует устанавливать их значения позднее в специально написанных процедурах, например li.make (input_text, cursor_position), где li типа LINE_INSERTION.
Теперь исчезла необходимость применения разбора случаев и ветвящихся инструкций if или inspect. Приведенная выше инициализация служит теперь точкой Единственного Выбора. Теперь реализацию абстрактной операции "Создать подходящий командный объект и присоединить его к requested" можно записать так:
requested := clone (commands @ code)где code является кодом последней команды. Так как каждый тип команды имеет теперь код, соответствующий его индексу в массиве, то базисная операция интерфейса, ранее написанная в виде "Декодировать запрос", анализирует запрос пользователя и определяет соответствующий код.
В присваивании requested используется клон (clone) шаблона команды из массива, так что можно получать более одного экземпляра одной и той же команды в списке истории (как это показано в предыдущем примере, где в списке истории присутствовали два экземпляра LINE_DELETION).
Если, однако, использовать предложенную технику, полностью отделяющую аргументы команды от командных объектов (так что список истории содержит экземпляры COMMAND_INSTANCE, а не COMMAND), то тогда в получении клонов нет необходимости, и можно перейти к использованию ссылок на оригинальные объекты из массива:
requested:= commands @ codeВ длительных сессиях такая техника может давать существенный выигрыш.
Проделки дьявола
Человеку свойственно ошибаться - чтобы окончательно все запутать, дайте ему компьютер. Чем быстрее становятся наши интерактивные системы, тем проще выполнить совсем не желанные действия. Вот почему хотелось бы иметь способ стереть прошлое, но не "большой красной кнопкой", стирающей все, - одной из компьютерных шуток, а иметь Большую Зеленую Кнопку, нажатие которой избавляет нас от сделанных ошибок.
Реализация Redo
Реализация Redo аналогична:
if not_last then history.forth history.item.redo else message ("Нет команды для отката - undo") endПредполагается, что в классе COMMAND введена новая процедура redo. До сих пор считалось верным, что redo - это то же самое, что и execute. Это справедливо в большинстве случаев, но для некоторых команд повторное выполнение может отличаться от выполнения с нуля. Лучший способ справиться с такой ситуацией, не жертвуя общностью, - задать для redo поведение по умолчанию в классе COMMAND:
redo is -- Повтор команды, которую можно отменить, -- по умолчанию эквивалентно ее выполнению. do execute endНаличие реализации превращает класс COMMAND в класс, определяющий поведение (см. лекцию 4 курса "Основы объектно-ориентированного программирования"). Он имеет отложенные процедуры execute и undo и эффективную процедуру redo. Большинство из потомков сохранят поведение по умолчанию redo, но некоторые зададут поведение, соответствующее специфике команды.
Реализация Undo
Имея список истории, достаточно просто реализовать Undo:
if on_item then history.item.undo history.back else message ("Нет команды для отката - undo") endИ снова динамическое связывание играет основную роль. Список истории history является полиморфной структурой данных:
Рис. 3.4. Список истории с различными объектами command
При передвижении курсора влево каждое успешное значение history.item может быть присоединено к объекту любого доступного типа command. Динамическое связывание гарантирует, что в каждом случае history.item.undo автоматически выберет нужную версию undo.
Роль реализации
Замечательное свойство пользовательского интерфейса, представленного в последнем разделе, состоит в том, что оно непосредственно выведено из реализации, - взяв внутреннее, относящееся к разработке понятие списка истории, мы транслировали его во внешнее, относящееся к пользователю понятие окна истории с привлекательным пользовательским механизмом взаимодействия.
Можно представить, что кто-то мог бы вначале придумать внешнее представление независимо от реализации. Но так не получилось ни в этом изложении, ни при разработке наших программных продуктов. |
Существование такого отношения между функциональностью системы и ее реализацией противоречит всему тому, чему учит традиционная методология разработки ПО. Нам говорят: выводите реализацию из спецификации, но не наоборот! Методы "итеративной разработки" и "жизненного цикла" немногое изменяют в том привычном подходе, когда реализация является рабом первичных концепций, а разработчики ПО должны делать то, что говорят их "пользователи". Здесь мы нарушаем это табу и утверждаем, что реализация может сказать нам, что следует делать системе. В прежние времена посягательство на освященные временем принципы -вокруг чего вращается мир - могло привести на костер.
Наивно верить, что пользователи могут предложить правильные свойства интерфейса. Иногда они могут это сделать, но, чаще всего, они будут исходить из свойств, знакомых им по прежним системам. Это понятно, у них своя работа, своя область, в которой они являются экспертами, и нельзя на них возлагать ответственность за то, что должно быть правильным в программной системе. Некоторые из худших интерактивных систем были спроектированы, находясь под слишком большим влиянием пользователей. Где действительно необходим вклад пользователей - так это их критические комментарии: они могут видеть изъяны в идее, которая могла казаться привлекательной разработчикам. Такой критицизм всегда необходим. Пользователи могут высказывать и блестящие положительные предложения тоже, но не следует быть зависимыми от них. Несмотря на критику иногда разработчикам удается склонить пользователей на свою сторону, возможно, после нескольких итераций и учета замечаний. И это происходит даже тогда, когда предложения вытекают из, казалось бы, чисто реализационных аспектов, как это было со списком истории.
Равенство традиционных отношений представляет важный вклад в объектную технологию. Рассматривая процесс разработки бесшовным и обратимым (см. лекцию 10;), мы допускаем влияние идей реализации на спецификации. Вместо одностороннего движения от анализа к проектированию и кодированию, приходим к непрерывному циклическому процессу с обратной связью. Реализация не должна рассматриваться как похлебка, низкоуровневый компонент конструирования системы. Разработанная с использованием методов, описанных в данной книге, она может и должна быть четкой, элегантной и абстрактной, ничуть не уступающей всему тому, что можно получить в презирающих реализацию традиционных формах анализа и проектирования.
Сохранение последней команды
Располагая понятием объекта command, можно добавить специфику в выполняемые операции, введя атрибуты:
requested: COMMAND --Команда, запрашиваемая пользователемАтрибут задает последнюю команду, подлежащую выполнению, отмене или повтору. Это позволяет уточнить нашу схему следующим образом:
"Получить и декодировать последний запрос пользователя" if "Запрос является нормальной командой (не Undo)" then "Создать подходящий объект command и присоединить его к requested" -- requested создан как экземпляр некоторого потомка -- класса COMMAND, такого как LINE_DELETION. -- (Эта инструкция детализируется ниже.) else requested.execute; undoing_mode := False elseif "Запрос является Undo" and requested /= Void then if undoing_mode then "Это Redo; детали оставляем читателям" else requested.undo; undoing_mode := True end else "Ошибочный запрос: вывод предупреждения или игнорирование" end
Булева сущность undoing_mode определяет, была ли Undo последней операцией. В этом случае непосредственно следующий запрос Undo будет означать Redo, хотя непосредственные детали остаются за читателем, (упражнение У3.2); мы увидим полную реализацию Redo в более интересном случае многоуровневого механизма. |
Информация, сохраняемая перед каждым выполнением команды, задается в экземпляре некоторого потомка COMMAND, такого как LINE_DELETION. Это означает, что, как и анонсировалось, решение удовлетворяет свойству U3 в списке требований: хранится не все состояние, а только разница между новым состоянием и предыдущим.
Ключом решения - и его уточнением в оставшейся части лекции - является полиморфизм и динамическое связывание. Атрибут requested полиморфен: объявленный как COMMAND он присоединяется к объектам одного из эффективных потомков, таким как LINE_INSERTION. Вызовы requested.execute и requested.undo осмыслены из-за динамического связывания: подключаемый компонент должен быть версией, определенной в соответствующем классе, выполняя, например, откат LINE_INSERTION, LINE_DELETION или команду любого другого типа, определенного тем объектом, к которому присоединен requested во время вызова.
Список истории
Что не позволяло нам производить откат на большую глубину? Ответ очевиден - у нас был только один объект - последний созданный экземпляр COMMAND, доступный через requested.
Фактически мы создавали столь много объектов, сколько команд выполнял пользователь. Но поскольку в нашем проекте присутствует только одна ссылка на командный объект - requested, всегда присоединенная к последней команде, то каждый командный объект становится недостижимым, как только пользователь создает новую команду. Нам нет необходимости заботиться о судьбе этих старых объектов. Важной частью, обеспечивающей элегантность и простоту хорошего ОО окружения, является сборщик мусора (см. лекцию 9 курса "Основы объектно-ориентированного программирования"), в задачу которого входит освобождение памяти. Было бы ошибкой пытаться самим использовать память, так как все объекты имеют разную структуру и размеры. |
Для обеспечения глубины отката достаточно заменить единственный объект requested списком, содержащим выполненные команды, - списком истории:
history: SOME_LIST [COMMAND]Имя SOME_LIST не является именем настоящего класса, - в подлинном ОО стиле АТД мы исследуем, какие операции и свойства необходимы классу SOME_LIST, и позже вынесем заключение, какой же списочный класс из базовой библиотеки (Base library) следует использовать. Принципиальные операции, нужные нам непосредственно, хорошо известны из предыдущего обсуждения:
Рис. 3.3. Список историиPut - команда вставки элемента в конец списка (единственное необходимое нам место вставки). По соглашению, put позиционирует курсор списка на только что вставленном элементе.Empty - запрос определения пустоты списка.Before, is_first и is_last - запросы о позиции курсора.Back, forth - команды, передвигающие курсор назад, вперед на одну позицию.Item - запрос элемента в позиции, заданной курсором. Этот компонент имеет предусловие: (not empty) and (not before), которое можно выразить как запрос on_item.
В отсутствие откатов курсор всегда (за исключением пустого списка) будет указывать на последний элемент и is_last будет истинным.
Если же пользователь начнет выполнять откат, курсор начнет передвигаться назад по списку вплоть до before, если отменяются все выполненные команды. Когда же начинается повтор, то курсор перемещается вперед.
На рис. 3.3 курсор указывает на элемент, отличный от последнего. Это означает, что пользователь выполнял откат, возможно, перемежаемый повторами. Заметьте, число команд Undo всегда не меньше числа Redo (в состоянии на рисунке оно на два больше). Если в этом состоянии пользователь выберет обычную команду (ни Undo, ни Redo) соответствующий элемент будет вставлен непосредственно справа от курсора. Это означает, что остававшиеся справа в списке элементы будут потеряны, так для них не имеет смысла выполнение Redo. Здесь возникает та же ситуация, которая привела нас в начале лекции к введению понятия операции Skip (см. У3.4). Как следствие, в классе SOME_LIST понадобится еще один компонент - процедура remove_all_right, удаляющий все элементы справа от курсора.
Выполнение Undo возможно, если и только если курсор стоит на элементе с истинным значением on_item. Выполнение Redo возможно, если и только если был сделан откат, для которого еще не выполнена операция Redo, - это означает истинность выражения: (not empty) and (not is_last), которое будем называть запросом not_last.
Требования к решению
Механизм undo-redo, который мы намереваемся обеспечить, должен удовлетворять следующим свойствам:
U1 Механизм должен быть применим к широкому классу интерактивных приложений независимо от их проблемной области.U2 Механизм не должен требовать перепроектирования при добавлении новых команд.U3 Он должен разумно использовать ресурсы памяти.U4 Он должен быть применимым к откатам как на один, так и несколько уровней.Первое требование следует из того, что ничего проблемно специфического в откатах и повторах нет. Только для облегчения обсуждения мы будем использовать в качестве примера знакомый каждому инструмент - текстовый редактор, (подобный Notepad или Vi), позволяющий пользователям вводить тексты и выполнять такие команды, как: INSERT_LINE, DELETE_LINE, GLOBAL_REPLACEMENT (одного слова в тексте другим) и другие. Но это только пример, и ни одна из концепций, обсуждаемых ниже, не является характерной только для текстовых редакторов.
Второе требование означает, что Undo и Redo имеют особый статус и не могут рассматриваться подобно любым другим команд интерактивной системы. Будь Undo обычной командой, ее структура требовала бы разбора случаев в форме:
If "Последняя команда была INSERT_LINE" then "Undo эффект INSERT_LINE" elseif "Последняя команда была DELETE_LINE" then "Undo эффект DELETE_LINE" и т.д.Мы знаем (см. лекцию 3 курса "Основы объектно-ориентированного программирования"), как плохи такие структуры, противоречащие принципу Единственного Выбора и затрудняющие расширяемость системы. Пришлось бы изменять программный текст при всяком добавлении новой команды. Хуже того, код каждой ветви отражал бы код соответствующей команды, например, первая ветвь должна бы знать достаточно много о том, что делает команда INSERT_LINE. Это было бы свидетельством изъянов проекта.
Третье требование заставляет нас бережно относиться к памяти. Понятно, что механизм undo-redo требует хранения некоторой информации для каждой команды Undo: например, при выполнении DELETE_LINE, нет возможности выполнить откат, если перед выполнением команды не запомнить где-нибудь удаляемую строку и ее позицию в тексте. Но следует хранить только то, что логически необходимо.
Вследствие третьего требования исключается такое очевидное решение, как сохранение полного состояния системы перед выполнением каждой команды. Такое решение можно было бы тривиально написать, используя свойства STORABLE (см.лекцию 8 курса "Основы объектно-ориентированного программирования"), но оно было бы нереалистичным, так как просто пожирало бы память. Нужно придумать что-то более разумное.
Последнее требование поддержки произвольного числа уровней отката уже обсуждалось. В данном случае оказывается проще рассмотреть откат на один уровень и затем обобщить решение на произвольное число уровней.
Этими требованиями заканчивается презентация проблемы. Хорошей идей, как обычно, является попытка самостоятельного поиска решения, прежде чем продолжить чтение этой лекции.
У3.1 Небольшая интерактивная система (программистский проект)
Этот небольшой программистский проект является лучшим способом проверки понимания тем этой лекции и ОО-техники в целом.
Напишите текстовый редактор, ориентированный на работу со строками, поддерживающий следующие операции:
p: печать введенного текста;: передвигает курсор к следующей строке, если она есть (используйте код l, если это более удобно);: передвигает курсор к предыдущей строке, если она есть (используйте код h, если это более удобно);i: вставляет новую строку после позиции курсора.d: удаляет строку в позиции курсора;u: откат последней операции, если она не была Undo; если же это Undo, то выполняется повтор redo.Можно добавить новые команды или спроектировать более привлекательный интерфейс, но во всех случаях следует создать законченную, работающую систему. (Возможно, вы сразу начнете с улучшений, описанных в следующем упражнении.)
У3.2 Многоуровневый Redo
Дополните одноуровневую схему предыдущего упражнения переопределением смысла операции отката u:
u: откат последней операции, отличной от Undo и Redo.
Добавьте операцию повтора Redo:
r: повтор последней операции, если она применима.У3.3 Undo-redo в Pascal
Объясните, как применить рассмотренную технику в не ОО-языках, подобных Pascal, Ada (используя записи с вариантами) или C (используя структуры и union типы). Сравните с ОО-решениями.
У3.4 Undo, Skip и Redo
С учетом проблем, поднятых в обсуждении, рассмотрите, как можно расширить механизм, разработанный в этой лекции так, чтобы он допускал поддержку Undo, Skip и Redo, а также делал возможным повтор и откат, перемежаемый обычными командами. Обсудите эффект обоих новинок как на уровне интерфейса, так и реализации.
У3.5 Сохранение командных объектов
Рассмотрите аргументы команды независимо от команд и создайте только один командный объект на каждый тип команды.
Если вы выполнили предыдущее упражнение, примените эту технику к решению.
У3.6 Составные команды
В некоторых системах может быть полезным ввести понятие составной команды, выполнение которых включает выполнение нескольких других команд. Напишите соответствующий класс COMPOSITE_COMMAND, потомка COMMAND, убедитесь, что составные команды допускают откат и что компонента составной команды может быть составной командой.
Указание: используйте множественное наследование, представленное для составных фигур (см. лекцию 15 курса "Основы объектно-ориентированного программирования").
У3.7 Необратимые команды
Система может включать необратимые команды либо по самой их природе ("Запуск ракет"), либо по прагматичным причинам больших расходов, связанных с отменой действия команды. Усовершенствуйте решение так, чтобы оно учитывало возможность присутствия необратимых команд. Внимательно изучите алгоритмы и интерфейс пользователя, в частности используйте окно истории.
Указание: введите наследников UNDOABLE и NON_UNDOABLE класса COMMAND.
У3.8 Библиотека команд (проектирование и реализация)
Напишите общецелевую библиотеку команд, предполагающую использование в произвольной интерактивной системе и поддерживающую неограниченный механизм undo-redo. Библиотека должна интегрировать свойства, обсуждаемые в последних трех упражнениях: отделение команд от их аргументов, составные команды, необратимые команды. Возможно также встраивание свойства "Undo, Skip и Redo". Проиллюстрируйте применимость библиотеки, построив на ее основе три демонстрационные системы различной природы, такие как текстовый редактор, графическая система, инструмент тестирования.
У3.9 Механизм истории
Полезным компонентом, встраиваемым в командно-ориентированный инструментарий, является механизм истории, запоминающий выполненную команду и позволяющий пользователю повторно ее выполнить, возможно, модифицировав. Под Unix, например, доступен командный язык C-shell, запоминающий несколько последних выполненных команд. Вы можете напечатать !-2, означающее, что нужно выполнить команду, предшествующую последней. Запись ^yes^no^ означает "выполнение последней команды с заменой yes на no". Другие окружения предлагают схожие свойства.
Механизмы истории, когда они существуют, построены в соответствии с модой. Под Unix многие интерактивные средства, выполняемые под C-shell, такие как текстовый редактор Vi или различные отладчики, будут получать преимущества от такого механизма, но он не будет предлагаться другим системам. Это тем более вызывает сожаление, что те же концепции истории команд и те же ассоциированные свойства полезны любой интерактивной системе независимо от выполняемых ею функций.
Спроектируйте класс, реализующий механизм истории общецелевого назначения, так чтобы любая интерактивная система, нуждающаяся в этом механизме, могла получить его путем простого наследования класса. (Заметьте, множественное наследование здесь необходимо.)
Обсудите расширение этого механизма на общий класс USER_INTERFACE.
У3.10 Тестирование окружения
Тестирование компонентов ПО, например, класса требует определенных свойств при подготовке теста: ввода тестовых данных, выполнения теста, записи результатов, сравнения с ожидаемыми результатами и так далее. Определите общий, допускающий наследование класс TEST, задающий тестирующее окружение. (Обратите внимание, что и здесь важно множественное наследование.)
У3.11 Интегрируемые функции
(Для читателей, знакомых с численными методами.) Напишите множество классов для интегрирования вещественных функций вещественной переменной на произвольном интервале. Сюда должен входить класс INTEGRABLE_FUNCTION, а также отложенный класс INTEGRATOR, описывающий метод интегрирования, и потомки класса, такие как RATIONAL_FIXED_INTEGRATOR.
Выполнение обычных команд
Обычная команда по-прежнему идентифицируется ссылкой requested. Такую команду следует не только выполнить, но и добавить ее в список истории, предварительно удалив все элементы справа от курсора. В результате получим:
if not is_last then remove_all_right end history.put (requested) -- Напомним, put вставляет элемент в конец списка, -- курсор указывает на новый элемент requested.executeМы рассмотрели все основные элементы решения. В оставшейся части лекции обсудим некоторые аспекты реализации и извлечем из нашего примера методологические уроки.