Происхождение
Язык Smalltalk был разработан командой Xerox Palo Alto Research Center Learning Research Group (Xerox, Пало Альто, Исследовательский центр, группа исследования обучения), как программная часть Dynabook - фантастического проекта Алана Кея (Alan Kay). В основу были положены идеи Simula, хотя известное влияние оказали также язык FLEX и работы Сеймора Паперта (Seymore Papert) и Валласа Феурзейга (Wallace Feurzeig). Smalltalk является одновременно и языком программирования, и средой разработки программ. Это - чисто объектно-ориентированный язык, в котором абсолютно все рассматривается как объекты; даже целые числа - это классы. Вслед за Simula, Smalltalk является важнейшим объектно-ориентированным языком, поскольку он не только оказал влияние на последующие поколения языков программирования, но и заложил основы современного графического интерфейса пользователя, на которых непосредственно базируются интерфейсы Macintosh, Windows и Motif.
Развитие Smalltalk потребовало почти десятилетних усилий группы энтузиастов. Главным архитектором на протяжении почти всей работы был Дэн Ингалс (Dan Ingalls), но значительный вклад внесли также Питер Дейч (Peter Deutsh), Гленн Краснер (Glenn Krasner) и Ким МакКолл (Kim McCall). Параллельно, усилиями Джеймса Альтхофа (James Althoff), Роберта Флегала (Robert Flegal), Неда Келера (Ned Kaehler), Дианы Мерри (Diana Merry) и Стива Паца (Steve Putz) разрабатывалась оболочка Smalltalk. Адель Голдберг (Adele Goldberg) и Дэвид Робсон (David Robson) взяли на себя роль летописцев проекта.
Известны пять выпусков языка Smalltalk, обозначаемых по году их появления:
Smalltalk-72, -74. -76, -78, и самое свежее воплощение - Smalltalk-80. Реализации 1972 и 1974 годов заложили основу языка, в частности идею передачи сообщений и полиморфизм, хотя механизм наследования еще не появился. В последующих версиях полноправное гражданство получили классы; этим достигла завершения точка зрения, что все состоит из объектов. Smalltalk-80 был перенесен на многие компьютерные платформы.
Есть также один важный диалект (схожий со Smalltalk-80), получивший название Smalltalk/V. Он создан фирмой Digitalk для IBM PC (Windows и OS/2) и Macintosh. За исключением классов пользовательского интерфейса, библиотеки классов Smalltalk/V в обеих версиях практически идентичны. Среда и инструменты разработки также напоминают Smalltalk-80 [4].
Просмотр
Просмотр (walkthroughs) - общепринятая практика, которую нужно использовать каждой команде разработчиков. Как и планирование задач, просмотр программного обеспечения был введен независимо от объектно-ориентированной технологии. Однако при просмотре не объектно-ориентированных систем внимание обращается на другое.
Руководитель должен проводить просмотры с разумной частотой. За исключением самых ответственных и уязвимых для ошибок мест, просто неэкономично проверять каждую строчку программы. Следовательно, руководитель должен направить ограниченные ресурсы своей команды на рассмотрение проблем, опасных для стратегии разработки. Для объектно-ориентированных систем это означает большую формальность при проведении просмотров сценариев и архитектуры системы и менее формальную проверку тактических решений.
Как описано в предыдущей главе, сценарии являются первичным результатом объектно-ориентированного анализа. Они должны выражать требуемое поведение системы в терминах ее функциональных точек. Формальные просмотры сценариев проводятся аналитиками команды, вместе с экспертами предметной области или конечными пользователями при возможном участии других разработчиков. Лучше проводить такие просмотры на протяжении всей стадии анализа, чем ожидать выполнения одного глобального просмотра по завершении анализа, когда будет уже слишком поздно сделать что-нибудь полезное, перенаправив усилия аналитиков. Эксперименты показывают, что даже непрограммисты могут понять сценарии, представленные в виде текста или диаграмм объектов [Мы встречались с использованием этой системы обозначении в работе таких непрограммистских групп как астрономы, биологи, метеорологи, физики и банкиры]. В конечном счете просмотр помогает выработать общий словарь для разработчиков и пользователей системы. Привлечение к участию в просмотре других членов команды способствует уяснению ими реальных требований к системе на ранних этапах разработки.
Просмотр архитектуры должен охватывать всю систему, включая ее механизмы и структуру классов.
Как и при просмотре сценариев, просмотр архитектуры (архитектором или другими проектировщиками) должен производиться на протяжении всего проекта. Сначала просмотр сосредоточен на общих архитектурных решениях, а позднее, возможно, он акцентируется на некоторых категориях классов или конкретных механизмах. Основная цель просмотра состоит в проверке архитектуры в начале жизненного цикла и выработке общего взгляда на нее. Вторичной целью является поиск повторяющихся шаблонов классов или взаимодействий, которые затем могут быть использованы для упрощения архитектуры.
Неформальный просмотр следует проводить еженедельно. На нем обычно рассматриваются некоторые группы классов или механизмы нижнего уровня. Цель - проверить тактические решения; побочная цель - дать возможность старшим разработчикам научить новичков.
7.2. Кадры
Распределение ресурсов
Один из наиболее замечательных аспектов управления объектно-ориентированными проектами - это тот факт, что в устойчивом состоянии обычно наблюдается сокращение необходимых ресурсов и изменяется график их расходования по сравнению с традиционными методами. Именно "в устойчивом состоянии". Вообще говоря, первый объектно-ориентированный проект, предпринятый организацией, потребует несколько больше ресурсов - главным образом, в соответствии с кривой обучения, описывающей адаптацию ко всякой новой технологии. Выгоды проявятся во втором или третьем проекте, когда разработчики наберутся опыта в проектировании классов, поиске общих абстракций и механизмов, а менеджеры освоятся с методом итеративного развития.
На стадии анализа потребность в ресурсах с переходом на объектно-ориентированные методы обычно мало изменяется. Однако, поскольку объектно-ориентированный процесс уделяет больше внимания архитектуре, мы стремимся привлекать архитекторов и других разработчиков как можно раньше, иногда начиная архитектурные эксперименты еще на последней стадии анализа. Во время эволюции, как правило, потребуется меньше ресурсов, потому что работа облегчится общими абстракциями и механизмами, изобретенными ранее при проектировании архитектуры или выпуске предварительных версий.Тестирование может также потребовать меньше ресурсов, потому что новые функции обычно добавляются к уже корректно ведущей себя структуре класса или механизму. Таким образом, тестирование начинается раньше и является скорее постоянным и постепенным, чем разовым действием. Интеграция обычно требует значительно меньших ресурсов по сравнению с традиционными методами, главным образом потому, что она тоже происходит постепенно, от релиза к релизу, а не одним броском. Таким образом, в устойчивом состоянии трудозатраты оказываются гораздо меньше, чем при традиционных подходах. Более того, если учесть эксплуатационные затраты, то окажется, что весь жизненный цикл объектно-ориентированных программ часто стоит дешевле, так как конечный продукт, скорее всего, будет лучшего качества и окажется более приспособленным к изменениям.
Простые и сложные программные системы
Звезда в преддверии коллапса; ребенок, который учится читать; клетки крови, атакующие вирус, - это только некоторые из потрясающе сложных объектов физического мира. Компьютерные программы тоже бывают сложными, однако их сложность совершенно другого рода. Брукс пишет: "Эйнштейн утверждал, что должны существовать простые объяснения природных процессов, так как Бог не действует из каприза или по произволу. У программиста нет такого утешения: сложность, с которой он должен справиться, лежит в самой природе системы" [1].
Мы знаем, что не все программные системы сложны. Существует множество программ, которые задумываются, разрабатываются, сопровождаются и используются одним и тем же человеком. Обычно это начинающий программист или профессионал, работающий изолированно. Мы не хотим сказать, что все такие системы плохо сделаны или, тем более, усомниться в квалификации их создателей. Но такие системы, как правило, имеют очень ограниченную область применения и короткое время жизни. Обычно их лучше заменить новыми, чем пытаться повторно использовать, переделывать или расширять. Разработка подобных программ скорее утомительна, чем сложна, так что изучение этого процесса нас не интересует.
Нас интересует разработка того, что мы будем называть промышленными программными продуктами. Они применяются для решения самых разных задач, таких, например, как системы с обратной связью, которые управляют или сами управляются событиями физического мира и для которых ресурсы времени и памяти ограничены; задачи поддержания целостности информации объемом в сотни тысяч записей при параллельном доступе к ней с обновлениями и запросами; системы управления и контроля за реальными процессами (например, диспетчеризация воздушного или железнодорожного транспорта). Системы подобного типа обычно имеют большое время жизни, и большое количество пользователей оказывается в зависимости от их нормального функционирования. В мире промышленных программ мы также встречаем среды разработки, которые упрощают создание приложений в конкретных областях, и программы, которые имитируют определенные стороны человеческого интеллекта.
Существенная черта промышленной программы - уровень сложности: один разработчик практически не в состоянии охватить все аспекты такой системы. Грубо говоря, сложность промышленных программ превышает возможности человеческого интеллекта. Увы, но сложность, о которой мы говорим, по-видимому, присуща всем большим программных системам. Говоря "присуща", мы имеем в виду, что эта сложность здесь неизбежна: с ней можно справиться, но избавиться от нее нельзя.
Конечно, среди нас всегда есть гении, которые в одиночку могут выполнить работу группы обычных людей-разработчиков и добиться в своей области успеха, сравнимого с достижениями Франка Ллойда Райта или Леонардо да Винчи. Такие люди нам нужны как архитекторы, которые изобретают новые идиомы, механизмы и основные идеи, используемые затем при разработке других систем. Однако, как замечает Петерс: "В мире очень мало гениев, и не надо думать, будто в среде программистов их доля выше средней" [2]. Несмотря на то, что все мы чуточку гениальны, в промышленном программировании нельзя постоянно полагаться на божественное вдохновение, которое обязательно поможет нам. Поэтому мы должны рассмотреть более надежные способы конструирования сложных систем. Для лучшего понимания того, чем мы собираемся управлять, сначала ответим на вопрос: почему сложность присуща всем большим программным системам?
Рациональный процесс проектирования
Однако мы не можем обойтись без рецептов, описывая обещанную выше зрелую, воспроизводимую в любой организации технологию разработки. Поэтому мы и характеризовали ее, как управляемый итеративно развивающийся процесс - управляемый в том смысле, что он поддается проверке и измерению, но оставляет достаточную свободу для творчества.
Упорядоченный процесс проектирования чрезвычайно важен для организаций, разрабатывающих программное обеспечение. Хэмфри перечисляет следующие пять уровней зрелости таких процессов [7]:
• Начальный | Процесс разработки организован как придется и нередко хаотичен. На этой стадии налаживание элементарного управления проектом - уже прогресс. |
• Воспроизводимый | Организация в разумной степени управляет своими планами и обязательствами. |
• Определенный | Процесс разработки в разумной степени определен, понятен и применяется на практике; он позволяет выбирать команду и предсказывать ход разработки. Следующая цель - оформить выработанную практику разработки как инструментальную среду. |
• Управляемый | Организация выработала количественные показатели процесса. Цель состоит в снижении затрат на сбор данных и налаживание механизмов обратной связи, позволяющих данным влиять на процесс. |
• Оптимальный | Организация имеет отлаженный процесс, устойчиво выдающий результаты высокого качества, своевременно, предсказуемо и эффективно. |
К сожалению, как отмечают Парнас и Клеменс: "Мы никогда не отыщем процесс, который дал бы нам возможность проектировать программы строго рациональным образом", поскольку дело это творческое и новаторское по определению. Однако, продолжают они, "хорошей новостью является, то, что мы можем его имитировать... (Поскольку) разработчики нуждаются в руководстве, мы приблизимся к рациональной разработке, если будем следовать процессу, а не действовать, как попало. Когда организация занята многими программными продуктами, есть смысл иметь стандартную процедуру... Если мы держим в голове идеальный процесс, становится легче измерять успехи проекта" [9].
С приобретением опыта у организации встает вопрос: "Как примирить творчество и новации с возрастающей управляемостью?". Ответ состоит в разграничении макро- и микроэлементов процесса проектирования. Микропроцесс родственен спиральной модели развития, предложенной Боемом, и служит каркасом для итеративного подхода к развитию [10]. Макропроцесс близок к традиционному "водопаду" и задает направляющие рамки для микропроцесса. Примиряя эти два в корне различных процесса, мы имитируем полностью рациональный процесс разработки и обретаем основу для определенного уровня зрелости в деле создания программного обеспечения.
Мы должны подчеркнуть, что каждый проект уникален, и, следовательно, разработчик сам должен поддерживать баланс между неформальностью микропроцесса и формальностью макропроцесса. Для исследовательских приложений, разрабатываемых тесно сплоченной командой высококвалифицированных разработчиков, чрезмерная формальность негативно отразится на новациях; для очень сложных проектов, разрабатываемых большим коллективом разработчиков, отделенных друг от друга пространством и временем, недостаток формальности приводит к хаосу.
Оставшаяся часть этой главы дает обзор и детальное описание целей, результатов, видов деятельности и измеримых характеристик, составляющих микро- и макропроцессы разработки. В следующей главе мы рассмотрим практические проявления этих процессов, в первую очередь с точки зрения менеджеров, которые должны надзирать за ходом объектно-ориентированного проекта.
6.2. Микропроцесс проектирования
Расширение функциональных возможностей
В этом разделе мы попытаемся улучшить возможности проектируемой системы и оценить ее гибкость.
В интеллектуальных системах очень важно наряду с решением задачи получить информацию о самом процессе поиска решения. Для этого нужно придать системе способность самоанализа: регистрировать ход активизации источников знаний, причины и характер выдвигаемых предположений и т.д., чтобы иметь возможность запросить у системы, по какой причине сделано конкретное предположение и к каким результатам оно приводит.
Для реализации такого свойства необходимо сделать две вещи. Во-первых, нужно ввести механизм трассировки действий контроллера и источников знаний, а во-вторых - модифицировать некоторые методы, чтобы они записывали соответствующую информацию. Идея состоит том, что действия источников знаний и контроллера регистрируются в некотором общем центральном хранилище.
Посмотрим, какие классы нам понадобятся. Прежде всего, введем класс Action, регистрирующий действия источников знаний и контроллера:
class Action {
public:
Action(KnowledgeSource* who, BlackboardObject* what, char* why);
Action(Controller* who, KnowledgeSource* what, char* why);
};
Экземпляр данного класса создается, например, при активизации контроллером какого-либо источника знаний. При этом в аргумент who (кто) заносится указатель на контроллер, в аргумент what (что) - активный источник знаний, а в аргумент why (почему) - какое-либо пояснение (например, приоритет предположения).
Первая часть нашего нового механизма создана, вторая тоже не очень сложна. Посмотрим, где в нашей системе происходят основные события. Мы увидим, что основными являются следующие пять операций:
методы, которые выдвигают предположения;
методы, которые откатывают предположения;
методы, которые активизируют источники знаний;
методы, которые выполняют правила;
методы, которые регистрируют высказывания от источников знаний.
Все эти события сконцентрированы в двух местах: в конечном автомате контроллера и в механизме выдвижения предположений. Нам не придется существенно изменять архитектуру системы, чтобы учесть указанные выше требования.
Для полноты нам остается только создать объект, отвечающий на вопросы пользователя системы: кто? что? когда? почему?. Спроектировать такой объект несложно, поскольку вся нужная для его работы информация может быть получена от экземпляров класса Actions.
Реализация классов и объектов
Цель. На этапе анализа реализация классов и объектов нужна, чтобы довести существующие абстракции до уровня, достаточного для обнаружения новых классов и объектов на следующем уровне абстракции; они сами будут в дальнейшем поданы на новую итерацию микропроцесса. При проектировании целью реализации становится создание осязаемого представления наших абстракций путем выпуска последовательных исполнимых версий системы (макропроцесс).
Этот шаг намеренно выполняется позже всех, так как микропроцесс концентрирует внимание на поведении и откладывает насколько возможно решения о представлении. Такая стратегия оберегает разработчика от недозрелых решений, которые могут не оставить шансов на облегчение и упрощение архитектуры, и оставляет свободу выбора реализации (например, из соображений эффективности), гарантируя сохранение существующей архитектуры.
Результаты. На этом шаге мы принимаем решения о представлении каждой абстракции и об отображении этих абстракций в физическую модель. В начале процесса разработки мы формулируем эти тактические решения о представлении в форме уточненных спецификаций классов. Решения, имеющие общий интерес, или подходящие для повторного использования, мы документируем также на диаграммах классов (показывающих их статическую семантику), состояний и взаимодействия (показывающих их динамическую семантику). Когда становится ясно, на каком языке реализовывать проект, можно начинать программировать в псевдокоде или в исполнимом коде.
Чтобы раскрыть связи между логическим и физическим в нашей реализации системы, мы вводим диаграммы модулей, которые можно затем использовать, чтобы наглядно показать отображение нашей архитектуры в ее программную реализацию. Далее можно применить специфические инструментальные средства, которые позволяют либо генерировать код из диаграмм, либо восстанавливать диаграммы по реализации.
В этот шаг входит и обновление словаря данных, включая новые классы и объекты, которые были выявлены или изобретены при реализации существующих абстракций.
Эти новые абстракции являются частью исходной информации для следующего цикла микропроцесса.
Виды деятельности. С реализацией связано одно главное действие: выбор структур и алгоритмов, которые представляют семантику определенных ранее микропроцессом абстракций. В отличие от первых трех стадий микропроцесса, сосредоточенных на внешних представлениях абстракций, этот этап акцентирует внимание на их внутреннем представлении.
На стадии анализа результаты этого действия относительно абстрактны: мы не так обеспокоены собственно реализацией, как заинтересованы в отыскании новых абстракций, которым можно делегировать обязанности. На стадии проектирования, особенно на поздних стадиях проектирования классов, мы действительно переходим к практическим решениям.
Типичный порядок действий таков:
Пересмотреть протокол каждого класса. Идентифицировать стереотипы его использования объектами-клиентами, чтобы определить, какие операции являются центральными и, следовательно, должны быть оптимизированы. Для облегчения реализации разработать точные сигнатуры всех важнейших операций.Рассмотреть возможность использования параметризованных классов, закрытого или защищенного наследования в реализации. Выбрать подходящие классы-примеси или параметризованные классы (или создать новые, если задача достаточно общая) и соответствующим образом изменить структуру наследования.
Рассмотреть объекты, которым можно делегировать обязанности. Для достижения эффективности может потребоваться незначительная реорганизация обязанностей и/или протокола абстракции нижнего уровня.
Если семантика абстракции не может быть выражена через наследование, инстанцирование или делегирование, рассмотреть подходящее представление из примитивов языка. Выбрать то представление, которое оптимизирует стереотипы использования, учитывая важность операций с точки зрения объектов-клиентов абстракции. Однако помните, что невозможно оптимизировать каждый случай использования. Получив эмпирическую информацию из последовательных версий-прототипов, мы можем выделить абстракции, которые неэффективно используют время или память и улучшить их реализацию, не опасаясь нарушить предположения клиентов относительно нашей абстракции.
Выбрать подходящий алгоритм для каждой операции. Ввести вспомогательные операции для расчленения сложных алгоритмов на более простые или более пригодные для повторного использования части. Рассмотреть возможные компромиссы, в частности, сделать выбор между хранением и вычислением отдельных членов-данных.
Путевые вехи и характеристики. На стадии анализа мы считаем, что благополучно завершили фазу реализации, когда идентифицировали все важные абстракции из тех, что необходимы для выполнения обязанностей абстракций, выявленных на этом цикле микропроцесса. На стадии проектирования реализация считается благополучно завершенной, когда мы получили исполнимую или почти исполнимую программную модель наших абстракций.
Главным показателем благополучия на этой фазе является простота. Сложные, неуклюжие или неэффективные реализации свидетельствуют о недостатках самой абстракции или о плохом ее представлении.
6.3. Макропроцесс проектирования
Роль абстракции
Выше мы ссылались на эксперименты Миллера, в которых было установлено, что обычно человек может одновременно воспринять лишь 7¦2 единицы информации. Это число, по-видимому, не зависит от содержания информации. Как замечает сам Миллер: "Размер нашей памяти накладывает жесткие ограничения на количество информации, которое мы можем воспринять, обработать и запомнить. Организуя поступление входной информации одновременно по нескольким различным каналам и в виде последовательности отдельных порций, мы можем прорвать... этот информационный затор" [35]. В современной терминологии это называют разбиением или выделением абстракций.
Методы проектирования программных систем
Методы появились как ответ на растущую сложность программных систем. На заре компьютерной эры очень трудно было написать большую программу, потому что возможности компьютеров были ограничены. Ограничения проистекали из объема оперативной памяти, скорости считывания информации с вторичных носителей (ими служили магнитные ленты) и быстродействия процессоров, тактовый цикл которых был равен сотням микросекунд. В 60-70-е годы эффективность применения компьютеров резко возросла, цены на них стали падать, а возможности ЭВМ увеличились. В результате стало выгодно, да и необходимо создавать все больше прикладных программ повышенной сложности. В качестве основных инструментов создания программных продуктов начали применяться алгоритмические языки высокого уровня. Эти языки расширили возможности отдельных программистов и групп разработчиков, что по иронии судьбы в свою очередь привело к увеличению уровня сложности программных систем. В 60-70-е годы было разработано много методов, помогающих справиться с растущей сложностью программ. Наибольшее распространение получило структурное проектирование по методу сверху вниз. Метод был непосредственно основан на топологии традиционных языков высокого уровня типа FORTRAN или COBOL. В этих языках основной базовой единицей является подпрограмма, и программа в целом принимает форму дерева, в котором одни подпрограммы в процессе работы вызывают другие подпрограммы. Структурное проектирование использует именно такой подход: алгоритмическая декомпозиция применяется для разбиения большой задачи на более мелкие. Тогда же стали появляться компьютеры еще больших, поистине гигантских возможностей. Значение структурного подхода осталось прежним, но как замечает Стейн, "оказалось, что структурный подход не работает, если объем программы превышает приблизительно 100000 строк" [19]. В последнее время появились десятки методов, в большинстве которых устранены очевидные недостатки структурного проектирования. Наиболее удачные методы были разработаны Петерсом [20], Йеном и Цаи [21], а также фирмой Teledyne-Brown Engineering [22]. Большинство этих методов представляют собой вариации на одни и те же темы. Саммервилль предлагает разделить их на три основные группы [23]: метод структурного проектирования сверху вниз; метод потоков данных; объектно-ориентированное проектирование. Примеры структурного проектирования приведены в работах Иордана и Константина [24], Майерса [25] и Пейдж-Джонса [26]. Основы его изложены в работах Вирта [27, 28], Даля, Дейкстры и Хоара [29]; интересный вариант структурного подхода можно найти в работе Милса, Лингера и Хевнера [30]. В каждом из этих подходов присутствует алгоритмическая декомпозиция. Следует отметить, что большинство существующих программ написано, по-видимому, в соответствии с одним из этих методов. Тем не менее структурный подход не позволяет выделить абстракции и обеспечить ограничение доступа к данным; он также не предоставляет достаточных средств для организации параллелизма. Структурный метод не может обеспечить создание предельно сложных систем, и он, как правило, неэффективен в объектных и объектно-ориентированных языках программирования. Метод потоков данных лучше всего описан в ранней работе Джексона [31, 32], а также Варниера и Орра [33]. В этом методе программная система рассматривается как преобразователь входных потоков в выходные. Метод потоков данных, как и структурный метод, с успехом применялся при решении ряда сложных задач, в частности, в системах информационного обеспечения, где существуют прямые связи между входными и выходными потоками системы и где не требуется уделять особого внимания быстродействию. Объектно-ориентированное проектирование (object-oriented design, OOD) - это подход, основы которого изложены в данной книге. В основе OOD лежит представление о том, что программную систему необходимо проектировать как совокупность взаимодействующих друг с другом объектов, рассматривая каждый объект как экземпляр определенного класса, причем классы образуют иерархию. Объектно-ориентированный подход отражает топологию новейших языков высокого уровня, таких как Smalltalk, Object Pascal, C++, CLOS и Ada. |
Вулф так описывает этот процесс: "Люди развили чрезвычайно эффективную технологию преодоления сложности. Мы абстрагируемся от нее. Будучи не в состоянии полностью воссоздать сложный объект, мы просто игнорируем не слишком важные детали и, таким образом, имеем дело с обобщенной, идеализированной моделью объекта" [36]. Например, изучая процесс фотосинтеза у растений, мы концентрируем внимание на химических реакциях в определенных клетках листа и не обращаем внимание на остальные части - черенки, жилки и т.д. И хотя мы по-прежнему вынуждены охватывать одновременно значительное количество информации, но благодаря абстракции мы пользуемся единицами информации существенно большего семантического объема. Это особенно верно, когда мы рассматриваем мир с объектно-ориентированной точки зрения, поскольку объекты как абстракции реального мира представляют собой отдельные насыщенные связные информационные единицы.
В главе 2 понятие абстракции рассмотрено более детально.
Роль декомпозиции
Как отмечает Дейкстра, "Способ управления сложными системами был известен еще в древности - divide et impera (разделяй и властвуй)" [16]. При проектировании сложной программной системы необходимо разделять ее на все меньшие и меньшие подсистемы, каждую из которых можно совершенствовать независимо. В этом случае мы не превысим пропускной способности человеческого мозга: для понимания любого уровня системы нам необходимо одновременно держать в уме информацию лишь о немногих ее частях (отнюдь не о всех). В самом деле, как заметил Парнас, декомпозиция вызвана сложностью программирования системы, поскольку именно эта сложность вынуждает делить пространство состояний системы [17].
Алгоритмическая декомпозиция. Большинство из нас формально обучено структурному проектированию "сверху вниз", и мы воспринимаем декомпозицию как обычное разделение алгоритмов, где каждый модуль системы выполняет один из этапов общего процесса. На рис. 1-2 приведен в качестве примера один из продуктов структурного проектирования: структурная схема, которая показывает связи между различными функциональными элементами системы. Данная структурная схема иллюстрирует часть программной схемы, изменяющей содержание управляющего файла. Она была автоматически получена из диаграммы потока данных специальной экспертной системой, которой известны правила структурного проектирования [18].
Объектно-ориентированная декомпозиция. Предположим, что у этой задачи существует альтернативный способ декомпозиции. На рис. 1-3 мы разделили систему, выбрав в качестве критерия декомпозиции принадлежность ее элементов к различным абстракциям данной проблемной области. Прежде чем разделять задачу на шаги типа Get formatted update (Получить изменения в отформатированном виде) и Add check sum (Прибавить к контрольной сумме), мы должны определить такие объекты как Master File (Основной файл) и Check Sum (Контрольная сумма), которые заимствуются из словаря предметной области.
Хотя обе схемы решают одну и ту же задачу, но они делают это разными способами.
Во второй декомпозиции мир представлен совокупностью автономных действующих лиц, которые взаимодействуют друг с другом, чтобы обеспечить поведение системы, соответствующее более высокому уровню. Get formatted update (Получить изменения в отформатированном виде) больше не присутствует в качестве независимого алгоритма; это действие существует теперь как операция над объектом File of Updates (Файл изменений). Эта операция создает другой объект - Update to Card (Изменения в карте). Таким образом, каждый объект обладает своим собственным поведением, и каждый из них моделирует некоторый объект реального мира. С этой точки зрения объект является вполне осязаемой вещью, которая демонстрирует вполне определенное поведение. Объекты что-то делают, и мы можем, послав им сообщение, попросить их выполнить то-то и то-то. Так как наша декомпозиция основана на объектах, а не на алгоритмах, мы называем ее объектно-ориентированной декомпозицией.
Рис. 1-2. Алгоритмическая декомпозиция.
Декомпозиция: алгоритмическая или объектно-ориентированная? Какая декомпозиция сложной системы правильнее - по алгоритмам или по объектам? В этом вопросе есть подвох, и правильный ответ на него: важны оба аспекта. Разделение по алгоритмам концентрирует внимание на порядке происходящих событий, а разделение по объектам придает особое значение агентам, которые являются либо объектами, либо субъектами действия. Однако мы не можем сконструировать сложную систему одновременно двумя способами, тем более, что эти способы по сути ортогональны [Лэнгдон предполагает, что эта ортогональность изучалась с древних времен. Он пишет: "К. X. Ваддингтон отметил, что такая дуальность взглядов прослеживается до древних греков. Пассивный взгляд предлагался Демокритом, который утверждал, что мир состоит из атомов. Эта позиция Демокрита ставила в центр всего материю. Классическим представителем другой стороны - активного взгляда - был Гераклит, который выделял понятие процесса"[34]]. Мы должны начать разделение системы либо по алгоритмам, либо по объектам, а затем, используя полученную структуру, попытаться рассмотреть проблему с другой точки зрения.
Опыт показывает, что полезнее начинать с объектной декомпозиции. Такое начало поможет нам лучше справиться с приданием организованности сложности программных систем. Выше этот объектный подход помог нам при описании таких непохожих систем, как компьютеры, растения, галактики и общественные институты. Как будет видно в дальнейшем (в главах 2 и 7), объектная декомпозиция имеет несколько чрезвычайно важных преимуществ перед алгоритмической. Объектная декомпозиция уменьшает размер программных систем за счет повторного использования общих механизмов, что приводит к существенной экономии выразительных средств. Объектно-ориентированные системы более гибки и проще эволюционируют со временем, потому что их схемы базируется на устойчивых промежуточных формах. Действительно, объектная декомпозиция существенно снижает риск при создании сложной программной системы, так как она развивается из меньших систем, в которых мы уже уверены. Более того, объектная декомпозиция помогает нам разобраться в сложной программной системе, предлагая нам разумные решения относительно выбора подпространства большого пространства состояний.
Преимущества объектно-ориентированных систем демонстрируются в главах 8-12 примерами прикладных программ, относящихся к различным областям. Следующая врезка сопоставляет объектно-ориентированное проектирование с более традиционными подходами.
Рис. 1-3. Объектно-ориентированная декомпозиция.
Роль иерархии
Другим способом, расширяющим информационные единицы, является организация внутри системы иерархий классов и объектов. Объектная структура важна, так как она иллюстрирует схему взаимодействия объектов друг с другом, которое осуществляется с помощью механизмов взаимодействия. Структура классов не менее важна: она определяет общность структур и поведения внутри системы. Зачем, например, изучать фотосинтез каждой клетки отдельного листа растения, когда достаточно изучить одну такую клетку, поскольку мы ожидаем, что все остальные ведут себя подобным же образом. И хотя мы рассматриваем каждый объект определенного типа как отдельный, можно предположить, что его поведение будет похоже на поведение других объектов того же типа. Классифицируя объекты по группам родственных абстракций (например, типы клеток растений в противовес клеткам животных), мы четко разделяем общие и уникальные свойства разных объектов, что помогает нам затем справляться со свойственной им сложностью [37].
Определить иерархии в сложной программной системе не всегда легко, так как это требует разработки моделей многих объектов, поведение каждого из которых может отличаться чрезвычайной сложностью. Однако после их определения, структура сложной системы и, в свою очередь, наше понимание ее сразу во многом проясняются. В главе 3 детально рассматривается природа иерархий классов и объектов, а в главе 4 описываются приемы распознавания этих структур.
1.4. О проектировании сложных систем
Роль классов и объектов в анализе и проектировании
На этапе анализа и ранних стадиях проектирования решаются две основные задачи:
Выявление классов и объектов, составляющих словарь предметной области.
Построение структур, обеспечивающих взаимодействие объектов, при котором выполняются требования задачи.
В первом случае говорят о ключевых абстракциях задачи (совокупность классов и объектов), во втором - о механизмах реализации (совокупность структур).
На ранних стадиях внимание проектировщика сосредоточивается на внешних проявлениях ключевых абстракций и механизмов. Такой подход создает логический каркас системы: структуры классов и объектов. На последующих фазах проекта, включая реализацию, внимание переключается на внутреннее поведение ключевых абстракций и механизмов, а также их физическое представление. Принимаемые в процессе проектирования решения задают архитектуру системы: и архитектуру процессов, и архитектуру модулей.
3.6. Качество классов и объектов
Роли разработчиков
Полезно помнить, что разработка программного продукта в конечном счете производится людьми. Разработчики - не взаимозаменяемые части, и успешное создание любой сложной системы требует уникальных и разнообразных навыков всех членов целеустремленного коллектива.
Эксперименты показывают, что объектно-ориентированная разработка требует несколько иного разделения труда по сравнению с традиционными методами. Мы считаем следующие три роли разработчиков важнейшими в объектно-ориентированном подходе:
архитектор проекта;
ответственные за подсистемы;
прикладные программисты.
Архитектор проекта - его творец, человек с сильно развитым воображением; он отвечает за эволюцию и сопровождение архитектуры системы. Для малых или средних систем архитектурное проектирование обычно выполняется одной, максимум двумя светлыми личностями. Для больших проектов эта обязанность может быть распределена в большом коллективе. Архитектор проекта - не обязательно самый главный разработчик, но непременно такой, который может квалифицированно принимать стратегические решения (как правило благодаря обширному опыту в построении систем такого типа). Благодаря опыту, разработчики интуитивно знают, какие общие архитектурные шаблоны уместны в данной предметной области и какие проблемы эффективности встают в определенных архитектурных вариантах. Архитекторы - не обязательно лучшие программисты, хотя они должны уметь программировать. Точно так же, как строительные архитекторы должны разбираться в строительстве, неблагоразумно нанимать архитектора программного обеспечения, который не является приличным программистом. Архитекторы проекта должны также быть сведущи в обозначениях и организации процесса объектно-ориентированной разработки, потому что они должны в конечном счете выразить свое архитектурное видение в терминах кластеров классов и взаимодействующих объектов.
Очень плохая практика - нанимать архитектора со стороны, который, образно выражаясь, въезжает на белом коне, провозглашает архитектурные принципы, а потом уматывает куда-то, в то время как другие пытаются справиться с последствиями его решений.
Гораздо лучше привлечь архитектора к активной работе уже при проведении анализа и оставить его на как можно более длительный срок, даже на все время эволюции системы. Тогда он освоится с действительными потребностями системы и со временем испытает на себе последствия своих решений. Кроме того, сохраняя в руках одного человека или небольшой команды разработчиков ответственность за архитектурную целостность, мы повышаем шансы получить гибкую и простую архитектуру.
Ответственные за подсистемы - главные творцы абстракций проекта. Они отвечают за проектирование целых категорий классов или подсистем. Каждый ответственный в сотрудничестве с архитектором проекта разрабатывает, обосновывает и согласует с другими разработчиками интерфейс своей категории классов или подсистемы, а потом возглавляет ее реализацию, тестирование и выпуск релизов в течение всей эволюции системы.
Ответственные за подсистемы должны хорошо знать систему обозначений и организацию процесса объектно-ориентированной разработки. Обычно они программируют лучше чем архитекторы проекта, но не располагают обширным опытом последних. Лидеры подсистем составляют от трети до половины численности команды.
Прикладные программисты (инженеры) - младшие по рангу участники проекта. На них возложено выполнение двух обязанностей. Некоторые из них отвечают за реализацию категории или подсистемы под руководством ее ведущего. Эта деятельность может включать в себя проектирование некоторых классов, но в основном связана с реализацией и последующим тестированием классов и механизмов, разработанных проектировщиками команды. Другие отвечают за написание классов, спроектированных архитектором и ответственными за подсистемы, реализуя тем самым функциональные точки системы. В некотором смысле, эти программисты занимаются написанием маленьких программ на языке предметной области, определенном классами и механизмами архитектуры.
Инженеры разбираются в системе обозначений и в организации процесса разработки, но не слишком блестяще; зато они, как правило, являются очень хорошими программистами, знающими основные идиомы и слабые места выбранных языков программирования.
Инженеры составляют половину команды или более того.
Разница в квалификации ставит проблему подбора кадров перед всеми организациями, которые обычно имеют несколько сильных проектировщиков и большее количество менее квалифицированного персонала. Социальная польза нашего подхода к кадровой политике состоит в том, что он открывает путь для карьеры начинающим сотрудникам: молодые разработчики работают под руководством более опытных. Когда они наберутся опыта в использовании хорошо определенных классов, они смогут сами проектировать классы. Вывод: не обязательно каждому разработчику быть экспертом по абстракциям, но каждый разработчик может со временем этому научиться.
В больших проектах могут потребоваться и другие роли. Большинство из них (например, роль специалиста в средствах разработки) явно не связаны с объектно-ориентированной технологией, но некоторые непосредственно вытекают из нее (такие, как инженер, отвечающей за повторное использование):
• Менеджер проекта | Отвечает за управление материалами проекта, заданиями, ресурсами и графиком работ. |
• Аналитик | Отвечает за развитие и интерпретацию требований конечных пользователей; должен быть экспертом в проблемной области, однако его не следует изолировать от остальной команды разработчиков. |
• Инженер по повторному использованию | Управляет хранилищем (репозитарием) материалов проекта; участвуя в просмотре и других действиях, активно ищет общее и добивается его использования; находит, разрабатывает или приспосабливает компоненты для общего использования в рамках конкретного проекта или целой организации. |
• Контролер качества | Измеряет результаты процесса разработки; задает общее направление (на системном уровне) тестирования всех прототипов и релизов. |
• Менеджер интеграции | Отвечает за сборку совместимых друг с другом версий категорий и подсистем в релизы; следит за их конфигурированием. |
• Ответственный за документацию | Готовит документацию по выпускаемому продукту и его архитектуре для конечного пользователя. |
• Инструментальщик | Отвечает за создание и адаптацию инструментов программирования, которые облегчают производство программ и (особенно) генерацию кода. |
• Системный администратор | Управляет физическими компьютерными ресурсами в проекте. |
Конечно, не каждый проект требует всех этих ролей. Для небольших проектов обязанности могут совмещаться. С другой стороны, для очень больших проектов каждой из ролей может заниматься целая организация.
Опыт показывает, что объектно-ориентированная разработка может обойтись меньшим числом занятых в ней людей по сравнению с традиционными методами. На самом деле, чтобы за один год произвести высококачественную программу объемом в несколько сот тысяч строк достаточно 30-40 разработчиков. Однако мы согласны с Боемом, который считает, что "лучшие результаты получаются, когда разработчиков занято меньше, а квалификация их выше" [6]. К сожалению, если при разработке проекта пытаться обойтись меньшим количеством людей, чем считается необходимым, можно встретить известное сопротивление. Как отмечалось в предыдущей главе, такое отношение, возможно, вызвано попытками некоторых менеджеров построить империю. Другие менеджеры любят скрываться за множеством служащих, потому что большее количество людей означает больше власти. Кроме того, в случае провала проекта есть на кого свалить вину.
Применение самого изощренного метода проектирования или новейших инструментов не освобождает менеджера от ответственности за подбор проектировщиков, способных мыслить, и не является основанием для того, чтобы пустить проект на самотек [7].
7.3. Управление релизами
Интеграция
Промышленные программные проекты требуют создания семейств программ. В процессе разработки создаются прототипы и релизы. Очень часто каждый разработчик имеет свое собственное представление о разрабатываемой системе.
В предыдущей главе объяснялось, что в процессе объектно-ориентированной разработки интеграция редко проводится за один раз. Обычно происходит множество мелких интеграции, каждая из которых соответствует созданию нового прототипа или архитектурного релиза. Каждый новый релиз поступательно развивает предыдущие стабильные релизы. "При итеративной разработке сначала строится программный продукт, удовлетворяющий нескольким конечным требованиям, но конструктивно выполненный так, чтобы облегчить затем удовлетворение всех требований и достичь таким образом большей адаптируемости" [8].
С точки зрения конечного пользователя, поток релизов проистекает из макропроцесса. Каждый следующий релиз поддерживает все больше функций, и в конечном счете они развиваются в готовую систему. С точки зрения человека, наблюдающего процесс изнутри, создается много больше релизов, но только некоторые из них будут "заморожены", чтобы стабилизировать важнейшие интерфейсы системы, и взяты за основу для дальнейшей работы. Такая стратегия дает возможность снизить риск разработки, ускоряет выявление проблем в архитектуре и узких мест уже на ранних стадиях.
Для больших проектов организация может готовить внутренние релизы системы каждые несколько недель, а релизы для заказчика - раз в несколько месяцев, в соответствии с потребностями проекта. В стабильном состоянии релиз состоит из множества совместимых подсистем вместе с соответствующей документацией. Приступать к построению релиза можно при условии, что главные подсистемы проекта достаточно стабильны, а их совместное взаимодействие достаточно слажено, чтобы обеспечить новый уровень функциональности.
Управление конфигурацией и версиями
Рассмотрим поток релизов с точки зрения разработчика, занятого созданием некоторой подсистемы. Он имеет текущую версию этой подсистемы. Следовательно, в его распоряжении должны быть как минимум интерфейсы всех импортируемых подсистем. Когда рабочая версия стабилизируется, она передается команде, занимающейся интеграцией, которая отвечает за сборку совместимых подсистем в целую систему. В конце концов набор подсистем замораживается, берется за точку отсчета и входит во внутренний релиз. Внутренний релиз становится текущим оперативным релизом, доступным всем разработчикам, которым нужно провести дальнейшую доработку своих частей реализации. Наш разработчик в это время может трудиться над новой версией своей подсистемы. Таким образом, разработка может вестись параллельно и оставаться устойчивой благодаря четко определенным и защищенным интерфейсам подсистем.
В этой модели неявно подразумевается, что единицей контроля версий является не отдельный класс, а группа классов.
Опыт показывает, что управление версиями классов слишком безнадежное дело, так как они слишком зависимы друг от друга. Лучше выпускать версии связанных групп классов, точнее говоря - подсистем, поскольку кластеры классов логической модели системы отображаются в подсистемы физической модели.
В любой момент разработки системы могут существовать несколько версий ее подсистем: версия для текущего разрабатываемого релиза, версия для текущего внутреннего релиза, версия для последующего релиза, предназначенного для заказчика и т.д. Это обостряет потребность в достаточно мощных средствах управления конфигурацией и версиями.
Под понятие "исходный код" подпадает не только текст программ, но и все остальные продукты объектно-ориентированного развития: технические требования, диаграммы классов, объектов, модулей и процессов.
Тестирование
Принцип непрерывной интеграции приложим и к тестированию, которое также производится в течение всего процесса разработки. В контексте объектно-ориентированной архитектуры тестирование должно охватывать как минимум три направления:
Тестирование модулей.
Предполагает тестирование отдельных классов и механизмов; является обязанностью инженера, который их реализовал.
Тестирование подсистем.
Предполагает тестирование целых категорий или подсистем; является обязанностью ответственного за подсистему; тесты подсистем могут использоваться регрессивно для каждой вновь выпускаемой версии подсистемы.
Тестирование системы
Предполагает тестирование системы как целого; является обязанностью контролеров качества; тестирование системы, как правило, тоже происходит регрессивно.
Тестирование должно фокусироваться на внешнем поведении системы; его побочная цель - определить границы системы чтобы понять, как она может выходить из строя при определенных условиях.
7.4. Повторное использование
Элементы повторного использования
Любой программный продукт (текст программы, архитектура, сценарий или документация) может быть использован повторно.
Как сказано в главе 3, в объектно- ориентированных языках программирования первичным лингвистическим средством повторного использования являются классы: класс может порождать подклассы, специализирующие или дополняющие его. Далее, в главе 4 говорилось о повторном использовании шаблонов классов, объектов и элементов проектирования в форме идиом, механизмов и сред разработки. Повторное использование шаблонов находится на более высоком уровне абстракции по сравнению с использованием индивидуальных классов и дает больший выигрыш (хотя оно труднее достижимо).
Не следует доверять цифрам, характеризующим повторное использование [9]. В удачных проектах, с которыми мы сталкивались, количество повторно использованных элементов доходило до 70% (то есть почти три четверти программного обеспечения системы было взято без изменений из некоторого другого источника), но бывало и нулевым. Не следует думать, что повторное использование должно достичь некоторой обязательной величины; возможность повторного использования сильно зависит от предметной области и нетехнических факторов, таких, например, как степень напряженности рабочего графика, природа отношений с субподрядчиками и соображения безопасности.
Безусловно, любой процент повторного использования лучше, чем нулевой, так как экономит ресурсы, которые иначе пришлось бы потратить еще раз.
Как осуществить повторное использование?
Повторное использование в пределах проекта или даже целой организации не должно протекать по воле случая. Нужно специально выискивать возможности и поощрять успехи. Именно поэтому мы включили поиск повторяющихся шаблонов в макропроцесс.
Лучше всего поручить повторное использование кому-то лично. Как описывалось в предыдущей главе, надо искать возможные общности, обычно выявляемые при просмотре архитектуры, реализовывать их, создавая новые или приспосабливая старые компоненты, а потом отстаивать их перед другими разработчиками. Даже простые формы поощрения, такие, как равное признание автора первоначального кода и первооткрывателя возможности заимствования, оказывают стимулирующее воздействие.
Можно придумать что-нибудь посущественнее - обед в ресторане или путешествие на выходные для двоих - и присуждать эти поощрения тем разработчикам, чьи решения были заимствованы чаще всего, или тем, которые заимствовали наибольшую часть кода за заданное время [Близкие к разработчикам люди часто терпят некоторый моральный урон в заключительной горячке разработки, и такая компенсация им будет весьма кстати].
Повторное использование может и не принести краткосрочных выгод, но окупается в долгосрочной перспективе. Этим имеет смысл заниматься в организации, которая имеет обширные, далеко идущие планы разработки программного обеспечения и смотрит дальше интересов текущего проекта.
7.5. Качество и измерения
Качество программного продукта
Шульмейер и МакМанус определяют качество программного продукта как "пригодность к использованию" [10]. Качество программы не должно быть делом случая. Качество должно гарантироваться процессом разработки. На самом деле, объектно-ориентированной технология не порождает качества автоматически: можно написать сколь угодно плохие программы на любом объектно-ориентированном языке программирования.
Вот почему в процессе объектно-ориентированной разработки мы придаем такое значение архитектуре программной системы. Качество закладывается благодаря простой, гибкой архитектуре и осуществляется естественными и последовательными тактическими решениями.
Контроль качества программного продукта - это "систематические действия, подтверждающие пригодность к использованию программного продукта в целом" [11]. Цель контроля качества - дать нам количественные меры качества программной системы. Многие традиционные количественные меры непосредственно приложимы и к объектно-ориентированным системам.
Как описывалось выше, разбор и просмотр не теряют своей значимости в объектно-ориентированных системах, позволяя предсказать качество системы и влиять на него. Возможно, самым главным количественным критерием качества является количество обнаруженных ошибок.
Во время эволюции системы мы учитываем программные ошибки в соответствии с их весом и расположением. График обнаружения ошибок отображает зависимость количества обнаруженных ошибок от времени. Как указывает Доббинс, "не так важно действительное число ошибок, как наклон этого графика" [12]. Для управляемого процесса этот график имеет форму горба, с вершиной примерно в середине периода тестирования, а дальше эта кривая падает до нуля. Неуправляемому процессу соответствует неубывающая со временем или медленно убывающая кривая.
Одно из достоинств макропроцесса в объектно-ориентированной разработке состоит в том, что он позволяет вести непрерывный сбор данных о количестве обнаруженных ошибок уже на ранних стадиях разработки. Для каждого нового релиза мы можем провести тестирование системы и нарисовать график зависимости количества ошибок от времени. У "здорового" проекта горбовидная форма этого графика наблюдается для каждого релиза, начиная с самых ранних.
Другая количественная мера - плотность ошибок. Количество обнаруженных ошибок на килостроку программного текста (KSLOC - Kilo Source Lines Of Code) является традиционным показателем, применимым, в частности, к объектно-ориентированным системам. В "здоровых" проектах плотность ошибок "имеет тенденцию достигать стабильного значения при просмотре примерно 10 KSLOC. Просматривая код далее, мы не должны наблюдать увеличения этого показателя" [13].
Мы полагаем, что в объектно-ориентированных системах полезно также измерять число ошибок на категорию классов или на класс. При этом правило 80/20 считается приемлемым: 80% выявленных ошибок в программе сосредоточено в 20% классов системы [14].
В дополнение к этим более формальным подходам к накоплению получаемой при тестировании информации об ошибках, мы считаем полезным устраивать "охоту за ошибками", в которой все желающие могут экспериментировать с релизом в течение ограниченного промежутка времени, после чего награждается призами тот, кто обнаружил наибольшее количество ошибок, и тот, кто отыскал самую незаметную ошибку.
Призы не должны быть экстравагантными: для награждения бесстрашных охотников годятся кофейная кружка, талоны на обед, билеты в кино или даже футболка.
Объектно-ориентированные меры
Наверное, самый скверный способ оценить сделанную работу, каким может воспользоваться управляющий, - сосчитать количество написанных строк текста программы. Число строк, попавших во фрагмент кода, абсолютно никак не связано с его завершенностью и сложностью. В дополнение к другим недостаткам этого неандертальского подхода, в нем слишком просто играть с цифрами, что приводит к оценкам производительности, отличающимся друг от друга более, чем на два порядка. Например, что такое строка программы (особенно на Smalltalk)? Считаются ли физические строки или точки с запятой? Как учесть несколько операторов на одной строке и операторы, которые занимают более одной строки? А как измерить количество затраченного труда? Считать код работой всего персонала или, может быть, только программистов, написавших реализацию? Рабочий день должен считаться восьмичасовым или время, проведенное за утренней раскачкой, тоже должно учитываться? Традиционные меры сложности более подходят для первых поколений языков программирования, они не являются показателями завершенности и сложности объектно-ориентированной системы.
Например, цикломатическая метрика МакКэйба не будет сколько-нибудь полезной мерой сложности, если ее применить к объектно-ориентированной системе в целом, потому что она слепа к структуре классов системы и механизмам. Однако, мы находим полезным применять цикломатическую метрику к отдельным классам, - она дает некоторое представление об их сложности и может быть использована для определения наиболее подозрительных классов, которые, вероятно, содержат больше всего ошибок.
Мы склонны измерять прогресс разработки числом готовых и работающих классов (логический аспект), или количеством функционирующих модулей (физический аспект). Как говорилось в предыдущей главе, другой мерой прогресса является стабильность ключевых интерфейсов (то есть насколько часто они подвергаются изменениям).
Сначала интерфейсы всех ключевых абстракций изменяются ежедневно, если не ежечасно. Через некоторое время стабилизируются наиболее важные из ключевых интерфейсов, следом - вторые по важности и т.д. К концу жизненного цикла разработки только несколько несущественных интерфейсов нуждаются в доработке, так как основной упор делается на то, чтобы заставить готовые классы и модули работать вместе. Иногда в ключевые интерфейсы требуется внести некоторые изменения, но они обычно остаются совместимыми снизу вверх. Причем, эти изменения производятся только после того, как будет тщательно продумано их влияние. Эти изменения могут быть внесены в разрабатываемую систему при подготовке нового релиза.
Чидамбер и Кемерер предлагают несколько мер, которые непосредственно применимы к объектно-ориентированным системам [15]:
взвешенная насыщенность класса методами;
глубина дерева наследования;
число потомков;
зацепление объектов;
отклик на класс;
недостаток связности в методах.
Взвешенная насыщенность класса дает относительную меру его сложности; если считать, что все методы имеют одинаковую сложность, то это будет просто число методов в классе. Вообще, класс, который имеет большее количество методов среди классов одного с ним уровня, является более сложным; скорее всего, он специфичен для данного приложения и содержит наибольшее количество ошибок.
Глубина дерева наследования и число потомков - количественные характеристики формы и размера структуры классов. Как обсуждалось в главе 3, хорошо структурированная объектно-ориентированная система чаще бывает организована как лес классов, чем как одно высоченное дерево. Мы советуем строить сбалансированные по глубине и ширине структуры наследования: обычно - не глубже, чем 7¦2 уровня, и не шире, чем 7¦2 ветви.
Зацепление объектов - мера их взаимозависимости. Так же, как и при традиционном программировании, мы стремимся спроектировать незацепленные (то есть слабо связанные) объекты, поскольку они имеют больше шансов на повторное использование.
Отклик на класс - количество методов, которые могут вызываться экземплярами класса. Связность методов - мера насыщенности абстракции. Класс, который может вызывать существенно больше методов, чем равные ему по уровню классы, является более сложным. У класса с низкой связностью можно подозревать случайную или неподходящую абстракцию: такой класс должен, вообще говоря, быть переабстрагирован в несколько классов или его обязанности должны быть переданы другим существующим классам.
7.6. Документация
Наследие разработки
Разработка программной системы включает в себя гораздо больше, чем просто написание кода. Некоторые аспекты проекта должны быть постоянно доступны менеджерам разработки и внешним пользователям. Мы хотим также сохранить сведения о решениях, принятых при анализе и проектировании для тех, кто будет заниматься сопровождением системы. Как отмечалось в главе 5, результаты объектно-ориентированной разработки обычно содержат диаграммы классов, объектов, модулей и процессов. В совокупности эти диаграммы предоставляют возможность проследить их появление непосредственно из начальных требований к системе. Диаграммы процессов соответствуют программам, которые являются корневыми модулями диаграммы модулей. Каждый модуль представляет реализацию некоторой комбинации классов и объектов, которые, в свою очередь, можно найти на диаграммах классов и объектов соответственно. Наконец, диаграммы объектов представляют сценарии, определяемые требованиями, а диаграммы классов демонстрируют ключевые абстракции, которые формируют словарь предметной области.
Содержание документации
Документация по архитектуре системы важна, но ее составление не должно быть двигателем процесса разработки: документация - существенный, но не самый главный результат. Важно помнить, что документация - живой продукт, и ей надо позволить эволюционировать вместе с релизами проекта. Вместе с текстом программ сопровождающая документация служит основой для проведения многих формальных и неформальных просмотров.
Что должно быть документировано? Очевидно, что документация, представляемая конечному пользователю, должна включать инструкции по установке и использованию каждого релиза. Кроме того, должны быть документированы результаты анализа, чтобы зафиксировать семантику функциональных точек системы в последовательности сценариев. Должна также вестись документация по архитектуре и реализации для согласования в команде разработчиков общего видения системы и деталей архитектуры, а также для того, чтобы сохранить информацию обо всех стратегических решениях - это несомненно облегчает эволюцию и адаптацию системы.
Документация по архитектуре и реализации должна описывать:
архитектуру системы верхнего уровня;
ключевые абстракции и механизмы архитектуры;
сценарии, иллюстрирующие важнейшие аспекты предусмотренного поведения системы.
Наихудшая документация, которую можно создать для объектно-ориентированной системы - это расписанные по классам объяснения смысла каждого метода в отдельности. При таком подходе получаются горы бесполезной бумаги, которую никто не читает и не доверяет ей, причем оказываются потеряны наиболее важные архитектурные решения, выходящие за рамки отдельных классов и проявляющиеся в сотрудничестве классов и объектов. Гораздо лучше документировать эти структуры верхнего уровня на языке диаграмм в описанной выше системе обозначений, но без явных операторов языка программирования, и сделать ссылки для разработчиков на интерфейсы важнейших классов для уточнения тактических деталей.
7.7. Инструменты
В предыдущих поколениях языков программирования команде разработчиков достаточно было иметь минимальный набор инструментов: редактор, компилятор, компоновщик и загрузчик - вот все, что обычно требовалось (и, чаще всего, все, что имелось). Особо благополучная команда могла обзавестись кодовым отладчиком. Сложные задачи в корне изменили картину: попытка построить большую программную систему с минимальным набором инструментов эквивалентна намерению возвести многоэтажное здание вручную.
Объектно- ориентированный подход также изменил многое. Традиционные инструменты разработки сосредоточены только на тексте программы, но объектно-ориентированные анализ и проектирование выдвигают на первый план ключевые абстракции и механизмы, и нам нужны инструменты, работающие с более богатой семантикой. Кроме того, для ускорения движимого макропроцессом объектно-ориентированного развития, требуются инструменты, позволяющие ускорить циклы разработки, особенно цикл редактирование/компиляция/выполнение/отладка.
Важно выбрать хорошо масштабируемые инструменты. Инструмент, который удобен для программиста-одиночки, занятого написанием небольшого приложения, не всегда подходит для производства сложных систем. На самом деле, для каждого инструмента существует порог, за которым его возможности оказываются превышены: его достоинства перевешиваются неуклюжестью и нерасторопностью.
Виды инструментов
Мы выделяем по крайней мере семь различных видов инструментов для объектно-ориентированной разработки. Первый инструмент - система с графическим интерфейсом, поддерживающая объектно-ориентированную систему обозначений, представленную в главе 5. Такой инструмент может быть использован при анализе, чтобы зафиксировать семантику сценариев, на ранних стадиях разработки, чтобы передать стратегические и тактические решения, принятые при проектировании, а также для координирования действий проектировщиков. Подобный инструмент будет полезен на протяжении всего жизненного цикла и при сопровождении системы. В частности, мы считаем возможным восстанавливать по тексту программы многие важные аспекты объектно-ориентированной системы. Такая способность инструментальной системы очень важна: в традиционных CASE-средствах разработчик может создавать замечательные картинки только для того, чтобы обнаружить, что они устарели, потому что программисты манипулируют реализацией, не отраженной в проекте. Восстановление проекта по коду снижает вероятность того, что документация будет идти не в ногу с реализацией.
Следующий важный для объектно-ориентированной разработки инструмент - броузер, который показывает структуру классов и архитектуру модулей системы. Иерархия классов может сделаться настолько сложной, что трудно даже отыскать все абстракции, которые были введены при проектировании или выявить кандидатов для повторного использования [16]. При изучении фрагмента программы разработчику может понадобиться посмотреть определение класса некоторого объекта. Найдя этот класс, ему вероятно придется заглянуть в описание какого-нибудь из его суперклассов. Рассматривая этот суперкласс, разработчик может захотеть внести изменения в интерфейс класса, но при этом придется изучить поведение всех его клиентов. Этот анализ будет особенно громоздким, если он применяется к файлам, которые представляют физические, а не логические аспекты проекта. По этой причине броузер оказывается очень важным инструментом объектно-ориентированного анализа и проектирования. Броузеры есть, например, в среде Smalltalk. Подобные средства, хотя и разного качества, имеются и для других объектно-ориентированных языков.
Другой вид инструментов, который очень важен, если не абсолютно необходим - инкрементный компилятор. Метод эволюционной разработки, который применяется в объектно-ориентированном программировании, нуждается в компиляторе, который мог бы компилировать отдельные объявления и операторы. Мейровиц замечает, что "UNIX, с ее ориентацией на пакетное компилирование больших программных файлов в библиотеки, которые потом компонуются с другими фрагментами кода, не предоставляет необходимой поддержки для объектно-ориентированного программирования. Совершенно недопустимо тратить десять минут на компиляцию и компоновку, чтобы изменить реализацию метода и тратить целый час на компиляцию и компоновку только для того, чтобы добавить новое поле в суперкласс верхнего уровня! Для быстрой отладки методы и определения полей должны компилироваться инкрементно" [17]. Такие компиляторы существуют для многих языков, описанных в приложении.
К сожалению, большинство реализации содержит традиционные пакетно-ориентированные компиляторы.
Мы также полагаем, что нетривиальные проекты нуждаются в отладчиках, которые могут работать с семантикой классов и объектов. При отладке программ часто требуется справка о переменных экземпляра и переменных класса для данного объекта. Традиционные отладчики для необъектных языков ничего не знают о классах и объектах. Так, пытаясь использовать стандартный отладчик языка С для программы на C++, разработчик не сможет получить всею информацию, необходимую для отладки объектно-ориентированной программы. Ситуация особенно критична для объектно-ориентированных языков, поддерживающих несколько потоков управления. При выполнении такой программы в один и тот же момент времени могут быть запущены несколько активных процессов. Естественно требуется отладчик, который позволял бы контролировать каждый отдельный поток и взаимодействие объектов между потоками.
В категорию отладочных средств мы включаем и такие инструменты, как стрессовые тестеры, которые испытывают программы в критических условиях ограниченности ресурсов, и инструменты для анализа памяти, которые распознают нарушения доступа к памяти (запись в неразрешенные участки памяти, чтение из неинициализированных участков, чтение или запись за границами массива).
Для больших проектов требуются инструменты управления конфигурацией и контроля версий. Как упоминалось ранее, для управления конфигурацией лучшими единицами являются категории классов и подсистемы.
Другой инструмент, который мы считаем важным для объектно-ориентированной разработки, - это библиотекарь классов. Многие из языков, о которых упоминается в этой книге, поставляются со стандартными библиотеками или библиотеки для них можно купить отдельно. По мере развития проекта библиотека классов растет, дополняясь новыми предметно-специфическими программными компонентами, годными для повторного использования. Библиотека быстро разрастается до таких размеров, что разработчики не могут отыскать понадобившийся класс.
Одна из причин быстрого роста библиотеки состоит в том, что класс может иметь несколько реализации с различными временными и пространственными семантиками. Если ожидаемая стоимость (обычно преувеличенная) отыскания компоненты выше, чем ожидаемая стоимость (обычно преуменьшенная) создания этой компоненты заново, пропадает всякий смысл повторного использования. По этой причине бывает важно иметь хоть какой-нибудь минимальный библиотечный инструмент, который позволял бы разработчику искать классы и модули, удовлетворяющие различным критериям, и заносить в библиотеку полезные классы и модули по мере их разработки.
Еще один тип инструмента, который мы считаем полезным для некоторых систем - генератор графического интерфейса пользователя. Для систем, в которых велик объем взаимодействия с пользователем, лучше иметь специальный инструмент для интерактивного создания диалогов и окон, чем программировать все с нуля. Код, сгенерированный такой системой, может быть потом связан с остальной объектно-ориентированной системой и, если необходимо, вручную подкорректирован.
Организационные выводы
Потребность в мощных инструментах приводит к появлению двух специальных должностей в штате организации, занимающейся разработкой программных систем: инженер по повторному использованию и инженер-инструментальщик. Среди прочего, обязанности первого состоят в сопровождении библиотеки классов проекта. Без активных усилий она может стать необозримым пустырем превратившихся в хлам классов, в который ни один из разработчиков не захочет заглядывать. Часто бывает, что разработчиков надо побуждать к заимствованию существующих компонентов или предотвращать изобретение велосипеда; это тоже входит в задачи инженера по повторному использованию. В обязанности инженера-инструментальщика входят создание новых предметно-зависимых инструментов и переделка существующих инструментов для текущих нужд. Например, может потребоваться целая система тестов для проверки некоторых аспектов пользовательского интерфейса или более специализированный броузер классов.
Инструментальщик облегчает свою работу тем, что разрабатывает и развивает инструменты, используя компоненты, уже помещенные в библиотеку классов. Их можно задействовать и в новых разработках.
Менеджер, ограниченный в средствах, может жаловаться, что хорошие инструменты, инженеры по повторному использованию и инструментальщики - непозволительная роскошь. Для некоторых проектов - да. Однако часто все это так или иначе делается, но стихийным, неэффективным образом. Мы стоим за явные затраты на средства и персонал, чтобы сделать эту деятельность более концентрированной и эффективной и увеличить ценность общей организации.
7.8. Специальные вопросы
Узко-специфические проблемы
Мы считаем, что некоторые предметные области заслуживают специального архитектурного рассмотрения.
Проектирование эффективного пользовательского интерфейса - скорее искусство, чем наука. В этой области абсолютно необходимо использование прототипов. Как можно раньше следует установить интенсивную обратную связь с конечными пользователями, чтобы выявить характерные движения рук, реакцию на ошибки и другие парадигмы взаимодействия. При анализе пользовательского интерфейса в высшей степени эффективно составление сценариев.
Некоторые приложения имеют собственную базу данных, для других приложений может требоваться интеграция с существующей базой данных, схема которой не может быть изменена (обычно из-за того, что база уже заполнена большим количеством данных - разновидность проблемы унаследованной сложности). В таких случаях хорошо работает принцип разделения обязанностей: лучше всего инкапсулировать доступ к таким базам данных в небольшом количестве четко определенных интерфейсов классов. Этот принцип особенно важен при совместном использовании объектно-ориентированной декомпозиции и технологии реляционных баз данных (РБД). Объектно-ориентированные базы данных (ООБД) лучше стыкуются с объектными программами, но мы должны помнить, что ООБД больше подходят для обеспечения продолжительности жизни объектов и меньше - для хранения больших объемов данных.
Коснемся также систем реального времени. Понятие реальное время в различном контексте воспринимается по-разному. В диалоговых системах оно может означать отклик в течение менее чем одной секунды, а в системах сбора данных и управления может требоваться отклик быстрее чем за одну микросекунду. Важно ясно понимать, что даже при очень жестких требованиях ко времени не каждая компонента должна (или может) быть оптимизирована. Для сложных систем наибольший риск состоит в том, будет или нет система завершена, а не в том, будет ли она удовлетворять требованиям эффективности. По этой причине мы предостерегаем от преждевременной оптимизации. Сосредоточьтесь на создании простой архитектуры, а узкие места выявятся в процессе эволюции системы сами собой (причем достаточно рано), и вы сможете принять меры.
Термин "унаследованная сложность" относится к приложениям, в которые были сделаны большие капиталовложения и от которых по экономическим причинам или по соображениям безопасности нельзя отказаться. Однако, такие системы могут иметь неподъемную стоимость сопровождения, отчего их и приходится со временем заменять. К счастью, совладать с унаследованной сложностью можно почти также, как с базами данных: инкапсулировать доступ к их услугам в контексте четко определенных интерфейсов классов, а потом постепенно, функцию за функцией, вытеснять их объектно-ориентированной архитектурой. Конечно, необходимо представлять, себе конечный результат, чтобы система в процессе неуправляемых изменений не превратилась в лоскутное одеяло.
Переход на объектные технологии
Как считает Кемпф, "Изучение объектно-ориентированного программирования может оказаться гораздо более трудной задачей, чем просто освоение очередного языка. В данном случае надо научиться другому стилю программирования, а не просто запомнить синтаксис. То есть учить приходится не язык, а способ мышления" [18]. Как развить объектно-ориентированное мировоззрение? Мы рекомендуем:
Обеспечить формальное обучение разработчиков и менеджеров элементам объектной модели.
Начать с проектов с низким уровнем риска и позволить команде делать ошибки; по мере приобретения опыта можно направлять участников в другие команды в качестве наставников.
Продемонстрировать разработчикам и менеджерам примеры хорошо структурированных объектно-ориентированных систем.
Хорошими кандидатами для первых проектов являются инструментальные средства или предметно-ориентированные библиотеки классов, которые можно потом использовать как ресурсы для следующих проектов.
Согласно нашему опыту, профессиональному разработчику требуется несколько недель, чтобы освоиться с синтаксисом и семантикой нового языка программирования. Потребуется в несколько раз больше времени, чтобы тот же разработчик начал понимать важность и мощь классов и объектов. Наконец, может потребоваться более шести месяцев экспериментов, чтобы разработчик созрел до хорошего проектировщика классов. Это не обязательно плохо - обучение мастерству всегда требует времени.
Мы считаем, что обучение на примерах часто оказывается эффективным и целесообразным подходом. Когда организация накопит критическую массу приложений, выполненных в объектно-ориентированном стиле, станет гораздо легче обучать этому делу новых разработчиков и менеджеров. Разработчики начинают как программисты-исполнители, применяя уже готовые хорошо структурированные абстракции. Через некоторое время разработчики, которые изучали и использовали эти компоненты под наблюдением более опытных сотрудников, сами приобретают достаточный опыт, чтобы проектировать классы.
7.9. Выгоды и опасности объектно-ориентированной разработки
Выгоды
Приверженцы объектно-ориентированной технологии обычно называют два ее главных преимущества. Во-первых, большая конкурентоспособность благодаря предсказуемости, сокращению времени на разработку и большой гибкости продукта. Во-вторых, разрабатываемые задачи могут быть настолько сложными, что не остается альтернативных решений.
В главе 2 говорилось, что использование объектной модели позволяет перенести в программу пять свойств хорошо структурированных сложных систем.
Объектная модель формирует концептуальный каркас системы обозначений и процесса объектно-ориентированной разработки; таким образом, и эти выгоды мы получаем непосредственно благодаря методу. Отмечались и преимущества, вытекающие из того, что объектная модель (а значит и процесс разработки):
использует выразительную мощь объектно-ориентированных языков программирования;
стимулирует повторное использование программных компонент;
приводит к созданию более гибких, легко изменяемых систем;
сокращает риск разработки;
лучше воспринимается человеческим сознанием.
Изучение многочисленных случаев из практики подкрепляет эти выводы; особенно часто указывается на то, что объектный подход может сократить время разработки и размер кода [19, 20, 21].
Опасности
Говоря о теневой стороне объектно-ориентированной технологии, нужно рассмотреть два вопроса: производительность и начальные затраты. По сравнению с процедурными языками, объектно-ориентированные языки определенно вносят дополнительные накладные расходы на пересылку сообщения от одного объекта другому. Как указывалось в главе 3, при вызове методов, которые не найдены и не связаны статически во время компиляции, выполняемая программа должна динамически искать нужный метод по классу объекта-получателя. Исследования показывают, что, в худшем случае, на вызов метода тратится в 1.75-2.5 раза больше времени чем на обычный вызов процедуры [22, 23]. С другой стороны, наблюдения показывают, что при вызове методов динамический поиск действительно необходим примерно в 20% случаев. В строго типизированных языках компилятор часто может определять, какие вызовы могут быть связаны статически и сгенерировать для них вызов процедуры вместо динамического поиска.
Другая причина снижения производительности кроется не столько в природе объектно-ориентированных языков, сколько в способе их использования в процессе объектно-ориентированной разработки. Как говорилось уже много раз, объектно-ориентированная технология порождает многослойные системы абстракций.
Одно из следствий этого расслоения в том, что каждый метод оказывается очень маленьким, так как он строится на методах нижнего уровня. Другое следствие расслоения: иногда методы служат лишь для того, чтобы получить доступ к защищенным атрибутам объекта. В результате происходит слишком много вызовов. Вызов метода на высшем уровне абстракции обычно влечет каскад других вызовов; методы верхних уровней вызывают методы нижних уровней и т.д. Для приложений, в которых время - ограниченный ресурс, недопустимо слишком большое количество вызовов методов. Опять же, с позитивной стороны такое слоение способствует пониманию системы; к некоторым сложным системам невозможно даже подступиться, если не начать с проектирования слоев. Мы рекомендуем сначала проектировать систему с желаемыми функциональными свойствами, а потом определять узкие места. Часто их можно "расшить", объявив соответствующие методы как подстановки (выигрывая тем самым время), "подчистив" иерархию классов или нарушив инкапсуляцию атрибутов класса.
Похожий риск для потери производительности происходит из-за большого количества наследуемого кода. Класс, лежащий глубоко в структуре наследования, может иметь много суперклассов; при компоновке программы должно быть подгружено описание их всех. Для маленьких приложений это практически может означать, что нужно избегать глубоких иерархий классов, потому что они требуют чрезмерного количества объектного кода. Проблему можно несколько смягчить, используя высокоразвитые компиляторы и компоновщики, которые могли бы устранять бесполезные участки программы.
Еще один источник проблем для производительности объектно-ориентированных программ - их поведение в системе со страничной организацией памяти. Большинство компиляторов выделяет память сегментами, размещая каждый компилируемый модуль (обычно файл) в одном или более сегментах. Это подразумевает локальность большинства ссылок: процедуры в одном сегменте вызывают в основном процедуры в том же сегменте.
В больших объектно- ориентированных системах все не так. Код каждого класса размещается в отдельном файле, а раз его методы интенсивно используют методы других (особенно вышестоящих) классов, при их выполнении происходит интенсивное переключение сегментов. Это поведение противоречит тому, чего большинство компиляторов ожидает от программ, особенно в системах с конвейерным процессором и страничной организацией памяти. Это еще один пример того, почему нужно разделять логические и физические аспекты проекта. Если программа работает слишком медленно из-за чрезмерно частого переключения страниц, можно попробовать изменить физическое расположение классов в модулях. Это проектное решение, касающееся физической модели системы - на логику программы оно не повлияет.
Наконец, еще одна составляющая риска - динамическое размещение и уничтожение объектов. Динамическое выделение памяти из "кучи" сопряжено с дополнительными вычислительными расходами по сравнению с размещением данных в стеке и (конечно) статическим резервированием при компиляции. Во многих системах это не вызывает никаких практических проблем, но иногда дополнительные накладные расходы непозволительны. Существуют простые решения: откажитесь от динамического создания объектов и разместите их все заранее, или замените стандартный алгоритм выделения памяти на специально приспособленный для ваших нужд.
И опять о хорошем: положительные свойства объектно-ориентированных систем часто окупают все перечисленные выше неприятности. Например, Руссо и Каплан сообщают, что производительность программы на C++ часто бывает выше, чем ее функционального эквивалента на С. Выигрыш, как они полагают, связан с использованием виртуальных функций, благодаря которым можно сэкономить на проверке типов и опустить многие управляющие конструкции. Согласно нашему опыту, код объектно-ориентированной программы и в самом деле обычно короче.
Для некоторых проектов начальные затраты на переход к объектно-ориентированной технологии могут оказаться непреодолимыми.
Как при всякой смене технологии, придется вкладывать деньги в покупку новых инструментальных средств разработки. Кроме того, если какой-либо объектно-ориентированный язык используется организацией впервые, то нет и предметно-специфического задела для повторного использования. Короче говоря, приходится начинать все сначала или найти какой-то способ использовать существующие программы в объектно-ориентированном окружении. Наконец, первая попытка объектно-ориентированной разработки наверняка провалится, если не было проведено соответствующее обучение. Объектная ориентация - это не "еще один" язык программирования, который можно выучить на трехдневных курсах или по книжке. Как мы многократно указывали, требуется время, чтобы освоить объектно-ориентированное проектирование как новое мировоззрение, которое должно быть усвоено как разработчиками, так и менеджерами.
Выводы
Успешная разработка и внедрение сложных программных систем - это нечто больше, чем просто программирование.
Многие приемы традиционного менеджмента программных разработок, например, просмотр, применимы и в объектно-ориентированной технологии.
В стабильном состоянии объектно-ориентированные проекты требуют меньших ресурсов, а роли, необходимые для управления этими ресурсам, несколько отличаются от традиционных.
В процессе объектно-ориентированной разработки нельзя производить интеграцию всего сразу и за один раз; структурными единицами управления для релизов должны быть категории классов и подсистемы, а не отдельные файлы и классы.
Повторным использованием надо заниматься специально.
График числа обнаруженных ошибок за определенное время и плотность ошибок - полезные количественные меры качества объектно-ориентированных программ. Существует ряд полезных количественных характеристик, ориентированных на классы.
Документация никогда не должна ставиться во главу угла при разработке.
Объектно-ориентированная разработка требует иного инструментария по сравнению с традиционными методами.
Переход организации на объектно-ориентированные технологии - это смена мировоззрения, а не просто изучение нового языка программирования.
Объектно-ориентированные технологии связаны как с выгодами, так и с опасностями, но опыт показывает, что выгод много больше.
Дополнительная литература
Ван Генучтен (Van Genuchten) [H 1991] и Джоунс (Jones) [H 1992] изучали понятие риска в программном обеспечении. Об образе мышления отдельного разработчика см. работу Вейнберга (Weinberg) [J 1971, H 1988]. Абдель-Хамид и Мэдник (Abdel-Hamid and Madnick) [H 1991] изучали динамику программистских групп.
Глиб (Gilb) [H 1988] и Чаретте (Charette) [H 1989] - это основные ссылки по практике менеджмента разработки программного обеспечения. Работа Арона (Aron) [H 1974] предлагает сравнительный взгляд на управление индивидуальным программистом и командой программистов. Что практически происходит, когда прагматика выталкивает теорию в окно, см. в работах Гласса (Glass) [G 1982], Ламмерса (Lammers) [H 1986] и Хэмфри (Hamphrey) [H 1989].ДeMapко и Листер(DeMarco and Lister) [H 1987], Иордан (Yourdon) [H 1989], Реттиг (Rettig) [H 1990] и Томсет (Thomsett) [H 1990] предложили ряд рекомендаций, интересных для менеджера разработки.
Детали управления просмотром см. в работах Вейнберга и Фридмана (Weinberg and Freedman) [H 1990] и Иордана (Yourdon) [H 1989a].
Шулмейер и МакМанус (Schulmeyer and McManus) [H 1992] - прекрасная традиционная ссылка по вопросу гарантирования качества программного обеспечения. Чидамбер и Keмеpep(ChidamberandKemerer)[H 1991] и Вош(Walsh) [H 1992, 1993] изучают обеспечение качества и количественные характеристики в контексте объектно-ориентированных систем.
Советы по поводу перехода на объектную модель (как индивидуальные, так и для целых организаций) можно найти в работах Голдберга (Goldberg) [G 1978], Голдберга и Кэя (Goldberg and Kay) [G 1977] и Кемпфа (Kempf) [G 1987].
Сценарии работы
Сейчас, когда мы представили себе систему в целом, продолжим наш анализ и изучим несколько сценариев ее работы. Сначала перечислим ряд основных режимов использования:
Клиент звонит по телефону в удаленную телемаркетинговую организацию, чтобы сделать заказ.
Клиент посылает заказ по почте.
Клиент звонит, чтобы узнать состояние дел по его заказу.
Клиент звонит, чтобы добавить или убрать некоторые позиции из заказа.
Кладовщик получает указание отгрузить клиенту необходимое количество товара.
Служба доставки получает со склада заказанные клиентом товары и готовит их к отправке.
Бухгалтерия готовит счет для клиента.
Отдел закупок готовит заказ на новый товар.
Отдел закупок добавляет или удаляет имя поставщика из списка.
Отдел закупок запрашивает поставщика о состоянии заказа.
Отдел приема товара принимает груз от поставщика и проверяет его соответствие заказу.
Кладовщик заносит новый товар в список.
Бухгалтерия отмечает прибытие нового товара.
Плановый отдел генерирует отчет о показателях продаж по различным типам продуктов.
Плановый отдел генерирует отчет для налоговых органов с указанием количества товаров на складах.
Каждый из основных сценариев может включать в себя ряд вторичных:
Заказанного клиентом товара нет на складе.
Заказ клиента неверно оформлен, или в нем присутствуют несуществующие или устаревшие идентификаторы товаров.
Клиент звонит, чтобы проверить состояние заказа, но не помнит точно что, кем и когда было заказано.
Кладовщик получил расходную накладную, но некоторые перечисленные в ней товары не нашлись.
Служба доставки получает заказанные клиентом товары, но они не соответствуют заказу.
Клиент не заплатил по счету.
Отдел закупок делает новый заказ, но поставщик либо ушел из бизнеса, либо больше не поставляет заказанный тип товара.
Отдел приема товара принимает груз, не полностью соответствующий заказу.
Кладовщик хочет разместить на складе новый товар, но обнаруживается, что для него нет места.
Изменяются налоговые коды, что вынуждает плановый отдел составить новый инвентаризационный список находящихся на складе товаров.
Для системы такой сложности, наверно, будут выявлены десятки основных сценариев и еще большее количество вторичных. Этот этап анализа может занять несколько недель, пока не удастся добиться более или менее подробного уровня детализации [Но помните о параличе анализа: если фаза анализа не укладывается в сроки, диктуемые бизнесом, то "оставь надежду всяк сюда входящий", - этот бизнес не для вас]. Поэтому мы настоятельно советуем применять правило восьмидесяти процентов: не ждите, пока сформируется полный список всех сценариев (никакого времени на это не хватит), изучите около 80% наиболее интересных из них и, если возможно, попытайтесь хотя бы оценочно проверить правильность общей концепции. В этой главе мы подробно остановимся на двух основных сценариях.
На рис. 10-2 представлен сценарий, в котором покупатель размещает свой заказ в телемаркетинговой фирме. В выполнении этой системной функции задействовано несколько различных объектов. И хотя управление осуществляется взаимодействием клиента (aCustomer) с агентом (anAgent), есть и другие ключевые объекты, а именно: сведения о клиенте (aCustomerRecord), база данных о товарах (inventoryDatabase) и заявка на комплектование (aPackingOrder), являющиеся абстракциями системы складского учета. Этот список абстракций формируется как раз на этапе рассмотрения сценариев работы.
Рис. 10-2. Сценарий заказа.
Рис. 10-3. Сценарий выполнения заказа.
Рис. 10-3 отражает продолжение данного сценария. На нем представлена схема взаимодействия кладовщика и расходной накладной. Мы видим, что здесь кладовщик является главной фигурой. Он взаимодействует с другими объектами, например с отгрузкой (shipping), которой не было в предыдущем сценарии. Однако большинство объектов, фигурирующих на рис. 10-3, присутствуют также и на рис. 10-2, хотя они играют в этих сценариях различные роли. Например, в сценарии взаимодействия с клиентом мы создаем заказ (anOrder) как документ, в котором отражены требования клиента. В складском сценарии тот же самый заказ исполняется.
При составлении каждого из таких сценариев мы должны постоянно задавать себе ряд вопросов. Какой объект будет нести ответственность за выполнение того или иного действия? Как объект будет проводить ту или иную операцию: самостоятельно или используя свойства другого объекта? Не слишком ли много операций вменяется в круг обязанностей данного объекта? Что произойдет при ошибке в ходе выполнения сценария (какие постусловия могут нарушиться)? Что случится, если будут нарушены некоторые предусловия?
Занимаясь подобным антропоморфизмом для каждого функционального свойства системы, мы откроем в системе целый ряд интересных объектов высокого уровня. Сначала перечислим лиц, взаимодействующих с системой:
Customer - клиент
Supplier - поставщик
OrderAgent - сотрудник отдела продаж
Accountant - бухгалтер
ShippingAgent - сотрудник отдела отгрузки
Stockperson - кладовщик
PurchasingAgent - сотрудник отдела закупок
ReceivingAgent - сотрудник отдела приема товаров
Planner - сотрудник планового отдела
Для нас очень важно выявить эти категории лиц: каждой из них соответствует своя отдельная роль в сценариях. Если мы хотим отслеживать, когда и почему произошли определенные события внутри системы и кто стал их причиной, то необходимо формализовать роли всех пользователей. Например, при рассмотрении жалобы нам возможно придется выяснить, кто вел переговоры с недовольным клиентом. Кроме того, нам понадобится эта классификация при разработке механизма ограничения доступа к различным частям системы для различных групп пользователей. В открытой системе централизованный контроль вполне эффективен и неизбежен: он уменьшает риск случайного или целенаправленного неправильного использования.
В результате анализа был выделен ряд ключевых абстракций, каждая из которых представляет собой определенный тип информации в системе:
CustomerRecord - информация о клиенте
ProductRecord - информация о товаре
SupplierRecord - информация о поставщике
Order - заказ от клиента
PurchaseOrder - заказ поставщику
Invoice - счет
PackingOrder - расходная накладная
StockingOrder - приходная накладная
ShippingLabel - документ на отгрузку
Классы CustomerRecord, ProductRecord и SupplierRecord связаны соответственно с абстракциями Customer, Product и Supplier. Мы, однако разделили эти два типа абстракций, так как они будут играть несколько разные роли.
Заметим, что существуют два вида счетов: те, которые посылаются компанией клиентам для оплаты заказанного товара, и те, которые компания получает от поставщиков товаров. Не отличаясь ничем по своей структуре, они, тем не менее, играют совершенно разные роли в системе.
По классам PackingOrder и StockingOrder потребуются некоторые дополнительные разъяснения. В соответствии с первыми двумя сценариями, после того, как сотрудник отдела продаж (OrderAgent) принимает заказ (order) от клиента (Customer), он должен дать указание кладовщику (StockPerson) на выдачу заказанного товара. В нашей системе соответствующая транзакция связана с объектом класса PackingOrder (расходная накладная). Этот класс ответственен за сбор всей информации, касающейся выписки расходной накладной по данному заказу. На операционном уровне это означает, что наша система формирует, а затем передает заказ на переносной компьютер одного из свободных в данный момент кладовщиков. Такая информация должна, как минимум, включать в себя идентификационный номер заказа, наименование и количество каждого из товаров. Нетрудно догадаться, как можно намного улучшить данный сценарий: наша система в состоянии передать кладовщику местоположение товаров, и, возможно, даже примерную последовательность вывоза их со склада, обеспечивающую максимальную эффективность этой операции [Конечно, в общем случае это известная задача о бродячем торговце, которая как известно, NP-полная. Однако, можно существенно ограничить задачу так, чтобы получались приемлемые решения. На самом деле, правила перевозки могут предписывать некое частичное упорядочение: сначала класть тяжелые грузы, потом легкие.
Желательно также группировать грузы по типу: штаны с рубашками, молотки с гвоздями, колеса с шинами (мы предупредили, что речь идет об общецелевой системе учета!)]. В нашей системе достаточно информации, чтобы обеспечить помощь недавно принятому на работу кладовщику - например, дать ему возможность вывести на экран своего переносного компьютера изображение внешнего вида того или иного товара. Такая поддержка может пригодиться и опытному кладовщику на период смены ассортимента товаров.
Рис. 10-4. Ключевые классы при приеме и выполнении заказа.
Рис. 10-4 содержит диаграмму классов, которая отражает наше понимание процесса взаимодействия некоторых из перечисленных абстракций в сценарии приема и выполнения заказа. Мы дополнили эту диаграмму некоторыми украшениями атрибутов, играющих важную роль в функционировании каждого из классов.
Основные мотивы введения именно такой структуры классов связаны с учетом перехода между экземплярами классов. Получив заказ, мы бы хотели, в частности, сформировать маркер, обозначающий клиента, сделавшего заказ; для этого необходимо перейти от экземпляра класса заказа (order) обратно к клиенту (customer). Получив расходную накладную, надо возвратиться к клиенту и к сотруднику отдела продаж для передачи информации об отгрузке; это означает, что нам потребуется перейти от расходной накладной к заказу, и затем от него - к клиенту и сотруднику отдела продаж. Что касается клиента, то желательно знать, какие товары он чаще всего заказывает в то или иное время года. Для выполнении такого запроса необходимо вернуться от клиента ко всем предыдущим его заказам.
Стоит подробнее остановиться еще на некоторых деталях диаграммы. Почему между классом Order и классом PackingOrder существует отношение 1:N (один ко многим)? По нашим бизнес-правилам каждая расходная накладная может соответствовать одному и только одному заказу. Однако обратное неверно. Предположим, например, что некоторые позиции, указанные в заказе, на данный момент отсутствуют на складе.
Тогда мы должны будем дополнительно отгрузить их по второй расходной накладной, когда товар появится в наличии.
Отметим ограничение на связь между объектами StockPerson и PackingOrder: сохранение контроля за качеством работы требует, чтобы кладовщик одновременно обслуживал не более одного заказа.
Завершая данный этап анализа, введем еще два ключевых класса:
Report - отчет
Transaction - транзакция
Мы ввели абстракцию Report для обозначения базового класса, объединяющего все различные типы печатных документов и пользовательских запросов, При детальном анализе всех сценариев может выясниться много конкретных типов документов, но, ввиду открытости нашей системы, будет лучше выработать общий механизм генерации отчетов, позволяющий без труда добавлять новые типы отчетов. Действительно, выделив общие для всех отчетов свойства, мы сможем наделить их общим поведением и структурой, что позволит придать соответствующим элементам системы стандартизованный вид и облегчит для конечного пользователя работу с системой.
Наш список далеко не полон, но у нас накопилось достаточно информации для перехода к разработке архитектуры системы. Однако, до того, необходимо рассмотреть некоторые принципы, влияющие на организацию структур данных внутри программы.
Семантика времени и памяти
Из пяти основных принципов строения библиотеки базовых классов, возможно, наиболее важен механизм, обеспечивающий клиента альтернативной простанственно-временной семантикой внутри каждого семейства классов.
Рассмотрим тот спектр требований, который должен учитываться при разработке библиотеки общего назначения. На рабочей станции, обладающей большим виртуальным адресным пространством, пользователь скорее всего будет расточать память ради более высокого быстродействия. С другой стороны, в некоторых встроенных системах, таких, как спутник или автомобильный мотор, ресурсы памяти часто ограничены, и разработчик вынужден выбирать в качестве рабочих те абстракции, которые используют меньше памяти (например, выделяя место под данные в стеке, а не в "куче"). Ранее мы различили эти две возможности как ограниченную и неограниченную формы соответственно.
Неограниченные формы применимы в тех случаях, когда размер структуры не может быть предсказан, а выделение и утилизация памяти из кучи не приводит ни к потере времени, ни к снижению надежности (как это бывает в некоторых приложениях, критичных по времени) [Некоторые требования к системе могут запретить использование динамически распределяемой памяти. Рассмотрим сердечный импульсный регулятор и возможные фатальные результаты, которые может вызвать сборщик мусора, "проснувшийся" в неподходящий момент. Есть системы с длительным рабочим циклом: в них даже минимальная утечка памяти может дать серьезный кумулятивный эффект; вынужденная перезагрузка системы из-за недостатка памяти может привести к неприемлемой потере функциональности]. Ограниченные формы лучше подходят для работы с небольшими структурами, размер которых достаточно хорошо предсказуем. Учтем также, что динамическое выделение памяти менее терпимо к ошибкам программиста.
Таким образом, все структуры данной библиотеки должны присутствовать в альтернативных вариантах; поэтому нам придется создать два низкоуровневых класса поддержки, Unbounded (неограниченный) и Bounded (ограниченный).
Задачей класса unbounded является поддержка быстро работающего связного списка, элементы которого размещаются в памяти, выделенной из "кучи". Это представление эффективно по скорости, но не по памяти, так как каждый элемент списка должен, кроме своего значения, дополнительно содержать указатель на следующий элемент того же списка. Задача класса Bounded состоит в организации структур на базе массива, что эффективно с точки зрения памяти, но добиться большой производительности трудно, так как, например, при добавлении элемента в середину списка приходится последовательно копировать все последующие (или предыдущие) элементы массива.
Как видно из рис. 9-4, для включения этих классов нижнего уровня в иерархию основных абстракций мы используем агрегацию. Более точно, диаграмма показывает, что мы используем физическое включение по значению с защищенным Доступом, которое означает, что это низкоуровневое представление доступно только подклассам и друзьям. На раннем этапе проектирования мы хотели воспользоваться примесями и сделать unbounded и Bounded защищенными суперклассами.
Рис. 9-4. Ограниченная и неограниченная формы.
Мы в конце концов отказались от такого варианта, так как он достаточно труден для понимания, и к тому же нарушает лакмусов принцип наследования: BoundedQueue, по крайней мере, с точки зрения типа данных, не является частным случаем класса Bounded.
Отметим также, что работа с двумя формами требует присутствия второго аргумента в их шаблоне. Для ограниченной формы - это беззнаковое целое число Size, обозначающее статический размер объекта. Для неограниченной формы - это класс StorageManager, ответственный за политику размещения в памяти. Мы рассмотрим его работу в следующем разделе.
Протокол обоих классов поддержки должен быть, с одной стороны, достаточным для обеспечения работы конкретных подклассов, а с другой стороны, универсальным, чтобы гарантировать выполнение ответственности всех других структур в библиотеке. В целях компактности и быстродействия мы не включили в описание классов Unbounded и Bounded ни одной виртуальной функции.
По этой причине мы не можем объединить их одним суперклассом, несмотря на то, что они имеют общий протокол; кроме того, мы не можем надлежащим образом построить на их базе иерархию подклассов. В данном случае гибкость приносится в жертву производительности. По той же причине мы решаем сделать ряд функций встроенными; хорошими кандидатами на это обычно являются селекторы, особенно те, которые возвращают простые переменные.
Рассмотрим, например, описание класса Bounded:
template<class Item, unsigned int Size>
class Bounded {
public: Bounded();
Bounded(const Bounded<Item, Size>&);
~Bounded();
Bounded<Item, Size>& operator=(const Bounded<Item, Size>&);
int operator==(const Bounded<Item, Size>&) const;
int operator!=(const Bounded<Item, Size>&) const;
const Item& operator[](unsigned int index) const;
Item& operator[](unsigned int index);
void clear();
void insert(const Item&);
void insert(const Item&, unsigned int before);
void append(const Item&);
void append(const Item&, unsigned int after);
void remove(unsigned int at);
void replace(unsigned int at, const Item&);
unsigned int available() const;
unsigned int length() const;
const Item& first() const;
const Item& last() const;
const Item& itemAt(unsigned int) const;
Item& itemAt(unsigned int);
int location(const Item&) const;
static void* operator new(size_t);
static void operator delete(void*, size_t);
protected: Item rep[Size];
unsigned int start;
unsigned int stop;
unsigned int expandLeft(unsigned int from);
unsigned int expandRight(unsigned int from);
void shrinkLeft(unsigned int from);
void shrinkRight(unsigned int from);
};
Объявление класса следует схеме, описанной ранее. Каким образом мы пришли именно к такому решению? Если честно, то на 80% это результат чистого проектирования классов, которое рассматривалось в главе 6. Затем интерфейс дорабатывался в соответствии с результатами пробного использования класса совместно с рядом основных абстракций системы.
Основная трудность при эволюции состояла в идентификации подходящих примитивных операций, которые должны использоваться при работе с набором различных структур.
Сердцем класса является защищенный массив rep постоянного размера Size. Рассмотрим следующее объявление:
Bounded<char, 100U> charSequence;
При создании соответствующего объекта в стеке образуется массив постоянного размера из 100 элементов. Защищенные члены класса start и stop (индексы в этом массиве) указывают начало и конец последовательности. Тем самым мы использовали кольцевой буфер данных. Добавление нового элемента в начало или в конец последовательности не потребует перемещения данных, а добавление элемента в середину массива приводит к копированию не более чем половины его элементов.
Проектирование ограниченного и неограниченного классов поддержки затрагивает также некоторые тонкие вопросы, касающиеся использования ссылок (мы упоминали о них в главе 3). Нам придется еще раз коснуться этой темы, и не только потому, что она имеет прямое отношение к разработке интерфейса параметризованных классов, но и потому, что данные вопросы сами по себе представляют значительный интерес для проектировщика любой более или менее нетривиальной библиотеки.
В C++ ссылки являются механизмом, позволяющим улучшить производительность. Однако пользоваться ими следует предельно осторожно во избежание нарушения корректного доступа к оперативной памяти. В данной библиотеке мы используем ссылки для ускорения работы при передаче аргументов функциям-членам. Это касается, например, класса Bounded, где подобным образом передаются ссылки на объекты классов Bounded и Item. Ссылки, как правило, не используются для передачи примитивных объектов (например, целых чисел в описании функции-члена itemAt) - программа от этого будет работать только медленнее. Кроме того, семантика языка C++ порождает некоторые опасности при манипулировании с временными объектами.
Все наши структуры, однако, содержат в качестве элементов не ссылки, а значения, что исключает возникновение ссылок на временные объекты в стеке при работе программы.
По той же причине мы отказались от хранения указателей на элементы структур, так как это вызывает крайне нестабильное поведение системы при инстанцировании шаблона встроенными типами данных. Подобные вопросы чрезвычайно существенны при проектировании сред разработки, включающих в себя параметризованные классы, так как пользователь может инстанцировать шаблон произвольным типом данных. При использовании ссылок существуют, вообще говоря, три случая, и нам придется при создании библиотеки постараться найти определенный баланс между ними.
Во-первых, встроенные типы данных можно без труда передавать по ссылке и копировать. Объявив типы аргумента постоянными ссылками, можно избежать неприятностей, связанных с появлением временных структур, возникающих при приведении типов [12].
Во-вторых, типы данных, определенные пользователем, также можно передавать по ссылке и копировать, но только в том случае, когда для них определены копирующий конструктор и оператор присваивания. Ссылки можно использовать в полиморфных операциях (передавая объект производного класса вместо объявленного при инстанцировании), но копирование не будет полиморфным. В результате объект будет "срезан" до размеров своего базового класса.
В-третьих, при полиморфном использовании библиотеки встретится инстанцирование шаблонов указателями на базовый класс. Хотя передача указателей по ссылке может и не улучшить производительность, но копирование указателей в представление сохраняет полиморфизм производных объектов.
Например, для класса BoundedQueue мы можем написать следующее:
class Event ... typedef Event* EventPtr;
BoundedQueue<int, 10U> intQueue;
BoundedQueue<Event, 50U> eventQueue1;
BoundedQueue<EventPtr, 100U> eventQueue2;
С помощью объекта класса eventQueue1 можно спокойно создавать очереди событий, однако при добавлении в очередь экземпляра любого подкласса Event произойдет "срезка", и полиморфное поведение такого экземпляра будет потеряно. С другой стороны, объект класса eventQueue2 содержит указатели на объекты класса Event, поэтому проблема "срезки" не возникает.
Наше решение, касающееся хранения внутри структур значений, а не ссылок, предъявляет определенные требования к конструкторам и деструкторам элементов. В частности, классы, используемые для инстанцирования структуры, должны, по крайней мере, иметь конструктор по умолчанию, копирующий конструктор и оператор присваивания. Кроме того, в некоторых случаях элементы не могут быть уничтожены сразу после удаления из структуры. В ограниченной форме, например, элементы (хранящиеся в массивах) не уничтожаются до уничтожения всей структуры.
Посмотрим, как можно использовать класс Bounded при формировании конкретного класса BoundedQueue. Отметим, что абстракция BoundedQueue содержит защищенный элемент rep класса Bounded.
template<class Item, unsigned int Size>
class BoundedQueue : public Queue<Item> {
public: BoundedQueue();
BoundedQueue(const BoundedQueue<Item, Size>&);
virtual ~BoundedQueue();
virtual Queue<Item>& operator=(const Queue<Item>&);
virtual Queue<Item>& operator=(const BoundedQueue<Item,
Size>&);
virtual int operator==(const Queue<Item>&) const;
virtual int operator=(const BoundedQueue<Item, Size>&) const;
int operator!=(const BoundedQueue< Item, Size>&) const;
virtual void clear();
virtual void append(const Item&);
virtual void pop();
virtual void remove(unsigned int at);
virtual unsigned int available() const;
virtual unsigned int length() const;
virtual int isEmpty() const;
virtual const Item& front() const;
virtual int location(const Item&) const;
protected: Bounded<Item, Size> rep;
virtual void purge();
virtual void add(const Item&);
virtual unsigned int cardinality() const;
virtual const Item& itemAt(unsigned int) const;
static void* operator new(size_t);
static void operator delete(void*, size_t);
};
Основная задача данного класса - завершить протокол, определенный в базовом классе. Часто это означает немного больше, чем простая передача обязанности классу более низкого уровня Bounded, как предлагается в следующей реализации:
template<class Item, unsigned int Size>
unsigned int BoundedQueue<Item, Size>::length() const
{
return rep.length();
}
Отметим, что в описание класса BoundedQueue включены некоторые дополнительные операции, которых нет в его суперклассе. Добавлен селектор available, возвращающий количество свободных элементов в структуре (вычисляется как разность Size - length()). Эта операция не включена в описание базового класса главным образом из-за того, что для неограниченной модели вычисление свободного места не очень осмысленно. Мы также переопределили оператор присваивания и проверку равенства. Как уже отмечалось ранее, это позволяет применить более эффективные алгоритмы по сравнению с базовым классом, так как подклассы лучше знают, что и как делать. Добавленные операторы new и delete определены в защищенной части класса, чтобы лишить клиентов возможности произвольно динамически размещать экземпляры BoundedQueue (что согласуется со статической семантикой этой конкретной формы).
Класс Unbounded имеет, в существенном, тот же протокол, что и класс Bounded, однако его реализация совершенно другая.
template<class Item, class StorageManager>
class Unbounded {
public:
...
protected: Node<Item, StorageManager>* rep;
Node<Item, StorageManager>* last;
unsigned int size;
Node<Item, StorageManager>* cache;
unsigned int cacheIndex;
};
Форма Unbounded реализует очередь как связный список узлов, где узел (Node) реализован следующим образом:
template<class Item, class StorageManager>
class Node {
public: Node(const Item& i,
Node<Item, StorageManager>* previous, Node<Item, StorageManager>* next);
Item item;
Node<Item, StorageManager>* previous;
Node<Item, StorageManager>* next;
static void* operator new(size_t);
static void operator delete(void*, size_t);
};
Основная задача этого класса - управлять одним элементом списка и указателями на предыдущий и следующий узлы. Данная абстракция отнесена к категории классов поддержки, к ней не имеют доступ внешние пользователи, и поэтому мы решили несколько ослабить наши традиционные строгие требования к инкапсуляции, сделав все элементы класса открытыми и жертвуя таким образом безопасностью ради эффективности.
Помня, что классы Bounded и Unbounded имеют практически идентичный внешний протокол, а, значит, их функциональные свойства во многом подобны, можно предположить, что и реализация будет схожей. Однако различие во внутреннем представлении классов приводит к существенно различной пространственно-временной семантике. Манипуляции с узлами связанного списка, например, осуществляются очень быстро, однако процедура нахождения нужного элемента будет занимать время порядка O(n). Поэтому наше представление кэширует последний узел, к которому было обращение, в надежде, что следующее обращение будет либо к этому же узлу, либо к его соседям. Схема же, базирующаяся на массивах, дает низкое быстродействие (в худшем случае порядка O(n/2) если элемент расположен в середине массива) при добавлении или удалении элементов, однако обеспечивает высокую скорость поиска (порядка O(1)).
Семейства классов
Третий основной принцип проектирования библиотеки заключается в построении семейств классов, связанных отношением наследования. Для каждого типа структур мы создадим несколько различных классов, объединенных единым интерфейсом (как в случае с абстрактным классом Queue), но с разными конкретными подклассами, имеющими несколько различные представления и поэтому отличающимися Своим устройством и характеристиками "время/память". Таким образом мы обеспечим библиотечное требование полноты. Разработчик сможет выбрать тот конкретный класс, который в большей степени подходит для решения его задачи. В то же время этот класс обладает тем же интерфейсом, что и остальные классы семейства. Сознательное четкое разделение абстрактного базового класса и его конкретных подклассов позволяет пользователю системы выбрать, скажем, на первом этапе проектирования один из классов в качестве рабочего, а затем, в процессе доводки приложения, заменить его на другой, чем-то отличающийся класс того же семейства, затратив на это минимум времени и усилий (единственное, что ему потребуется, - это заново оттранслировать свою программу). При этом разработчик будет уверен в нормальном функционировании программы, так как все классы, принадлежащие одному семейству, обладают идентичным внешним интерфейсом и схожим поведением. Смысл в такой организации классов состоит еще и в возможности копирования, присваивания и сравнения объектов одного семейства даже в том случае, если их представления совершенно разнятся.
Можно сказать, что базовый абстрактный класс как бы содержит в себе все важные черты абстракции. Другое важное применение абстрактных базовых классов - это кэширование общего состояния, которое дорого вычислять заново. Так можно перевести вычисление O(n) в операцию порядка O(1) - простое считывание данных. При этом, естественно, требуется обеспечить соответствующий механизм взаимодействия между абстрактным базовым классом и его подклассами, чтобы гарантировать актуальность кэшируемого значения.
Элементы семейства классов представляют собой различные формы абстракции. Опыт показывает, что существуют две основные формы абстракций, которыми следует пользоваться разработчику при создании серьезных приложений. Во-первых, это форма конкретного представления абстракции в оперативной памяти машины. Существует два варианта такого представления: выделение памяти для структуры из стека или выделение оперативной памяти из кучи. Им соответствуют две формы абстракций: ограниченная и неограниченная:
• Ограниченная | Структура хранится в стеке и, таким образом, имеет статический размер (известный в момент создания объекта). |
• Неограниченная | Структура хранится в куче и ее размеры могут динамически изменяться. |
Так как ограниченная и неограниченная формы абстракции имеют общие интерфейс и поведение, их обе можно представить в виде прямых подклассов абстрактного базового класса для каждой структуры. Мы обсудим эти и другие особенности организации данных в следующих разделах.
Второй вариант связан с синхронизацией. Как было отмечено в главе 2, множество полезных приложений обходятся одним процессом. Их называют последовательными системами, потому что они используют один поток управления. Для других приложений (особенно это касается систем реального времени) требуется обеспечить синхронизацию нескольких одновременно выполняемых потоков. Такие системы называются параллельными, и в них каким-то образом должно обеспечиваться взаимное исключение процессов, конкурирующих за один и тот же ресурс. Ясно, что нельзя дать возможность управлять одним и тем же объектом одновременно нескольким потокам, это в конце концов приведет к нарушению его состояния. Рассмотрим, например, поведение двух агентов, которые одновременно пытаются добавить элемент одному и тому же объекту класса Queue. Первый агент, начавший добавление элемента, может быть прерван раньше, чем окончит данную операцию, и оставит объект второму агенту в незавершенном состоянии.
Рис. 9-3. Семейства классов.
Как отмечалось в главе 3, в данном случае при проектировании существуют всего три возможных альтернативы, каждая из которых требует обеспечения различного уровня взаимодействия между агентами, оперирующими с общими объектами:
последовательный;
защищенный;
синхронизированный.
Мы рассмотрим каждый из этих вариантов более подробно в следующем разделе. Обеспечение взаимодействия между абстрактным базовым классом, формами его представления и формами синхронизации порождает для каждой структуры семейство классов, подобное тому, которое приведено на рис. 9-3. Теперь можно понять, почему мы в свое время решили организовать библиотеку именно в виде семейств классов, а не в виде единого дерева. Это было сделано из-за того, что такая архитектура:
Отражает общность различных форм.
Позволяет осуществлять более простой доступ к элементам библиотеки.
Позволяет избежать бесконечных метафизических споров о "чистом объектно-ориентированном подходе".
Упрощает интеграцию системы с другими библиотеками.
Микроорганизация
В целях обеспечения простоты работы с системой выберем один общий стиль оформления структур и механизмов библиотеки:
template<...>
class Name : public Superclass {
public: // конструкторы
// виртуальный деструктор
// операторы
// модификаторы
// селекторы
protected: // данные
// функции
private: // друзья
};
Описание абстрактного базового класса Queue начинается следующим образом:
template<class Item> class Queue {
Сигнатура шаблона template служит для задания аргументов параметризованного класса. Отметим, что в C++ шаблоны сознательно введены таким образом, чтобы передать достаточную гибкость (и ответственность) в руки разработчика, инстанцирующего шаблон в своем приложении.
Далее определим обычный список конструкторов и деструкторов:
Queue();
Queue(const Queue<Item>&);
virtual ~Queue();
Отметим, что мы описали деструктор виртуальным, чтобы обеспечить полиморфное поведение при уничтожении объектов класса. Далее объявим все операторы:
virtual Queue<Item>& operator=(const Queue<Item>&);
virtual int operator==(const Queue<Item>&) const;
int operator!=(const Queue<Item>&) const;
Мы определили оператор присваивания (operator==) и оператор сравнения (operator==) как виртуальные для того, чтобы обеспечить безопасность типов. Переопределение этих операторов входит в обязанности подклассов. В них будут использоваться функции, аргументом которых является объект собственного специализированного класса. В этом смысле подклассы имеют то преимущество, что они знают представление своих экземпляров и могут обеспечить очень эффективную реализацию. Когда конкретный подкласс очереди неизвестен (например, если мы передаем объект по ссылке на его базовый класс), вызывается оператор базового класса, использующий может быть менее эффективные, но более универсальные алгоритмы. Эта идиома имеет побочный эффект: возможность работы одной и той же функции с очередями, имеющими различную внутреннюю реализацию, без нарушения типизации.
Если мы хотим ограничить доступ к копированию, присваиванию или сравнению некоторых объектов, нам надо объявить эти операторы защищенными или закрытыми.
Определим теперь модификаторы, позволяющие менять состояние объекта:
virtual void clear() = 0;
virtual void append(const Item&) = 0;
virtual void pop() =0;
virtual void remove (unsigned int at) = 0;
Данные операции объявлены как чисто виртуальные, а это значит, что их описание является обязанностью подклассов. Наличие чисто виртуальных функций делает класс Queue абстрактным.
Спецификатор const указывает (компилятор может это проверить) на использование функций-селекторов, то есть функций, предназначенных исключительно для получения информации о состоянии объекта, но не для изменения состояния:
virtual unsigned int length() const = 0;
virtual int isEmpty() const = 0;
virtual const Item& front() const =0;
virtual int location(const Item&) const = 0;
Эти операции тоже определены как чисто виртуальные, потому что класс Queue не обладает достаточной информацией для их полного описания.
Защищенная часть каждого класса начинается с описания тех элементов, которые формируют основу его структуры и должны быть доступны подклассам [Всюду, где веские причины не заставляют нас действовать по-другому, мы объявляем элементы класса закрытыми. Здесь, однако, существует веская причина объявить эти фрагменты защищенными: доступ к ним потребуется подклассам]. Абстрактный класс Queue, в. отличие от своих подклассов (см. ниже), подобных элементов не имеет.
Продолжит защищенную часть базового класса определение служебных функций, которые будут полиморфно реализованы в конкретных подклассах. Класс Queue содержит довольно типичный список таких функций:
virtual void purge() = 0;
virtual void add(const Item&) = 0;
virtual unsigned int cardinality() const = 0;
virtual const Item& itemAt (unsigned int) const = 0;
virtual void lock();
virtual void unlock();
Причины, по которым мы ввели именно эти функции, будут рассмотрены в следующем разделе.
И, наконец, определим закрытую часть, обычно содержащую объявления о классах-друзьях и те элементы, которые мы хотим сделать недоступными даже для подклассов. Класс Queue содержит только декларации о друзьях:
friend class QueueActiveIterator<Item>;
friend class QueuePassiveIterator<Item>;
Как мы увидим в дальнейшем, эти объявления друзей понадобятся для поддержки идиом итератора.
Синхронизация
При разработке любого универсального инструментального средства должны учитываться проблемы, связанные с организацией параллельных процессов. В операционных системах типа UNIX, OS/2 и Windows NT приложения могут запускать несколько "легких" процессов ["Легким" называется процесс, который исполняется в том же адресном пространстве, что и другие. В противоположность им существуют "тяжелые" процессы; их создает, например, функция fork в UNIX. Тяжелые процессы требуют специальной поддержки операционной системы для организации связи между собой. Для C++ библиотека AT&T предлагает "полупереносимую" абстракцию легких процессов для UNIX. Легкие процессы непосредственно доступны в OS/2 и Windows NT. В библиотеку классов Smalltalk включен класс Process, реализующий поддержку легких процессов]. В большинстве случаев классы просто не смогут работать в такой среде без специальной доработки: когда две задачи взаимодействуют с одним и тем же объектом, они должны делать это согласованно, чтобы не разрушить состояния объекта. Как уже отмечалось, существуют два подхода к задаче управления процессами; они находят свое отражение в существовании защищенной и синхронизированной форм класса.
При разработке данной библиотеки было сделано следующее предположение: разработчики, планирующие использовать параллельные процессы, должны импортировать либо разработать сами по крайней мере класс Semaphore (семафор) для синхронизации легких процессов. Разработчики, которые не хотят связываться с параллельными процессами, будут свободны от необходимости поддерживать защищенные или синхронизованные формы классов (таким образом, не потребуется никаких дополнительных издержек). Защищенные и синхронизированные формы изолированы в библиотеке и основываются на своей внутренней реализации параллелизма. Единственная зависимость от локальной реализации сосредоточена в классе Semaphore, который имеет следующий интерфейс:
class Semaphore {
public:
Semaphore();
Semaphore(const Semaphore&);
Semaphore(unsigned int count);
~Semaphore();
void seize(); // захватить
void release(); // освободить
unsigned int nonePending() const;
protected:
};
Так же, как и при управлении памятью, мы разделяем политику синхронизации процессов и ее реализацию. По этой причине в аргументы шаблона для каждой защищенной формы включен класс Guard (страж), ответственный за связь с локальной реализацией класса Semaphore или его эквивалента. Аргументы шаблона для каждой из синхронизированных форм содержат класс Monitor, который близок по своим функциональным свойствам к классу Semaphore, но, как будет видно в дальнейшем, обеспечивает более высокий уровень параллелизма процессов.
Как показано на рис. 9-3, защищенный класс является прямым подклассом своего конкретного ограниченного либо неограниченного класса и содержит в себе объект класса Guard. Все защищенные классы имеют общедоступные функции-члены seize (захватить) и release (освободить), позволяющие получить эксклюзивный доступ к объекту. Рассмотрим в качестве примера класс GuardedUnboundedQueue, производный от UnboundedQueue:
template<class Item, class StorageManager, class Guard>
class GuardedUnboundedQueue : public UnboundedQueue<Item, StorageManager> {
public: GuardedUnboundedQueue();
virtual ~GuardedUnboundedQueue();
virtual void seize();
virtual void release();
protected: Guard guard;
};
В нашей библиотеке предусмотрен интерфейс одного из предопределенных классов защиты: класса semaphore. Пользователи могут дополнить реализацию данного класса в соответствии с локальным определением легкого процесса.
На рис. 9-10 приведена схема работы данного варианта синхронизации; клиенты, использующие защищенные объекты, должны придерживаться простого алгоритма: сначала захватить объект для эксклюзивного доступа, провести над ним нужную работу, и после ее окончания снять защиту (в том числе в тех случаях, когда возникла исключительная ситуация). Другая схема поведения рассматривается как социально неприемлемая, поскольку претензии одного агента не позволят правильно работать другим.
Если мы, например, не снимем защиту после окончания работы с объектом, больше никто не сможет получить к нему доступ; попытка снятия защиты с объекта, к которому в данный момент никто не имел эксклюзивного доступа, также может привести к нежелательным последствиям. Игнорирование этого протокола просто безответственно, поскольку оно может разрушить состояние объекта, с которым одновременно работают несколько агентов.
Рис. 9-10. Процессы защищенного механизма.
Основное преимущество защищенной схемы - ее простота. В то же время для агентов, производящих операции над одним и тем же объектом, использование данной модели обуславливает необходимость выполнения определенных коллективных действий. Другая особенность защищенных форм состоит в том, что она дает возможность агентам выделять критически важные моменты, когда несколько операций, произведенных над объектом, будут гарантированно интерпретироваться как одна атомарная транзакция.
Подобно механизму управления памятью, сигнатура шаблона защищенной формы импортирует стража, а не превращает его в неизменяемую характеристику. Это позволяет пользователям ввести новую политику синхронизации. При использовании в качестве стража предопределенного класса Semaphore, стандартная политика синхронизации подразумевает, что каждому объекту ставится в соответствие свой семафор. Данное решение приемлемо только до тех пор, пока количество параллельных процессов не достигнет некоторого критического значения.
Альтернативный подход подразумевает возможность обслуживания одним семафором сразу нескольких защищенных объектов. Разработчику при этом нужно только создать новый класс-страж, имеющий тот же протокол, что и semaphore (но не обязательно являющийся его подклассом). Этот класс может содержать семафор в качестве статического члена; тогда семафор будет совместно использоваться всеми экземплярами класса. Инстанцируя защищенную форму с этим новым стражем, разработчик библиотеки вводит новую политику, поскольку все объекты инстанцированного класса пользуются общим стражем, вместо выделения отдельного стража каждому объекту.
Преимущество данной схемы наиболее ясно проявляется, когда новый класс-страж используется для инстанцирования других структур: все полученные объекты будут работать с одним и тем же стражем. Таким образом, на первый взгляд незначительное изменение политики приводит не только к уменьшению количества параллельных процессов, но также позволяет клиенту блокировать целую группу объектов, несвязанных напрямую. Захват одного объекта автоматически блокирует доступ и ко всем остальным структурам, имеющим того же стража, даже если это структуры различного типа.
Синхронизированный класс, являясь прямым подклассом какого-либо конкретного ограниченного или неограниченного класса, содержит в себе объект-монитор, протокол которого можно описать следующим абстрактным базовым классом:
class Monitor {
public: Monitor();
Monitor(const Monitor&);
virtual ~Monitor();
virtual void seizeForReading() = 0;
virtual void seizeForWriting() = 0;
virtual void releaseFromBeadingt() = 0;
virtual void releaseFromWritingt() = 0;
protected:
...
};
С помощью мониторов можно реализовать два типа синхронизации:
• Одиночная | Гарантирует семантику структуры в присутствии нескольких потоков управления, но с одним читающим или одним записывающим. |
• Множественная | Гарантирует семантику структуры в присутствии нескольких потоков управления, с несколькими читающими или одним записывающим. |
Агент записи меняет состояние объекта; агенты записи вызывают функции-модификаторы. Агент чтения сохраняет состояние объекта; он вызывает только функции-селекторы. Как видно, множественная форма синхронизации обеспечивает наивысшую степень параллелизма процессов. Мы можем реализовать обе политики в виде подклассов абстрактного базового класса Monitor. Обе формы можно построить на основе класса Semaphore.
В отличие от защищенных форм, синхронизованные классы не содержат дополнительных функций-членов по сравнению со своим суперклассом: они просто переопределяют все виртуальные функции суперкласса.
Семантика, вносимая синхронизированным классом, заставляет трактовать каждую такую функцию как атомарную транзакцию. В то время, как клиенты защищенного объекта должны для получения эксклюзивного доступа каждый раз явно захватывать и освобождать доступ, синхронизированные формы обеспечивают эксклюзивность доступа, не требуя специальных действий со стороны своих клиентов.
Это достигается с помощью механизма блокировки, схема работы которого приведена на рис. 9-11. Взаимодействие мониторов с экземплярами предопределенных классов ReadLock и WriteLock обеспечивает эксклюзивность вызова каждой функции-члена. В этом механизме блокировка использует либо семафор, либо монитор в качестве агента, ответственного за процесс синхронизации, а сама блокировка отвечает за захват этого агента при создании и освобождение при удалении. В качестве примера рассмотрим определение класса ReadLock:
class ReadLock {
public:
ReadLock (const Monitor& m) : monitor(m) { monitor.seizeForReading(); }
~ReadLock() { monitor.releaseFromReading(); }
private:
Monitor& monitor;
};
Рис. 9-11. Механизм блокировки.
Определив блокировку и ее монитор как две отдельные абстракции, мы дали клиенту возможность использовать различные политики блокировки. Описание класса WriteLock аналогично, разница лишь в том, что он использует протокол монитора для записи.
Описания всех функций-членов синхронизированного класса используют блокировки для "оборачивания" операций, унаследованных из суперкласса. Рассмотрим в качестве примера реализацию функции length для синхронизированной неограниченной очереди:
template<class Item, class StorageManager, class Monitor>
unsigned int SynchronizedUnboundedQueue<Item, StorageManager,
Monitor>::length() const
{
ReadLock lock(monitor);
return UnboundedQueue<Item, StorageManager>::length();
}
Данный фрагмент кода иллюстрирует механизм, приведенный на рис. 9-11. Как правило, объекты класса ReadLock используются для всех синхронизированных селекторов, а экземпляры WriteLock - для синхронизированных модификаторов.
Простота и элегантность подобной архитектуры проявляется в том, что каждая функция представляет собой законченную операцию, в любом случае гарантирующую сохранность состояния объекта, причем без каких-либо явных действий со стороны агентов чтения/записи.
Действительно, клиенты, работающие с синхронизированными объектами, не должны придерживаться специальной последовательности действий, так как механизм синхронизации процессов поддерживается здесь в неявном виде. Это исключает появление ошибок типа неверной блокировки. Разработчику следует, однако, предпочитать защищенную форму синхронизированной, когда вызов нескольких функций нужно оформить как атомарную транзакцию; синхронизированная форма может гарантировать атомарность только отдельных функций-членов.
Наша архитектура обеспечивает синхронизированным формам отсутствие ситуаций типа "смертельное объятие". Например, операции присваивания объекта самому себе или сравнения его с самим собой потенциально опасны, так как требуют блокировки и левого и правого элементов выражения, которые в данном случае являются одним и тем же объектом. Будучи создан, объект не может изменить свою идентичность, поэтому тесты на самоидентичность выполнятся до блокировки какого-либо объекта. Именно поэтому описанный ранее оператор присваивания operator= включал такую проверку, как показывает следующая сокращенная запись:
template<class Item>
Queue<Item>& Queue<Item>::operator=(const Queue<Item>& q)
{
if (this == &q) return *this;
}
Любые функции-члены, среди аргументов которых есть экземпляры класса, к которому они принадлежат, должны проектироваться так, чтобы обеспечивалась корректная схема блокировки этих аргументов. Наше решение базируется на полиморфизме двух служебных функций, lock и unlock, определенных в каждом абстрактном базовом классе. Каждый абстрактный базовый класс по умолчанию содержит заглушку для этих двух функций; синхронизированные формы обеспечивают захват и освобождение аргумента.Вот почему описанный ранее оператор присваивания operator= включал вызовы этих двух функций, как показывает следующая сокращенная запись:
template<class Item>
Queue<Item>& Queue<Item>::operator=(const Queue<Item>& q)
{
((Queue<Item>&)q).lock();
((Queue<Item>&)q).unlock();
return *this;
}
Явное приведение типа используется в данном случае для того, чтобы освободиться от ограничения const на аргумент.
9.3. Эволюция
Система сбора данных: метеорологическая станция
Теория теорией, но сточки зрения инженера-практика никакая, даже самая элегантная методология, предлагаемая учеными, не стоит и ломаного гроша, если она не помогает в построении реальных, работающих систем. Предыдущие семь глав были лишь прелюдией к данному разделу книги, где будут рассмотрены приложения объектно-ориентированного анализа к решению практических задач. В этой и в оставшихся четырех главах мы будем придерживаться следующей схемы: рассмотрев требования к той или иной системе, формализуем задачу, используя стандартные условные обозначения, и далее, в процессе объектно-ориентированной разработки, придем к некоторому решению. В качестве примеров был выбран ряд самых разнообразных областей, включая обработку данных, информационные системы, искусственный интеллект и управление. Каждой из них присущи свои особенности. Здесь вы не найдете подробного описания полученных решений, так как в этой книге мы обращаем основное внимание на анализ и проектирование, а не на программирование как таковое. Мы, однако, включили достаточно полное описание перехода от анализа к проектированию и, затем, к реализации проекта, а также обратили внимание на наиболее интересные аспекты, связанные с особенностями архитектуры рассматриваемых систем.
Требования к метеорологической станции
Система должна обеспечивать автоматический мониторинг следующих первичных погодных параметров: скорость и направление ветра; температура; барометрическое давление; влажность воздуха. Система также должна вычислять некоторые производные параметры, в число которых входят: коэффициент резкости погоды; точка росы; относительное изменение температуры; относительное изменение барометрического давления. В системе должна быть предусмотрена возможность определения текущего времени и даты, которые будут использоваться при генерации сообщении о максимальных и минимальных значениях первичных параметров за последние 24 часа. Система должна обеспечивать постоянный вывод на дисплей текущих значений всех восьми первичных и производных параметров, а также текущее время и дату. Пользователь должен иметь возможность увидеть максимальные и минимальные значения любого из первичных параметров за 24 часа, сопровождаемые информацией о времени произведения соответствующего замера. Система должна позволять пользователю проводить калибровку датчиков по известным опорным значениям, а также устанавливать текущие время и дату. |
8.1. Анализ
Системные и программные требования: хрупкий компромисс
Крупные проекты, подобные рассматриваемому, обычно организуются вокруг небольшой центральной группы, ответственной за глобальную архитектуру системы, а сама разработка передается сторонним субподрядчикам или другим группам внутри той же организации. Уже на стадии анализа системные архитекторы имеют некоторую концептуальную модель, которая разделяет аппаратную и программную части реализации. Многие, правда, считают, что это уже не анализ, а проектирование. Это - спорный вопрос. В самом деле, трудно решить, что показано на схеме рис. 12-1- исходные требования или проект системы. Но в любом случае схема предполагает, что на данной стадии разработки архитектура системы принципиально объектно-ориентированна. Например, на схеме присутствуют такие сложные объекты, как система управления энергией или система управления операциями. Каждый из них выполняет одну из основных функций всей системы. Это как раз то, о чем говорилось в главе 4: объекты самого высокого уровня абстракции отвечают за основные функции системы. Поэтому процесс анализа в данном случае мало отличается от процесса проектирования.
Когда мы уже имеем скелет архитектуры (как на рис. 12-1), можно с помощью экспертов в данной прикладной области приступать к разработке основных сценариев поведения системы, как это было описано в главе 6. Чтобы подробнее описать ожидаемое поведение системы, можно использовать диаграммы взаимодействия, диаграммы объектов, протоколы действий или прототипы. На рис. 12-2 приведена диаграмма взаимодействия компонент системы, отражающая сценарий подготовки ежедневных приказов по движению поездов. На данном уровне анализа нас интересуют именно основные события и взаимодействия, определяющие поведение системы. Такие детали, как сигнатуры операций и ассоциации - это тактические подробности, которые понадобятся на последующих фазах проектирования.
В системе таких размеров запросто можно найти сотни первичных сценариев [Мы встречали проекты программных систем, в которых одни только результаты анализа занимали больше 8000 страниц документации - несомненный знак слишком ревностного анализа.
Начинающийся с этого проект редко бывает удачным]. В главе 6 мы уже установили "правило 80%". Это значит, что до перехода к проектированию архитектуры желательно зафиксировать 80% важнейших сценариев. Дожидаться 100% готовности бессмысленно.
Рис. 12-2. Подготовка ежедневных приказов по движению.
Очевидно, нужно перевести требования к системе на язык требований к ее программной и аппаратной частям, чтобы различные компетентные организации могли одновременно заниматься отдельными частями задачи (но обязательно под присмотром некоторой центральной группы, обеспечивающей общее видение проекта). Совместное создание аппаратного и программного обеспечения - сложная задача, особенно, если эти части слабо связаны и создаются разными фирмами. Иногда ясно, какая аппаратура будет использоваться. Например, можно использовать готовые терминалы или рабочие станции для бортовых дисплейных систем и в центрах управления операциями. Аналогично, представляется вполне очевидным, что составлением расписаний поездов занимаются программы. Окончательное решение о том, какую основу, аппаратную или программную, использовать в каждом конкретном случае, зависит от предпочтений разработчиков не меньше, чем от всего остального. Специализированную аппаратуру можно использовать, когда важнее производительность, а использование программ целесообразнее, когда необходимо обеспечить гибкость.
Будем считать, что первоначальный вариант аппаратной архитектуры выбран архитекторами системы. Этот выбор не должен считаться окончательным, но по крайней мере он дает отправную точку для уточнения требований к программному обеспечению. В ходе анализа, а затем и проектирования, нам необходима свобода в выборе аппаратной или программной реализации той или иной функции: позднее может оказаться, что нужна дополнительная аппаратура, или что данную функцию можно реализовать программно.
Рис. 12-3. Диаграмма процессов системы управления движением.
На рис. 12-3 показано целевое аппаратное обеспечение для системы управления движением; здесь используются наши обозначения для диаграмм процессов.
Эта архитектура процессов соответствует схеме на рис. 12-1. В частности, предусмотрен один бортовой компьютер на каждом поезде, соединяющий систему сбора и передачи информации о локомотиве, систему управления энергией, бортовой дисплей и устройство управления данными. Мы предполагаем, что некоторые бортовые устройства, такие, как дисплей, обладают минимальным интеллектом, но, возможно, не все они программируемые. Мы полагаем, что каждый ответчик подсоединен к передатчику, который посылает сообщения на проходящий мимо него поезд; компьютер к ответчику местоположения не подсоединен. Все группы путевых устройств (каждое из которых логически состоит из интерфейса и переключателя) управляются компьютером, который может взаимодействовать с проходящим поездом или с наземным контроллером через их передатчики и приемники. Каждый наземный контроллер присоединяется через глобальную сеть к диспетчерскому центру (который входит в систему управления операциями). Для обеспечения бесперебойного обслуживания мы решили разместить на каждом диспетчерском центре два компьютера: основной и резервный (второй включится в случае отказа основного компьютера). В свободное время резервный компьютер может использоваться для обслуживания других, низкоприоритетных пользователей.
На эксплуатационном уровне система управления движением может содержать сотни компьютеров: по одному на каждый поезд, по одному на каждый блок интерфейса путевых устройств и по два на каждый диспетчерский центр. На диаграмме процессов показаны только некоторые компьютеры, так как излишне показывать повторяющиеся компоненты конфигурации.
Как уже говорилось в главах 6 и 7, здравый смысл подсказывает, что при разработке большого проекта огромную роль играют разумность и ясность интерфейсов между ключевыми частями системы. Особенно это важно для интерфейса между программной и аппаратной частями системы. В начале работы над проектом интерфейс может быть определен не полностью, но он должен быть достаточно быстро формализован, чтобы различные части системы можно было разрабатывать, тестировать и интегрировать одновременно.Хорошо определенный интерфейс позволяет производить сборку системы без существенных переделок ее частей. Кроме того, мы не рассчитываем, что все разработчики, участвующие в проекте, будут одинаково сильны в программировании. Поэтому мы должны поручить спецификации ключевых абстракций и механизмов сильнейшим системным архитекторам
Словарь терминов
CRC-карточки, CRC cards. CRC - Class/Responsibilities/Collaborators, Класс/Ответственности/Сотрудники; простое, но достаточно эффективное средство мозгового штурма при выявлении ключевых абстракций и механизмов.
абстрактная операция, abstract operation. Объявленная, но не реализованная операция в абстрактном классе. В C++ абстрактные операции объявляются как чисто виртуальные функции-члены.
абстрактный класс, abstract class. Класс, который не может иметь экземпляров. Абстрактный класс пишется в предположении, что его конкретные подклассы дополнят его структуру и поведение, скорее всего, реализовав абстрактные операции.
абстракция, abstraction. Существенные характеристики объекта, которые отличают его от всех других объектов и четко определяют его концептуальные границы для наблюдателя. Абстрагирование - процесс выявления абстракций. Один из основных элементов объектной модели.
агент, agent. Объект, который подвергается воздействию со стороны и сам воздействует на другие объекты. Обычно агенты создаются для выполнения некоторой работы по поручению актеров или других агентов.
актер, actor. Объект, воздействующий на другие объекты, но сам не подвергающийся воздействию с их стороны. В некоторых контекстах то же самое, что активный объект.
активный объект, active object. Объект, которому выделен свой поток управления.
алгоритмическая декомпозиция, algorithmic decomposition. Процесс разделения системы на части, каждая из которых отражает этап общего процесса. Применение структурного подхода к проектированию приводит к алгоритмической декомпозиции, которая фокусируется на потоке управления в системе-
архитектура модулей, module architecture. Граф, вершины которого соответствуют модулям, а ребра - отношениям модулей между собой. Архитектура модулей системы представляется совокупностью диаграмм модулей.
архитектура процессов, process architecture. Граф, вершины которого соответствуют процессорам и устройствам, а ребра - соединениям между ними. Для описания архитектуры процессов системы используются диаграммы процессов.
архитектура, architecture. Логическая и физическая структура системы, сформированная всеми стратегическими и тактическими проектными решениями.
ассоциация, association. Отношение, означающее некоторую смысловую связь между классами.
атрибут, attribute. Часть составного объекта (агрегата).
базовый класс, base class. Наиболее общий класс в какой-либо структуре классов. В большинстве приложений есть несколько таких корневых классов. В некоторых языках программирования определяется всеобщий базовый класс, который является суперклассом для всех остальных классов.
блокирующий объект, blocking object. Пассивный объект, способный работать в многопоточном окружении. Вызов операции блокирующего объекта блокирует клиента на все время операции.
видимость, visibility. Способность одной абстракции видеть другую и, таким образом, ссылаться на ее ресурсы извне. Абстракции видимы друг другу, только если они находятся в одном пространстве имен. Контроль экспорта может еще более ограничить доступ к видимым абстракциям.
виртуальная функция, virtual function. Какая-либо операция над объектом. Виртуальная функция может быть переопределена в подклассах, следовательно, ее реализация определяется всем множеством методов, объявленных во всех классах дерева наследования. Термины "обобщенная функция" и "виртуальная функция" взаимозаменяемы.
временная сложность, time complexity. Относительное или абсолютное время, за которое выполняется операция.
действие, action. Некое происшествие в системе, требующее, с практической точки зрения, нулевого времени для своего завершения. Действием может быть вызов операции, запуск другого события, начало или остановка деятельности.
делегирование, delegation. При делегировании один объект, ответственный за операцию, передает выполнение этой операции другому объекту.
деструктор, destructor. Операция класса, которая освобождает состояние объекта и/или уничтожает сам объект.
деятельность, activity. Операция, выполнение которой требует некоторого времени.
диаграмма взаимодействий, interaction diagram. Часть системы обозначении объектно- ориентированного проектирования; используется для демонстрации выполнения какого-либо сценария в контексте диаграммы объектов.
диаграмма классов, class diagram. Часть системы обозначений объектно-ориентированного проектирования; используется, чтобы наглядно показать классы и их взаимоотношения в логическом проекте системы. Может представлять всю структуру классов или ее часть.
диаграмма модулей, module diagram. Часть системы обозначений объектно-ориентированного проектирования; используется для демонстрации разбиения классов и объектов по модулям в физическом проекте системы. Диаграмма модулей отображает архитектуру модулей системы.
диаграмма объектов, object diagram. Часть системы обозначений объектно-ориентированного проектирования; используется, чтобы наглядно показать объекты и отношения между ними в логическом проекте системы. Может отражать всю объектную структуру или часть ее; обычно иллюстрирует смысл механизмов в логическом проекте. Отдельная диаграмма объектов - моментальный снимок из жизни системы.
диаграмма переходов и состояний, state transition diagram. Часть обозначений объектно-ориентированного проектирования; используется для отображения пространства состояний данного класса, событий, которые вызывают переход из одного состояния в другое, и действий, возникающих в результате смены состояния.
диаграмма процессов, process diagram. Часть системы обозначений объектно-ориентированного проектирования; используется, чтобы наглядно показать, как процессы размешены по процессорам в физическом проекте системы. Диаграмма процессов отражает архитектуру процессов.
динамическое связывание, dynamic binding. Связывание означает установление соответствия имени (например, объявленной переменной) с классом. Динамическое связывание происходит при выполнении программы в тот момент, когда создается объект, обозначенный именем.
друг, friend. Класс или операция, имеющие доступ к закрытым операциям или данным некоторого класса.
Только сам класс может называть своих друзей.
закрытая часть, private. Часть интерфейса какого-либо класса, объекта или модуля, закрытая (невидимая) для других классов, объектов и модулей.
защищенная часть, protected. Часть интерфейса какого-либо класса, объекта или модуля, невидимая для всех других классов, объектов и модулей за исключением подклассов.
идентичность, identity. Природа объекта; то, что отличает его от других объектов.
идиома, idiom. Выражение, общепринятое в каком-либо языке программирования или культуре какого-либо приложения, отражающее общепринятый способ использования данного языка.
иерархия, hierarchy. Подчинение или упорядочение абстракций. Две типичных иерархии в сложной системе - структура классов (включая иерархию "обшее/частное") и структура объектов (включая иерархию "целое/часть"); иерархии можно также обнаружить в архитектурах модулей и процессов.
инвариант, invariant. Логическое выражение некоторого условия, истинность которого необходимо соблюдать.
инкапсуляция, encapsulation. Процесс разделения элементов абстракции, которые образуют ее структуру и поведение. Служит для отделения внешних обязательств объекта от его реализации.
инстанцирование, instantiation. Подстановка параметров шаблона обобщенного или параметризованного класса; в результате создается конкретный класс, который может иметь экземпляры.
интерфейс, interface. Внешний вид класса, объекта или модуля, выделяющий его существенные черты и не показывающий внутреннего устройства и секретов поведения.
исключение, exception. Возбуждение исключения показывает, что некоторый логический инвариант не соблюдается. В C++ мы возбуждаем исключение, чтобы избежать неправомочное исполнение операций и дать знать о возникшей проблеме другим объектам, которые могут перехватить исключение и принять меры.
использовать, use. Ссылаться на абстракцию извне.
итератор, iterator. Операция, позволяющая навещать части некоторого объекта.
категория классов, class category. Логически полный набор классов, одни из которых видимы для других категорий классов, а другие - нет.
Классы в категории сотрудничают для предоставления некоторого набора услуг.
класс, class. Множество объектов с общей структурой и поведением. Термины "класс" и "тип" в большинстве случаев (но не всегда) взаимозаменяемы. Понятие класса отличается от понятия типа тем, что концентрируется на классификации по структуре и поведению.
класс-контейнер, container class. Класс, экземпляры которого представляют собой коллекции других объектов. Контейнер может быть однородным (коллекции включают экземпляры только одного класса) либо неоднородным (коллекции включают экземпляры разных классов, имеющих обычно общий суперкласс). В C++ контейнеры обычно определяются как параметризованные классы с параметром, обозначающим класс объектов коллекции.
клиент, client. Объект, который пользуется услугами другого объекта либо выполняя операции над последним, либо через доступ к его состоянию.
ключ, key. Атрибут, значение которого однозначно идентифицирует объект.
ключевая абстракция, key abstraction. Класс или объект, являющийся частью словаря предметной области.
конкретный класс, concrete class. Класс, реализация которого завершена и который, поэтому, может иметь экземпляры.
конструктор, constructor. Операция, создающая объект и/или инициализирующая его состояние.
метакласс, metaclass. Класс класса; класс, экземпляры которого сами являются классами.
метод, method. Операция над объектом, определенная как часть описания класса. Не любая операция является методом, но все методы - операции. Термины "метод", "сообщение" и "операция" обычно взаимозаменяемы. В некоторых языках методы существуют сами по себе и могут переопределяться подклассами; в других языках метод не может быть переопределен, - он служит как часть реализации обобщенных или виртуальных функций, которые можно переопределять в подклассах.
механизм, mechanism. Структура, посредством которой объекты сотрудничают друг с другом, осуществляя поведение, которое соответствует требованиям системы.
модификатор, modifier. Операция, изменяющая состояние объекта.
модуль, module. Единица кода, служащая строительным блоком физической структуры системы; программный блок, который содержит объявления, выраженные в соответствии с требованиями языка и образующие физическую реализацию части или всех классов и объектов логического проекта системы. Как правило, модуль состоит из интерфейсной части и реализации.
модульность, modularity. Свойство системы, которая была разделена на связные и слабо зацепленные между собой модули.
мономорфизм, monomorphism. Положение теории типов, согласно которому имена (например, переменных) могут обозначать только объекты одного и того же класса.
мощность, cardinality. Число экземпляров класса: число экземпляров, участвующих в связи классов.
наследование, inheritance. Отношение между классами, при котором класс использует структуру или поведение другого (одиночное наследование) или других (множественное наследование) классов. Наследование вводит иерархию "общее/частное" в которой подкласс наследует от одного или нескольких более общих суперклассов. Подклассы обычно дополняют или переопределяют унаследованную структуру и поведение.
обобщенная функция, generic function. Какая-либо операция над объектом. Обобщенная функция класса может быть переопределена в подклассах; следовательно, ее реализация определяется всем множеством методов, объявленных во всех классах дерева наследования. Термины "обобщенная функция" и "виртуальная функция" взаимозаменяемы.
обобщенный класс, generic class. Класс, служащий шаблоном для создания других классов: шаблон параметризуется другими классами, объектами и/или операциями. Обобщенный класс до создания объектов должен быть инстанцирован. Обобщенные классы используются как контейнерные классы. Термины "обобщенный класс" и "параметризованный класс" взаимозаменяемы.
обратный инжиниринг, reverse-engineering. Восстановление логической или физической модели системы по коду.
Противопоставляется прямому инжинирингу.
объект, object. Нечто, чем можно оперировать. Объект имеет состояние, поведение и идентичность. Структура и поведение сходных объектов определены в общем для них классе. Термины "экземпляр" и "объект" взаимозаменяемы.
объектная модель, object model. Совокупность основополагающих принципов, лежащих в основе объектно-ориентированного проектирования; парадигма программирования, основанная на принципах абстрагирования, инкапсуляции, модульности, иерархичности, типизации, параллелизма и устойчивости.
объектное программирование, object-based programming. Метод программирования, основанный на представлении программы как совокупности объектов, каждый из которых является экземпляром некоторого типа. Типы образуют иерархию, но не наследственную. В таких программах типы рассматриваются как статические, а объекты имеют более динамическую природу, которую ограничивают статическое связывание и мономорфизм.
объектно-ориентированная декомпозиция, object-oriented decomposition. Процесс разбиения системы на части, соответствующие классам и объектам предметной области. Практическое применение методов объектно-ориентированного проектирования приводит к объектно-ориентированной декомпозиции, при которой мы рассматриваем мир как совокупность объектов, согласованно действующих для обеспечения требуемого поведения.
объектно-ориентированное программирование, object-oriented programming (OOP). Методология реализации, при которой программа организуется, как совокупность сотрудничающих объектов, каждый из которых является экземпляром какого-либо класса, а классы образуют иерархию наследования. При этом классы обычно статичны, а объекты очень динамичны, что поощряется динамическим связыванием и полиморфизмом.
объектно-ориентированное проектирование, object-oriented design (OOD). Методология проектирования, соединяющая процесс объектно-ориентированной декомпозиции и систему обозначений для представления логической и физической, статической и динамической моделей проектируемой системы.
Система обозначений состоит из диаграмм классов, объектов, модулей и процессов.
объектно-ориентированный анализ, object-oriented analysis. Метод анализа, согласно которому требования рассматриваются с точки зрения классов и объектов, составляющих словарь предметной области.
объект-член, member object. Часть состояния объекта. В совокупности объекты-члены полностью определяют структуру объекта. Термины "переменная экземпляра", "поле". "объект-член" и "слот" взаимозаменяемы.
ограничение, constraint. Выражение некоторого смыслового условия, которое должно выполняться.
операция класса, class operation. Операция, например, конструктор или деструктор, общая для всего класса и не принадлежащая конкретному объекту.
операция, operation. Нечто, проделываемое одним объектом над другим, чтобы вызвать реакцию. Все операции, которые можно выполнить над каким-либо объектом, сосредоточены в свободных подпрограммах и функциях-членах (методах). Термины "операция", "метод" и "сообщение" взаимозаменяемы.
ответственность, responsibility. Поведение, за которое ответственен объект.
открытая часть, public. Часть интерфейса какого-либо класса, объекта или модуля, открытая (видимая) для всех классов, объектов и модулей.
параллелизм, concurrency. Свойство, отличающее активные объекты от неактивных.
параллельный объект, concurrent object. Активный объект, способный работать в многопоточной среде.
параметризованный класс, parameterized class. Класс, служащий шаблоном для других классов; шаблон параметризуется другими классами, объектами и/или операциями. Параметризованный класс должен быть инстацирован до создания объектов. Параметризованные классы используются как контейнеры. Термины "обобщенный класс" и "параметризованный класс" взаимозаменяемы.
пассивный объект, passive object. Объект, не имеющий собственного потока управления.
переменная класса, class variable. Часть состояния класса. Совокупность всех переменных класса образует его структуру.
Переменные класса совместно используются всеми его экземплярами. В C++ переменная класса объявляется как статический член.
переменная экземпляра, instance variable. Часть состояния объекта. В совокупности переменные экземпляра полностью определяют структуру объекта. Термины "переменная экземпляра", "поле", "объект-член" и "слот" взаимозаменяемы.
переход, transition. Переход из одного состояния в другое.
поведение, behavior. Действия и реакции объекта, выраженные в терминах передачи сообщений и изменения состояния; видимая извне и воспроизводимая активность объекта.
подкласс, subclass. Класс, наследующий от одного или нескольких классов (которые называются его непосредственными суперклассами).
подсистема, subsystem. Совокупность модулей, часть которых видима для других подсистем, а часть - скрыта.
поле, field. Часть состояния объекта; совокупность полей объекта образуют его структуру. Термины "поле", "переменная экземпляра", "объект-член" и "слот" означают одно и то же.
полиморфизм, polymorphism. Положение теории типов, согласно которому имена (например, переменных) могут обозначать объекты разных (но имеющих общего родителя) классов. Следовательно, любой объект, обозначаемый полиморфным именем, может по-своему реагировать на некий общий набор операций.
последовательное проектирование, round-trip gestalt design. Стиль проектирования, который подчеркивает последовательность и итеративность в развитии системы: посредством уточнения различных, хотя и согласованных логических и физических представлений системы в целом; объектно-ориентированное проектирование основывается на последовательном проектировании, что является выражением взаимозависимости общей картины проекта и его деталей.
последовательный объект, sequential object. Пассивный объект, рассчитанный на работу в однопоточном окружении.
постусловие, postcondition. Инвариант, соблюдаемый на выходе из операции.
поток управления, thread of control. Отдельный процесс.
Запуск потока управления приводит к возникновению независимой динамической деятельности в системе; данная система может иметь несколько одновременно выполняемых потоков, некоторые из которых могут динамически возникать и уничтожаться. Многопроцессорные системы допускают истинную многопоточность. в то время как на однопроцессорных компьютерах возможна только иллюзия многопоточности. (Термин "thread of control" переводится также "нить управления". В данном издании принят перевод "поток управления" как более распространенный. Отметим, что в некоторых случаях автор использует термин "flow of control", который переведен также. - Примеч. ред.)
предусловие, precondition. Инвариант, предполагаемый на входе в операцию.
примесь, mixin. Класс, реализующий какое-либо четко выделенное поведение; используется для уточнения поведения других классов посредством наследования; поведение примеси обычно ортогонально поведению класса, с которым она смешивается.
пространственная сложность, space complexity. Относительный или абсолютный объем памяти, занимаемый объектом.
пространство состояний, state space. Перечислимое множество всех возможных состояний объекта. Пространство состояний программы содержит неопределенное, но конечное число состояний (не обязательно желаемых или ожидаемых).
протокол, protocol. Способы, которыми объекты могут действовать и реагировать; полное статическое и динамическое представление объекта; протокол объекта определяет допустимое поведение объекта.
процесс, process. Запуск одного потока управления.
процессор, processor. Часть аппаратного обеспечения, имеющая вычислительные ресурсы.
прямой инжиниринг, forward-engineering. Создание исполнимого кода по логической или физической модели. Противопоставляется обратному инжинирингу.
раздел, partition. Категории классов или подсистемы, составляющие часть данного уровня абстракции.
реактивная система, reactive system. Система, движимая событиями. Поведение такой системы не определяется простым отображением "вход-выход".
реализация, implementation. Внутреннее представление класса, объекта или модуля, включая секреты его поведения.
роль, role. Способность или цель, с которой класс или объект участвует в отношениях с другими; некоторая четко выделяемая черта поведения объекта в определенный момент времени; роль - это лицо, которое объект являет миру в данный момент.
свободная подпрограмма, free subprogram. Процедура или функция, которая выполняется как непримитивная операция над объектом или объектами одного и тоже или различных классов. Свободная подпрограмма - это любая подпрограмма, которая не является методом какого-либо класса.
связь, link. Связь между объектами, экземпляр ассоциации.
селектор, selector. Операция, имеющая доступ к состоянию объекта, но не изменяющая его.
сервер, server. Объект, который никогда не воздействует на другие объекты, но используется ими; объект, предоставляющий некоторые услуги.
сигнатура, signature. Полная спецификация операции с указанием типов аргументов и возвращаемого значения.
сильно типизированный, strongly typed. Свойство языка программирования, в соответствии с которым во всех выражениях гарантируется согласованность типов.
синхронизация, synchronization. Семантика параллельности операции. Операция может быть простой (присутствует только один поток управления); синхронной (рандеву двух потоков); односторонняя (рандеву, при котором одному из потоков приходится ждать);по истечении времени (рандеву, в котором один процесс ждет другого определенное время); асинхронной (два процесса независимы друг от друга).
система реального времени, real-time system. Система, в которой некоторые существенные процессы должны укладываться в отведенное время. Система "жесткого" реального времени должна быть детерминированной; запаздывание с реакцией грозит катастрофой.
скрытие информации, information hiding. Процесс скрытия всех секретов объекта, которые ничего не добавляют к его существенным характеристикам; обычно скрывают структуру объекта и реализацию его методов.
словарь данных, data dictionary. Полный перечень всех классов в системе.
слой, layer. Совокупность категорий классов или подсистем одного уровня абстракции.
слот, slot. Часть состояния объекта; совокупность слотов образуют структуру объекта. Термины "поле", "переменная экземпляра", "объект-член" и "слот" означают одно и то же.
событие, event. Что-то, что может изменить состояние системы.
сообщение, message. Операция, которую один объект может выполнять над другим. Термины "сообщение", "метод" и "операция" обычно взаимозаменяемы.
составной объект (агрегат), aggregate object. Объект, состоящий из других объектов (его частей).
состояние, state. Совокупный результат поведения объекта: одно из стабильных условий, в которых объект может существовать, охарактеризованных количественно; в любой конкретный момент времени состояние объекта включает в себя перечень (обычно, статический) свойств объекта и текущие значения (обычно, динамические) этих свойств.
сотрудничество, collaboration. Процесс, в котором несколько объектов сотрудничают для обеспечения требуемого поведения верхнего уровня.
сохраняемость, persistence. Способность объекта существовать во времени, переживая породивший его процесс, и (или) в пространстве, перемещаясь из одного адресного пространства в другое.
среда разработки, framework. Набор классов, предоставляющих некоторые базовые услуги в определенной области. Таким образом, среда разработки экспортирует классы и механизмы, которые клиенты могут использовать или адаптировать.
статическое связывание, static binding. Связывание означает установление соответствия имени (например, объявленной переменной) классу. Статическое связывание происходит при объявлении имени (во время компиляции), до того, как объект будет создан.
страж, guard. Логическое выражение, применяемое к событию; если выражение истинно, то событие происходит и система изменяет состояние.
стратегическое проектное решение, strategic design decision.
Проектные решения, которые имеют решающее влияние на архитектуру.
структура классов, class structure. Граф, вершины которого соответствуют классам, а ребра - отношениям классов. Структура классов для конкретной системы представляется в виде совокупности диаграмм классов.
структура объектов, object structure. Граф, вершины которого соответствуют объектам, а ребра - отношениям объектов. Для отражения структуры объектов или ее части используются диаграммы объектов.
структура, structure. Конкретное представление состояния объекта. Каждый объект имеет собственное состояние, независимое от других объектов, хотя все объекты одного класса имеют одинаковое представление состояния.
структурное проектирование, structured design. Метод проектирования, основанный на алгоритмической декомпозиции.
суперкласс, superclass. Класс, которому наследуют другие классы (называемые непосредственными подклассами).
сценарий, scenario. Последовательность событий, выражающая некий аспект поведения системы.
тактическое проектное решение, tactical design decision. Проектное решение, имеющее ограниченное значение для архитектуры.
тип, type. Определение области допустимых значений, которые может принимать объект, и множества операций, которые могут выполняться над объектом. Термины "класс" и "тип" обычно (но не всегда) взаимозаменяемы; тип отличается от класса тем, что фокусируется на поддержке общего протокола.
типизация, typing. Механизмы, препятствующие замене объектов одного типа на другой или, в крайнем случае, жестко ограничивающие такую замену.
трансформационная система, transformational system. Система, поведение которой определяется в терминах отображения "вход-выход".
управление доступом, access control. Механизм доступа к данным и операциям класса. В C++ открытые элементы доступны всем, защищенные элементы доступны подклассам, так называемым друзьям класса и файлам реализации, закрытые элементы доступны реализации и друзьям класса. Наконец, элементы с доступом на уровне реализации доступны только в файле реализации класса.
уровень абстракции, level of abstraction. Относительное упорядочение абстракций по структурам классов, объектов, модулей или процессов. В терминах иерархии "часть/целое" объект находится на более высоком уровне абстракции, чем другие, если он строится на основе этих объектов: в терминах иерархии "общее/частное", высокоуровневые абстракции носят более обобщенный характер, чем низкоуровневые.
услуга, service. Поведение, обеспечиваемое некоторой частью системы.
устройство, device. Часть аппаратуры, не имеющая собственных вычислительных ресурсов.
утверждение, assertion. Логическое выражение некоторого условия, истинность которого необходимо обеспечить.
утилита класса, class utility. Совокупность свободных подпрограмм. На C++ - класс, который состоит только из статических членов и/или функций-членов.
функциональная точка, function point. В контексте анализа требований к системе - отдельное поведение, видимое извне и поддающееся проверке.
функция, function. Некоторое преобразование "вход-выход", вытекающее из поведения объекта.
функция-член, member function. Операция над объектом, определенная как часть описания класса. Все функции-члены - операции, но не все операции - функции-члены. Термины "функции-члены" и "методы" взаимозаменяемы. В некоторых языках функции-члены существуют сами по себе и могут переопределяться подклассами; в других языках функция-член не может быть переопределена, - она служит как часть реализации обобщенных или виртуальных функций, которые можно переопределять в подклассах.
экземпляр, instance. Нечто, чем можно оперировать. Экземпляр имеет состояние, поведение и идентичность. Структура и поведение всех экземпляров класса определяются этим классом. Термины "объект" и "экземпляр" взаимозаменяемы.
Сложность
Врач, строитель и программистка спорили о том, чья профессия древнее. Врач заметил: "В Библии сказано, что Бог сотворил Еву из ребра Адама. Такая операция может быть проведена только хирургом, поэтому я по праву могу утверждать, что моя профессия самая древняя в мире". Тут вмешался строитель и сказал: "Но еще раньше в Книге Бытия сказано, что Бог сотворил из хаоса небо и землю. Это было первое и, несомненно, наиболее выдающееся строительство. Поэтому, дорогой доктор, вы не правы. Моя профессия самая древняя в мире". Программистка при этих словах откинулась в кресле и с улыбкой произнесла: "А кто же по-вашему сотворил хаос?"
1.1. Сложность, присущая программному обеспечению
Смысл проектирования
В любой инженерной дисциплине под проектированием обычно понимается некий унифицированный подход, с помощью которого мы ищем пути решения определенной проблемы, обеспечивая выполнение поставленной задачи. В контексте инженерного проектирования Мостов определил цель проектирования как создание системы, которая
"удовлетворяет заданным (возможно, неформальным) функциональным спецификациям;
согласована с ограничениями, накладываемыми оборудованием;
удовлетворяет явным и неявным требованиям по эксплуатационным качествам и ресурсопотреблению;
удовлетворяет явным и неявным критериям дизайна продукта;
удовлетворяет требованиям к самому процессу разработки, таким, например, как продолжительность и стоимость, а также привлечение дополнительных инструментальных средств" [40].
По предположению Страуструпа: "Цель проектирования - выявление ясной и относительно простой внутренней структуры, иногда называемой архитектурой... Проект есть окончательный продукт процесса проектирования" [41]. Проектирование подразумевает учет противоречивых требований. Его продуктами являются модели, позволяющие нам понять структуру будущей системы, сбалансировать требования и наметить схему реализации.
Важность построения модели. Моделирование широко распространено во всех инженерных дисциплинах, в значительной степени из-за того, что оно реализует принципы декомпозиции, абстракции и иерархии [42]. Каждая модель описывает определенную часть рассматриваемой системы, а мы в свою очередь строим новые модели на базе старых, в которых более или менее уверены. Модели позволяют нам контролировать наши неудачи. Мы оцениваем поведение каждой модели в обычных и необычных ситуациях, а затем проводим соответствующие доработки, если нас что-то не удовлетворяет.
Как мы уже сказали выше, чтобы понять во всех тонкостях поведение сложной системы, приходится использовать не одну модель. Например, проектируя компьютер на одной плате, инженер-электронщик должен рассматривать систему как на уровне отдельных элементов схемы (микросхем), так и на уровне схемы.
Схема помогает инженеру разобраться в совместном поведении микросхем. Схема представляет собой план физической реализации системы микросхем, в котором учтены размер платы, потребляемая мощность и типы имеющихся интегральных микросхем. С этой точки зрения инженер может независимо оценивать такие параметры системы, как температурное распределение и технологичность изготовления. Проектировщик платы может также рассматривать динамические и статические особенности системы. Аналогично, инженер-электронщик использует диаграммы, иллюстрирующие статические связи между различными микросхемами, и временные диаграммы, отражающие поведение элементов во времени. Затем инженер может применить осциллограф или цифровой анализатор для проверки правильности и статической, и динамической моделей.
Элементы программного проектирования. Ясно, что не существует такого универсального метода, "серебряной пули" [43], который бы провел инженера-программиста по пути от требований к сложной программной системе до их выполнения. Проектирование сложной программной системы отнюдь не сводится к слепому следованию некоему набору рецептов. Скорее это постепенный и итеративный процесс. И тем не менее использование методологии проектирования вносит в процесс разработки определенную организованность. Инженеры-программисты разработали десятки различных методов, которые мы можем классифицировать по трем категориям. Несмотря на различия, эти методы имеют что-то общее. Их, в частности, объединяет следующее:
условные обозначения - язык для описания каждой модели;
процесс - правила проектирования модели;
инструменты - средства, которые ускоряют процесс создания моделей, и в которых уже воплощены законы функционирования моделей. Инструменты помогают выявлять ошибки в процессе разработки.
Хороший метод проектирования базируется на прочной теоретической основе и при этом дает программисту известную степень свободы самовыражения.
Объектно-ориентированные модели. Существует ли наилучший метод проектирования? На этот вопрос нет однозначного ответа.
По сути дела это завуалированный предыдущий вопрос: "Существует ли лучший способ декомпозиции сложной системы?" Если и существует, то пока он никому не известен. Этот вопрос можно поставить следующим образом: "Как наилучшим способом разделить сложную систему на подсистемы?" Еще раз напомним, что полезнее всего создавать такие модели, которые фокусируют внимание на объектах, найденных в самой предметной области, и образуют то, что мы назвали объектно-ориентированной декомпозицией.
Объектно-ориентированный анализ и проектирование - это метод, логически приводящий нас к объектно-ориентированной декомпозиции. Применяя объектно-ориентированное проектирование, мы создаем гибкие программы, написанные экономными средствами. При разумном разделении пространства состояний мы добиваемся большей уверенности в правильности нашей программы. В итоге, мы уменьшаем риск при разработке сложных программных систем.
Так как построение моделей крайне важно при проектировании сложных систем, объектно-ориентированное проектирование предлагает богатый выбор моделей, которые представлены на рис. 1-4. Объектно-ориентированные модели проектирования отражают иерархию и классов, и объектов системы. Эти модели покрывают весь спектр важнейших конструкторских решений, которые необходимо рассматривать при разработке сложной системы, и таким образом вдохновляют нас на создание проектов, обладающих всеми пятью атрибутами хорошо организованных сложных систем.
В главе 5 подробно рассмотрен каждый из четырех типов моделей. В главе 6 описан процесс объектно-ориентированного проектирования, представляющий собой цепь последовательных шагов по созданию и развитию моделей. В главе 7 рассмотрена практика управления процессом объектно-ориентированного проектирования.
В этой главе мы привели доводы в пользу применения объектно-ориентированного анализа и проектирования для преодоления сложности, связанной с разработкой программных систем. Кроме того, мы определили ряд фундаментальных преимуществ, достигаемых в результате применения такого подхода.
Прежде чем мы представим систему обозначений и процесс проектирования, мы должны изучить принципы, на которых этот процесс проектирования основан: абстрагирование, инкапсуляцию, модульность, иерархию, типизацию, параллелизм и устойчивость.
Рис. 1-4. Объектно-ориентированные модели.
Выводы
Программам присуща сложность, которая нередко превосходит возможности человеческого разума.
Задача разработчиков программных систем - создать у пользователя разрабатываемой системы иллюзию простоты.
Сложные структуры часто принимают форму иерархий; полезны обе иерархии: и классов, и объектов.
Сложные системы обычно создаются на основе устойчивых промежуточных форм.
Познавательные способности человека ограничены; мы можем раздвинуть их рамки, используя декомпозицию, выделение абстракций и создание иерархий.
Сложные системы можно исследовать, концентрируя основное внимание либо на объектах, либо на процессах; имеются веские основания использовать объектно-ориентированную декомпозицию, при которой мир рассматривается как упорядоченная совокупность объектов, которые в процессе взаимодействия друг с другом определяют поведение системы.
Объектно-ориентированный анализ и проектирование - метод, использующий объектную декомпозицию; объектно-ориентированный подход имеет свою систему условных обозначений и предлагает богатый набор логических и физических моделей, с помощью которых мы можем получить представление о различных аспектах рассматриваемой системы.
Дополнительная литература
Проблемы, связанные с развитием сложных программных систем, были отчетливо описаны в классических работах Брукса (Brooks) [Н 1975] и [Н 1987]. В работах Гласса (Glass) [Н 1982], Defense Science Board [Н 1987], и Joint Service Task Force [Н 1982] можно найти более свежую информацию о современной практике программирования. Эмпирические исследования природы и причин программистских неудач можно найти в работах ван Генучтена (van Genuchten) [Н 1991], Гвиндона (Guindon) и др. [Н 1987], Джонса (Jones) [H1992].
Работы Саймона (Simon) [A 1962,1982] - богатый источник сведений об архитектуре сложных систем. Куртуа (Courtois) [A 1985] применил эти идеи к области программного обеспечения. Плодотворная работа Александера (Alexander) [I 1979] предлагает свежий подход к архитектуре. Питер (Peter) [I 1986] и Петроски (Petroski) [11985] изучали сложность в контексте соответственно социальных и физических систем. Аллен и Стар (Alien and Starr) [A 1982] изучали иерархические системы в ряде предметных областей. Флуд и Кэрсон (Flood and Carson) [A 1988] предприняли формальное исследование сложности сквозь призму теории систем. Волдрап (Waldrop) [A 1992] описал возникающую науку о сложности и ее использование при изучении больших адаптивных систем, возникающего поведения и самоорганизации. Отчет Миллера (Miller) [A 1956] дает эмпирические свидетельства фундаментальных ограничивающих факторов человеческого сознания.
По проектированию программного обеспечения есть ряд замечательных ссылок. Росс, Гудинаф и Ирвайн (Ross, Goodenough, and Irvine) [Н 1980], а также Зелковитс (Zeikowitz) [Н 1978] - это две классические работы, суммирующие существенные элементы проектирования. Более широкий круг работ по этому предмету включает: Дженсен и Тонис (Jensen and Tonies) [Н 1979], Саммервиль (Sommerville) [Н 1985], Вик и Рамамурти (Vick and Ramamourthy) [Н 1984], Вегнер (Wegner) [Н 1980], Пресман (Pressman) [Н 1992], Оман и Льюис (Oman and Lewis) [A 1990], Берзинс и Луки (Berzins and Luqi) [Н 1991] и Hг и Йeн(NgandYen) [Н 1990]. Другие статьи, касающиеся проектирования программного обеспечения , можно найти в Йордон (Yourdon) [Н 1979] и Фриман и Вассерман( Freeman and Wasserman)[H 1993]. Две работы, Грэхема (Graham) [F 1991] и Берарда (Berard) [Н 1993], предлагают широкое истолкование объектно-ориентированного проектирования.
Глейк (Gleik) [I 1987] предложил легко читаемое введение в хаосоведение.
Сохраняемость
Любой программный объект существует в памяти и живет во времени. Аткинсон и др. предположили, что есть непрерывное множество продолжительности существования объектов: существуют объекты, которые присутствуют лишь во время вычисления выражения, но есть и такие, как базы данных, которые существуют независимо от программы. Этот спектр сохраняемости объектов охватывает:
"Промежуточные результаты вычисления выражений.
Локальные переменные в вызове процедур.
Собственные переменные (как в ALGOL-60), глобальные переменные и динамически создаваемые данные.
Данные, сохраняющиеся между сеансами выполнения программы.
Данные, сохраняемые при переходе на новую версию программы.
Данные, которые вообще переживают программу" [79].
Традиционно, первыми тремя уровнями занимаются языки программирования, а последними - базы данных. Этот конфликт культур приводит к неожиданным решениям: программисты разрабатывают специальные схемы для сохранения объектов в период между запусками программы, а конструкторы баз данных переиначивают свою технологию под короткоживущие объекты [80].
Унификация принципов параллелизма для объектов позволила создать параллельные языки программирования. Аналогичным образом, введение сохраняемости, как нормальной составной части объектного подхода приводит нас к объектно-ориентированным базам данных (OODB, object-oriented databases). На практике подобные базы данных строятся на основе проверенных временем моделей - последовательных, индексированных, иерархических, сетевых или реляционных, но программист может ввести абстракцию объектно-ориентированного интерфейса, через который запросы к базе данных и другие операции выполняются в терминах объектов, время жизни которых превосходит время жизни отдельной программы. Как мы увидим в главе 10, эта унификация значительно упрощает разработку отдельных видов приложений, позволяя, в частности, применить единый подход к разным сегментам программы, одни из которых связаны с базами данных, а другие не имеют такой связи.
Языки программирования, как правило, не поддерживают понятия сохраняемости; примечательным исключением является Smalltalk, в котором есть протоколы для сохранения объектов на диске и загрузки с диска. Однако, записывать объекты в неструктурированные файлы - это все-таки наивный подход, пригодный только для небольших систем. Как правило, сохраняемость достигается применением (немногочисленных) коммерческих OODB [81]. Другой вариант - создать объектно-ориентированную оболочку для реляционных СУБД; это лучше, в частности, для тех, кто уже вложил средства в реляционную систему. Мы рассмотрим такую ситуацию в главе 10.
Сохраняемость - это не только проблема сохранения данных. В OODB имеет смысл сохранять и классы, так, чтобы программы могли правильно интерпретировать данные. Это создает большие трудности по мере увеличения объема данных, особенно, если класс объекта вдруг потребовалось изменить.
До сих пор мы говорили о сохранении объектов во времени. В большинстве систем объектам при их создании отводится место в памяти, которое не изменяется и в котором объект находится всю свою жизнь. Однако для распределенных систем желательно обеспечивать возможность перенесения объектов в пространстве, так, чтобы их можно было переносить с машины на машину и даже при необходимости изменять форму представления объекта в памяти. Этими вопросами мы займемся в главе 12.
В заключение определим сохраняемость следующим образом:
Сохраняемость - способность объекта существовать во времени, переживая породивший его процесс, и (или) в пространстве, перемещаясь из своего первоначального адресного пространства.
2.3. Применение объектной модели
Сопровождение
Цель. Сопровождение - это деятельность по управлению эволюцией продукта в ходе его эксплуатации. Она в значительной степени продолжает предыдущие фазы, за исключением того, что вносит меньше архитектурных новшеств. Вместо этого делаются более локализованные изменения, возникающие по мере учета новых требований и исправления старых ошибок.
Леман и Белади сделали несколько неоспоримых наблюдений, рассматривая процесс "созревания" уже внедренной программной системы:
"Эксплуатируемая программа должна непрерывно изменяться; в противном случае она будет становиться все менее и менее полезной (закон непрерывного изменения).
Когда эволюционирующая программа изменяется, ее структура становится более сложной, если не прилагаются активные усилия, чтобы этого избежать (закон возрастающей сложности)" [31].
Мы отличаем понятие сохранения системы программного обеспечения от ее сопровождения. При сопровождении разработчики вносят непрерывные усовершенствования в существующую систему; сопровождением обычно занимается другая группа людей, отличная от группы разработчиков. Сохранение же основано на привлечении дополнительных ресурсов для поддержания устаревшей системы (которая часто имеет плохо разработанную архитектуру и, следовательно, трудна для понимания и модификации). Итак, нужно принять деловое решение: если цена владения программным продуктом выше, чем цена разработки новой системы, то наиболее гуманный образ действий - оставить старую систему в покое или покончить с ней.
Результаты. Поскольку сопровождение является в определенном смысле продолжением эволюции системы, ее результаты похожи на то, чего мы добивались на предыдущих этапах. В дополнение к ним, сопровождение связано также с управлением списком новых заданий. Кроме тех требований, которые по каким-либо причинам не были учтены, вероятно, уже вскоре после выпуска работающей системы, разработчики и конечные пользователи обменяются множеством пожеланий и предложений, которые они хотели бы увидеть воплощенными в следующих версиях системы.
Заметим, что когда с системой поработает больше пользователей, выявятся новые ошибки и неожиданные методы использования, которых не смогли предвидеть контролеры качества [Пользователи проявляют чудеса изобретательности в использовании системы самым необычным образом]. В список заносятся обнаруженные дефекты и новые требования, которые будут учтены при планировании новых релизов в соответствии с их приоритетом.
Виды деятельности. Сопровождение несколько отличается от эволюции системы. Если первоначальная архитектура удалась, добавление новых функций и изменение существующего поведения происходят естественным образом.
Кроме обычных действий по эволюции, при сопровождении нужно определить приоритеты задач, собранных в список замечаний и предложений. Типичный порядок действий таков:
Упорядочить по приоритетам предложения о крупных изменениях и сообщения об ошибках, связанных с системными проблемами, и оценить стоимость переработки.
Составить список этих изменений и принять их за функциональные точки в дальнейшей эволюции.
Если позволяют ресурсы, запланировать в следующем релизе менее интенсивные, более локализованные улучшения.
Приступить к разработке следующего эволюционного релиза программы.
Путевые вехи и характеристики. Путевыми вехами сопровождения являются продолжающееся производство эволюционирующих релизов и устранение ошибок.
Мы считаем, что занимаемся именно сопровождением системы, если архитектура выдерживает изменения; мы определим, что вошли в стадию сохранения, когда количество ресурсов, требуемых для достижения нужного улучшения, начнет резко нарастать.
Выводы
Удачные проекты обычно характеризуются ясным представлением об архитектуре и хорошо управляемым итеративным жизненным циклом.
Идеально рациональный процесс проектирования невозможен, но его можно имитировать, сочетая микро- и макропроцесс разработки.
Микропроцесс объектно-ориентированной разработки приводится в движение потоком сценариев и продуктов архитектурного анализа (макропроцесс); микропроцесс представляет ежедневную деятельность команды разработчиков.
Первый шаг в микропроцессе связан с идентификацией классов и объектов на данном уровне абстракции; основными видами деятельности являются открытие и изобретение.
Второй шаг микропроцесса состоит в выявлении семантики классов и объектов; основными видами деятельности здесь являются раскадровка сценариев, проектирование изолированных классов и поиск шаблонов.
Третий шаг микропроцесса - выявление связей между классами и объектами; основными действиями являются спецификация ассоциаций, выявление взаимодействий и уточнение ассоциаций.
Четвертый шаг микропроцесса связан с реализацией классов и объектов; основное действие - выбор структур данных и алгоритмов.
Макропроцесс объектно-ориентированной разработки управляет микропроцессом, определяет измеримые характеристики проекта и помогает контролировать риск.
Первый шаг макропроцесса - концептуализация, которая устанавливает основные требования к системе; она служит для опробования концепций и, по большей части, не должна контролироваться, чтобы предоставить неограниченную свободу фантазии.
Второй шаг макропроцесса - анализ. Его цель - получить модель поведения системы. Основными действиями на этом этапе являются анализ предметной области и планирование сценариев.
Третий шаг макропроцесса - проектирование. На этом шаге создается архитектура реализации и вырабатываются единые тактические приемы; основными действиями являются архитектурное планирование, тактическое проектирование и планирование релизов.
Четвертый шаг макропроцесса - эволюция, последовательно приближающая систему к желаемому результату. Основные действия - применение микропроцесса и управление изменениями.
Пятый шаг макропроцесса - сопровождение, то есть управление эволюцией системы в ходе ее эксплуатации; основные действия похожи на действия предыдущего шага, но к ним добавляется работа со списком улучшений и исправлений.
Дополнительная литература
Ранняя форма процесса, описанного в этой главе, была впервые опубликована Бучем (Booch) [F 1982].
Берард (Berard) позднее развил эту работу в статье [F 1986]. Среди родственных подходов можно назвать GOOD (General Object-Oriented Design) Сейдвица и Старка (Seidewitz and Stark) [F 1985,1986,1987], SOOD (Structured Object-oriented Design) корпорация Локхид (Lockheed) [С 1988], MOOD (Multipleview Object-oriented Design) Керта (Kerth) [F 1988], и HOOD (Hierarchical Object-oriented Design), предложенный CISI Ingenierie и Matra для европейской космической станции [F 1987]. Более свежие ссылки: Страуструп (Stroustrup) [G 1991] и Microsoft [G 1992], где предложены сходные процессы.
В дополнение к работам, упомянутым в дополнительной литературе к главе 2, ряд других методологов предложил специфические процессы объектно-ориентированного развития. На эти работы есть много библиографических ссылок. Вот наиболее интересные из них: Алабио (Alabios) [F 1988], Бойд (Boyd) [F 1987], Бур (Buhr) [F 1984], Черри (Cherry) [F 1987,1990], деШампо (deChampeaux) [F 1992], Фелсингер (Felsinger) [F 1987], Файерсмит (Firesmith) [F 1986,1993], Хайнс и Юнгер (Hines and Unger) [G 1986], Дже-кобсон (Jacobson) [F 1985], Джамса (Jamsa) [F 1984], Кади (Kadie) [F 1986], Мазиеро и Германо (Masiero and Germano) [F 1988], Ниелсен (Nielsen) [F 1988], Ниес (Nies) [F 1986], Рэйлич и Сильва (Railich and Silva) [F 1987], Грэхем (Graham) [F 1987].
Сравнение различных процессов объектно-ориентированного развития можно найти в работах Арнольда (Arnold) [F 1991], Боем-Дэвиса и Росса (Boehm-Davis and Ross) [H 1984], деШампо (deChampeaux) [В 1991], Криббса, Муна и Ро (Cribbs, Moon, and Roe) [F 1992], Фоулер (Fowler) [F 1992], Келли (Kelly) [F 1986], Манино (Mannino) [F 1987], Сонга (Song) [F 1992], Вебстера (Webster) [F 1988]. Брукман (Brookman) [F 1991] и Фичмэн (Fichman) [F 1992] сравнили структурные и объектно-ориентированные методы.
Эмпирические исследования процессов создания программного обеспечения можно найти в работе Кертис (Curtis) [H 1992], а также в трудах Software Process Workshop [H 1988]. Еще одно интересное исследование принадлежит Гвиндону (Guindon) [H 1987], изучавшему процессы, которые разработчики использовали раньше.Речтин (Rechtin) [H 1992] предложил прагматическое руководство для системного архитектора, который должен управлять процессом развития.
Интересная ссылка по вопросу о "созревании" программного продукта - это работа Хэмфри (Hamphrey) [H 1989]. Классическая ссылка на то, как симитировать этот процесс, - статья Парнаса (Parnas) [H 1986].
Состояние
Семантика. Рассмотрим торговый автомат, продающий напитки. Поведение такого объекта состоит в том, что после опускания в него монеты и нажатия кнопки автомат выдает выбранный напиток. Что произойдет, если сначала будет нажата кнопка выбора напитка, а потом уже опущена монета? Большинство автоматов при этом просто ничего не сделают, так как пользователь нарушил их основные правила.
Другими словами, автомат играл роль (ожидание монеты), которую пользователь игнорировал, нажав сначала кнопку. Или предположим, что пользователь автомата не обратил внимание на предупреждающий сигнал "Бросьте столько мелочи, сколько стоит напиток" и опустил в автомат лишнюю монету. В большинстве случаев автоматы не дружественны к пользователю и радостно заглатывают все деньги.
В каждой из таких ситуаций мы видим, что поведение объекта определяется его историей: важна последовательность совершаемых над объектом действий. Такая зависимость поведения от событий и от времени объясняется тем, что у объекта есть внутреннее состояние. Для торгового автомата, например, состояние определяется суммой денег, опущенных до нажатия кнопки выбора. Другая важная информация - это набор воспринимаемых монет и запас напитков.
На основе этого примера дадим следующее низкоуровневое определение:
Состояние объекта характеризуется перечнем (обычно статическим) всех свойств данного объекта и текущими (обычно динамическими) значениями каждого из этих свойств.
Одним из свойств торгового автомата является способность принимать монеты. Это статическое (фиксированное) свойство, в том смысле, что оно - существенная характеристика торгового автомата. С другой стороны, этому свойству соответствует динамическое значение, характеризующее количество принятых монет. Сумма увеличивается по мере опускания монет в автомат и уменьшается, когда продавец забирает деньги из автомата. В некоторых случаях значения свойств объекта могут быть статическими (например, заводской номер автомата), поэтому в данном определении использован термин "обычно динамическими".
К числу свойств объекта относятся присущие ему или приобретаемые им характеристики, черты, качества или способности, делающие данный объект самим собой. Например, для лифта характерным является то, что он сконструирован для поездок вверх и вниз, а не горизонтально. Перечень свойств объекта является, как правило, статическим, поскольку эти свойства составляют неизменяемую основу объекта. Мы говорим "как правило", потому что в ряде случаев состав свойств объекта может изменяться. Примером может служить робот с возможностью самообучения. Робот первоначально может рассматривать некоторое препятствие как статическое, а затем обнаруживает, что это дверь, которую можно открыть. В такой ситуации по мере получения новых знаний изменяется создаваемая роботом концептуальная модель мира.
Все свойства имеют некоторые значения. Эти значения могут быть простыми количественными характеристиками, а могут ссылаться на другой объект. Состояние лифта может описываться числом 3, означающим номер этажа, на котором лифт в данный момент находится. Состояние торгового автомата описывается в терминах других объектов, например, имеющихся в наличии напитков. Конкретные напитки - это самостоятельные объекты, отличные от торгового автомата (их можно пить, а автомат нет, и совершать с ними иные действия).
Таким образом, мы установили различие между объектами и простыми величинами: простые количественные характеристики (например, число 3) являются "постоянными, неизменными и непреходящими", тогда как объекты "существуют во времени, изменяются, имеют внутреннее состояние, преходящи и могут создаваться, уничтожаться и разделяться" [6].
Тот факт, что всякий объект имеет состояние, означает, что всякий объект занимает определенное пространство (физически или в памяти компьютера).
Примеры. Предположим, что на языке C++ нам нужно создать регистрационные записи о сотрудниках. Можно сделать это следующим образом:
struct PersonnelRecord { char name[100];
int socialSecurityNumber;
char department[10];
float salary;
};
Каждый компонент в приведенной структуре обозначает конкретное свойство нашей абстракции регистрационной записи. Описание определяет не объект, а класс, поскольку оно не вводит какой-либо конкретный экземпляр [Точнее, это описание определяет структуру в C++, семантика которой соответствует классу, у которого все поля открыты. Таким образом, структуры - это неинкапсулированные абстракции]. Для того чтобы создать объекты данного класса, необходимо написать следующее:
PersonnelRecord deb, dave, karen, jim, torn, denise, kaitlyn, krista, elyse;
В данном случае объявлено девять различных объектов, каждый из которых занимает определенный участок в памяти. Хотя свойства этих объектов являются общими (их состояние представляется единообразно), в памяти объекты не пересекаются и занимают каждый свое место. На практике принято ограничивать доступ к состоянию объекта, а не делать его общедоступным, как в предыдущем определении класса. С учетом сказанного, изменим данное определение следующим образом:
class PersonnelRecord {
public: char* employeeName() const;
int employeeSocialSecurityNumber() const;
char* employeeDepartment() const;
protected: char name[100];
int socialSecurityNumber;
char department[10];
float salary;
};
Новое определение несколько сложнее предыдущего, но по ряду соображений предпочтительнее [К вопросу о стилях: по критериям, которые вводятся в этой главе далее, предложенное определение класса PersonnelRecord - это далеко не шедевр. Мы хотим здесь только показать семантику состояния класса. Иметь в классе функцию, которая возвращает значение char*, часто опасно, так как это нарушает парадигму защиты памяти: если метод отводит себе память, за которую получивший к ней доступ клиент не отвечает, результатом будет замусоривание памяти. В наших системах мы предпочитаем использовать параметризованный класс строк переменной длины, который можно найти в базовой библиотеке классов, вроде той, что описана в главе 9. И еще: классы - это больше чем структуры из С с синтаксисом классов C++; как объясняется в главе 4, классификация требует определенного согласования структуры и поведения].
В частности, в новом определении реализация класса скрыта от других объектов. Если реализация класса будет в дальнейшем изменена, код придется перекомпилировать, но семантически клиенты не будут зависеть от этих изменении (то есть их код сохранится). Кроме того, решается также проблема занимаемой объектом памяти за счет явного определения операций, которые разрешены клиентам над объектами данного класса. В частности, мы даем всем клиентам право узнать имя, код социальной защиты и место работы сотрудника, но только особым клиентам (а именно, подклассам данного класса) разрешено устанавливать значения указанных параметров. Только этим специальным клиентам разрешен доступ к сведениям о заработной плате. Другое достоинство последнего определения связано с возможностью его повторного использования. В следующем разделе мы увидим, что механизм наследования позволяет повторно использовать абстракцию, а затем уточнить и многими способами специализировать ее.
В заключение скажем, что все объекты в системе инкапсулируют некоторое состояние, и все состояние системы инкапсулировано в объекты. Однако, инкапсуляция состояния объекта - это только начало, которого недостаточно, чтобы мы могли охватить полный смысл абстракций, которые мы вводим при разработке. По этой причине нам нужно разобраться, как объекты функционируют.
Создание клиентской части приложения
Создание клиентской части в значительной степени сводится к построению графического прикладного интерфейса. В свою очередь, построение удобного интуитивного и дружественного пользователю интерфейса - скорее искусство, чем наука. В приложениях, построенных в рамках архитектуры клиент/сервер, именно качество интерфейса определяет (в большинстве случаев) популярность тех или иных программ. При создании интерфейса пользователя необходимо учитывать множество различных факторов: технические ограничения, особенности психологии, традиции, вкусы персонала.
При создании нашей системы складского учета мы можем столкнуться с двумя препятствиями. Во-первых, нужно выяснить, каким должен быть "правильный" интерфейс пользователя. Во-вторых, желательно определить, какие общепринятые подходы мы можем использовать при создании интерфейса.
Ответ на первый вопрос можно получить достаточно просто, но для этого нужно прототипировать, прототипировать и прототипировать. Нужно как можно раньше получить действующую модель системы, чтобы показать ее пользователям и получить от них квалифицированные замечания. Объектно-ориентированный подход существенно поможет нам в этом смысле, поскольку он основан на итерационном развитии проекта. На самых ранних стадиях проекта мы уже сможем показать пользователям прототип системы.
Второй вопрос находится в сфере стратегии проекта, но для его успешного разрешения у нас имеется множество хороших примеров. Существуют коммерческие продукты, например, Х Window System от MIT, Open Look, Windows от Microsoft, MacApp от Apple, NextStep от Next, Presentation Manager от IBM. Все эти продукты существенно различаются: некоторые основываются на сети, а некоторые опираются на концепцию ядра, некоторые позволяют действовать на уровне пикселей, а другие считают примитивами более сложные геометрические фигуры. В любом случае все они позволяют существенно упростить создание графического интерфейса пользователя. Ни один из перечисленных продуктов не родился за одну ночь.
Все они постепенно развивались из самых простых систем, прошли путь проб и ошибок. В результате эти системы вобрали в себя набор абстракций, достаточный для построения пользовательского интерфейса. Поскольку нет однозначного ответа на вопрос о лучшем интерфейсе, то существуют несколько вариантов оконной модели.
В главе 9 мы уже упоминали о том, что при работе с большими библиотеками классов (каковыми являются и библиотеки графического интерфейса) важно понять механизмы их построения. Для нашей задачи основным механизмом является реакция GUI-приложений на события. Берсон указывал, что для клиентской части приложения существенны события, связанные со следующими объектами [24]:
мышь
клавиатура
меню
обновление окна
изменения размера окна
активизация/деактивация
начало/завершение.
Мы добавим к этому перечню сетевые события [Например, механизмы DDE (Dynamic Data Exchange, динамический обмен данными) и OLE (Object Linking and Embedding, связь и внедрение объектов) от Microsoft представляют собой основанные на сообщениях протоколы, обеспечивающие обмен информацией между приложениями Windows]. Для нашей архитектуры они очень существенны, поскольку клиентская часть приложения связана с другими компонентами и приложениями через сеть. Описанная семантика хорошо согласуется с нашим подходом к построению класса Transaction, который может рассматриваться как посредник, пересылающий события от приложения к приложению. С точки зрения построения клиентской части, сетевые события являются разновидностью событий, что позволяет описать единый механизм реакции на события.
Берсон обратил внимание на наличие нескольких альтернативных моделей обработки событий [25]:
• Цикл обработки событий | В цикле просматривается очередь событии и для каждого события вызывается соответствующая процедура обработки. |
• Обратный вызов | Приложение регистрирует функцию обратного вызова для каждого элемента GUI; обратный вызов происходит, когда элемент зарегистрирует событие. |
• Гибридная модель | Сочетание циклического опроса и функций обратного вызова. |
Изрядно упрощая, можно утверждать, что в интерфейсе MacApp используется цикл, в Motif - функции обратного вызова, a Microsoft Windows является примером гибридной модели.
Кроме первичного механизма, нам необходимо реализовать еще множество GUI-механизмов: рисование, прокрутка, работа с мытью, меню, сохранение и восстановление, печать, редактирование, обработка ошибок, распределение памяти. Безусловно, подробное рассмотрение всех этих вопросов находится вне рамок нашего анализа, поскольку каждая конкретная GUI-среда имеет свои собственные реализации этих механизмов.
Мы предлагаем разработчику клиентской части приложения выбрать подходящую GUI-среду разработки, изучить ее основные механизмы и правильно их применить.
10.3. Эволюция
Спецификации
Спецификация - это неграфическая форма, используемая для полного описания элемента системы обозначений: класса, ассоциации, отдельной операции или целой диаграммы. Просматривая диаграммы, можно относительно легко разобраться в большой системе; однако одного графического представления недостаточно: мы должны иметь некоторые пояснения к рисункам, и эту роль будут играть спецификации.
Как было сказано ранее, диаграмма - срез разрабатываемой модели системы. Спецификации же служат неграфическими обоснованиями каждого элемента обозначений. Таким образом, множество всех синтаксических и семантических фактов, нашедших свое отражение на диаграмме, должно быть подмножеством фактов, описанных в спецификации модели и согласовываться с ними. Очевидно, что важную роль в сохранении согласованности диаграмм и спецификаций может играть инструмент проектирования, поддерживающий такую систему обозначений.
В этом разделе мы рассмотрим сначала основные элементы двух важнейших спецификаций, а затем изучим их дополнительные свойства. Мы не ставим себе задачу подробного описания каждой спецификации, - оно зависит от пользовательского интерфейса конкретных сред, поддерживающих нашу систему обозначений. Мы также не будем представлять спецификации всех элементов (в частности, вне нашего внимания окажутся метакласс и отдельные типы связей). В большинстве такие спецификации или являются подмножеством более общих спецификаций, таких как спецификации классов, или ничего не добавляют к графическому представлению. Особенно важно подчеркнуть следующее: спецификация должна отражать то, что не выражено в графических элементах диаграммы; спецификации содержат ту информацию, которую лучше записать в текстовом, а не графическом виде.
Общие элементы. Все спецификации имеют как минимум следующие компоненты:
Имя: идентификатор
Определение: текст
Уникальность имени зависит от именуемого элемента. Например, имена классов должны быть уникальны по крайней мере в содержащей их категории, тогда как имена операций имеют область видимости, локальную для содержащего их класса.
Каждый элемент диаграммы переходов может иметь спецификацию, которая дает его полное определение. В отличие от спецификаций классов, спецификации переходов и состояний ничего не добавляют к уже описанному в этом разделе, поэтому нет необходимости обсуждать их специально.
5.4. Диаграммы объектов
Как и для диаграмм классов, за каждым элементом диаграммы объектов могут стоять спецификации. Спецификации объектов и их связей не несут никакой иной информации, кроме уже описанной. С другой стороны, спецификации диаграмм объектов как целого могут сообщить кое-что важное. Как упоминалось ранее, каждая диаграмма объектов существует в контексте. В спецификации контекст указывается следующим образом:
Context: глобальный | категория | класс | операция
В частности, область видимости диаграммы объектов может быть глобальной, или в контексте указанной категории классов, класса или операции (включая, как методы, так и свободные подпрограммы).
Рис. 5-29. Расписание.
5.5. Диаграммы взаимодействия
Спецификация подсистем
Если мы рассмотрим внешнее представление любой из подсистем, то обнаружим, что она обладает всеми характеристиками объекта. Каждая подсистема имеет уникальную, хотя и статичную, идентичность и большое число возможных состояний, а также демонстрирует очень сложное поведение. Подсистемы используются как хранилища других классов, утилит классов и объектов; таким образом, они лучше всего характеризуются экспортируемыми ресурсами. На практике при использовании C++ эти ресурсы представляются в форме каталогов, содержащих логически связанные модули и вложенные подсистемы.
Диаграмма модулей на рис. 12-9 полезна, но не полна, так как каждая из подсистем на ней слишком велика для реализации одним небольшим коллективом разработчиков. Мы должны раскрыть внутреннее представление подсистем верхнего уровня и провести их декомпозицию.
Рассмотрим для примера подсистему NetworkFacilities (сеть). Мы решили разбить ее на две другие подсистемы, одна из которых - закрытая (RadioCommunication (радиосвязь)), а другая - открытая (Messages (сообщения)). Закрытая подсистема скрывает детали своего программного управления физическими устройствами, в то время как открытая подсистема обеспечивает поддержку спроектированного ранее механизма передачи сообщений.
Подсистема, названная Databases (базы данных), построена на основе ресурсов подсистемы NetworkFacilities и служит для реализации механизма планов движения поезда, который мы описали выше. Мы составляем эту подсистему из двух экспортируемых открытых подсистем, TrainPlanDatabase (база данных планов поездов) и TrackDatabase (база данных путей). Для действий, общих для этих двух подсистем, мы предусмотрим закрытую подсистему DatabaseManager (менеджер баз данных).
Подсистема Devices (устройства) также естественно разбивается на несколько небольших подсистем. Мы решили сгруппировать программы, относящиеся ко всем путевым устройствам, в одну подсистему, а программы, связанные с активными механизмами и датчиками локомотива, - в другую. Эти две подсистемы доступны клиентам подсистемы Devices, и обе они построены на основе ресурсов подсистем TrainPlanDatabase и Messages.
Таким образом, мы спроектировали подсистему Devices для реализации механизма датчиков, который описан выше.
Наконец, мы представляем подсистему верхнего уровня UserApplications (прикладные программы) в виде нескольких небольших подсистем, включая EngineerApplications (программы для машиниста) и DispatcherApplications (программы для диспетчера), чтобы зафиксировать разную роль двух главных пользователей системы управления движением. Подсистема EngineerApplications содержит ресурсы, которые обеспечивают взаимодействие машиниста и компьютера, в частности, анализ системы сбора и отображения информации о состоянии локомотива и системы управления энергией. Подсистема DispatcherApplicatlona обеспечивает интерфейс "диспетчер/компьютер". Подсистемы EngineerApplications и DispatcherApplications разделяют общие закрытые ресурсы, экспортируемые из подсистемы Displays (отображение), которая реализует описанный ранее механизм отображения.
В результате проектирования мы получили четыре подсистемы верхнего уровня и десять подсистем следующего уровня, в которых размещены все введенные ранее ключевые абстракции и механизмы. Важно, что в терминах этих подсистем можно планировать работу, управлять конфигурациями и версиями. Как говорилось в главе 7, отвечать за каждую такую подсистему может один человек, в то время как разрабатывать ее будет множество программистов. Ответственный за подсистему детализирует ее проект и реализацию и управляет ее интерфейсом с другими подсистемами на том же уровне абстракции. Так, за счет декомпозиции сложной задачи на несколько более простых, становится возможным управление разработкой сложных проектов.
В главе 7 уже демонстрировалась возможность нескольких одновременных представлений разрабатываемой системы. Набор совместимых версий подсистем образует релиз, и таких релизов может быть множество - по одному на каждого разработчика, еще один - для тестирования, один - для опробования пользователями и т.д. Отдельные проектировщики могут для своих нужд создавать собственные стабильные релизы и интегрировать в них те части, за которые они отвечают, до передачи их остальным.
Так создается механизм непрерывной интеграции нового кода.
Основой успеха является тщательное конструирование интерфейсов подсистем. После того как интерфейсы определены, они должны тщательно оберегаться. Как мы определяем внешнее представление подсистемы? Нужно каждую подсистему рассматривать как объект. Поэтому мы ставим те же вопросы, которые задавали в главе 4 для значительно более простых объектов: Какие состояния имеет объект? Какие действия над ним может выполнить клиент? Каких действий он требует от других объектов?
Например, рассмотрим подсистему TrainPlanDatabase. Она строится на основе трех других подсистем (Messages, TrainDatabase, TrackDatabase) и имеет нескольких важных клиентов - подсистемы WaysideDevices (путевые устройства), LocomotiveDevices (устройства на локомотиве), EngineerApplications и DispatcherApplications. Подсистема TrainPlanBatabase относительно проста - она содержит все планы поездов. Конечно, хитрость в том, что эта подсистема должна поддерживать механизм распределенной передачи планов движением поезда. Снаружи клиент видит монолитную базу данных, но изнутри мы знаем, что на самом деле база данных - распределенная, и поэтому должны основывать се на механизме передачи сообщений подсистемы Messages.
Какие действия можно выполнять с помощью TrainPlanDatabase? Все обычные для базы данных операции: добавление, удаление и изменение записей, запросы. Так же как в главе 10, нужно зафиксировать все проектные решения об этой подсистеме в форме классов C++, которые снабдят нас объявлениями операций.
На этой стадии нам следует продолжить процесс проектирования для каждой подсистемы. Еще раз отметим, что вероятность того, что все интерфейсы окажутся правильными с первого раза, очень мала. К счастью, как и для небольших объектов, опыт подсказывает, что большинство изменений, которые мы произведем в интерфейсах, не затронет верхних уровней (совместимость снизу вверх), если мы хорошо поработали, описывая каждую подсистему в объектно-ориентированном стиле.
12.4. Сопровождение
SQL
При работе с объектно-ориентированной моделью, где данные и формы поведения соединены воедино, пользователю может понадобиться осуществить ряд транзакций с таблицами. Он, например, может захотеть добавить в базу нового поставщика, исключить из нее некоторые товары или изменить количество имеющегося в наличии товара. Может также появиться необходимость сделать различные выборки из базы данных, например, просмотреть список всех продуктов от определенного поставщика или получить список товаров, количество которых на складе недостаточно или избыточно с точки зрения заданного нами критерия. Может, наконец, понадобиться создать исчерпывающий отчет, в котором оценивается стоимость пополнения запасов до определенного уровня, используя наименее дорогих поставщиков. Подобные типы транзакций присутствуют почти в каждом приложении, использующем реляционную базу данных. Для взаимодействия с реляционными СУБД разработан стандартный язык - SQL (Structured Query Language, язык структурированных запросов). SQL может использоваться и в интерактивном режиме, и для программирования.
Самой важной конструкцией языка SQL является предложение SELECT следующего вида:
SELECT <attribute> FROM <relation> WHERE <condition>
Для того чтобы, например, получить коды продуктов, чей запас на складе меньше 100 единиц, можно написать следующее:
SELECT PRODUCTID, QUANTITY FROM INVENTORY WHERE QUANTITY < 100
Возможно создание и более сложных выборок. Например такой, где вместо кода товара фигурирует его наименование:
SELECT NAME, QUANTITY
FROM INVENTORY, PRODUCTS
WHERE QUANTITY < 100 AND INVENTORY.PRODUCTID = PRODUCTS.PRODUCTID
В этом предложении присутствует связь, позволяющая как бы объединять несколько отношений в одно. Данное предложение SELECT не создает новой таблицы, но оно возвращает набор строк. Одна выборка может содержать сколь угодно большое число строк, поэтому мы должны иметь средства для доступа к информации в каждой из них. Для этого в языке SQL введено понятие курсора, смысл которого схож с итерацией, о которой мы говорили в главе 3.
Можно, например, определить курсор следующим образом:
DECLARE С CURSOR
FOR SELECT NAME, QUANTITY
FROM INVENTORY, PRODUCTS
WHERE QUANTITY < 100 AND INVENTORY.PRODUCTID = PRODUCTS.PRODUCTID
Чтобы открыть эту выборку, мы пишем
OPEN C
Для прочтения записей выборки используется оператор FETCH:
FETCH C INTO NAME, AMOUNT
И, наконец, после того, как работа завершена, мы закрываем курсор;
CLOSE C
Вместо использования курсора можно пойти другим путем: создать виртуальную таблицу, где содержатся результаты выборки. Такая виртуальная таблица называется представлением. С ним можно работать как с настоящей таблицей. Создадим, например, представление, содержащее наименование товара, имя поставщика и стоимость:
CREATE VIEW V (NAME, COMPANY, COST) AS
SELECT PRODUCTS.NAME, SUPPLIERS.COMPANY, PRICES.PRICE
FROM PRODUCTS, SUPPLIERS, PRICES
WHERE PRODUCTS.PRODUCTID = PRICES.PRODUCTID AND SUPPLIERS.SUPPLIERID = PRICES.SUPPLIERID
Использование представлений предпочтительнее, так как оно позволяет создавать различные представления для различных клиентов системы. Поскольку представления могут существенно отличаться от низкоуровневых связей в базе данных, гарантируется некоторая степень независимости данных. Права доступа пользователей к информации можно определять на основе виртуальных, а не реальных таблиц, позволяя таким образом записывать безопасные транзакции. Представления несколько отличаются от таблиц, хотя бы тем, что связи в представлениях не могут быть обновлены напрямую.
В нашей системе SQL-запросы будут играть роль абстракций низкого уровня. Пользователи вряд ли будут разбираться в SQL, ведь этот язык не является частью предметной области. Мы будем использовать SQL при реализации программы. Составлять свои SQL-предложения смогут только достаточно искушенные в программировании разработчики инструментальных средств нашей системы. От простых смертных, работающих с системой каждый день, язык запросов будет скрыт.
Рассмотрим следующую задачу: получив заказ, мы хотим определить имя сделавшей его компании.
С точки зрения программиста SQL, это нетрудная задача. Однако, в нашем случае, когда основное программирование выполняется на C++, мы предпочли бы использовать следующее выражение:
currentOrder.customer().name()
С точки зрения объектно-ориентированного подхода это выражение вызывает селектор customer, возвращающий ссылку на клиента, а затем - селектор name, возвращающий имя клиента. На самом деле данное выражение вычисляется следующим запросом:
SELECT NAME
FROM ORDERS, CUSTOMERS
WHERE ORDERS.CUSTOMERID = CURRENTORDER.CUSTOMERID
AND ORDERS.CUSTOMERID = CUSTOMERS.CUSTOMERID
Спрятав от клиента детали реализации данного вызова, мы скрыли от него все неприятные особенности работы с SQL.
Отображение объектно-ориентированного представления мира в реляционное концептуально ясно, но обычно требует довольно утомительной проработки деталей [Большая часть преимуществ объектно-ориентированных баз данных заключается как раз в том, что в них эти утомительные детали скрыты от разработчика. Отображение классов в таблицы достаточно легко алгоритмизуемо, поэтому существует альтернатива ООСУБД: инструментальные средства, которые автоматически преобразуют определения классов C++ в реляционную схему и SQL-код. Тогда, например, если приложение запрашивает атрибут данного объекта, сгенерированный код создает необходимые SQL-предложения для стандартной реляционной базы данных, получает требуемые данные и доставляет их клиенту в форме, согласованной с интерфейсом C++]. По замечанию Румбаха, "Соединение объектной модели с реляционной базой данных - в целом довольно простая задача, за исключением вопросов, связанных с обобщением" [16]. Румбах предлагает также некоторые правила, которые следует учитывать при отображении классов и ассоциаций (включая агрегацию) на таблицы:
Каждый класс отображается в одну или несколько таблиц.
Каждое отношение "многие ко многим" отображается в отдельную таблицу.
Каждое отношение "один ко многим" отображается в отдельную таблицу или соотносится с внешним ключом [17].
Далее он предлагает три альтернативных варианта отображения иерархии наследования в таблицы:
Суперкласс и каждый его подкласс отображаются в таблицу.
Атрибуты суперкласса реплицируются в каждой таблице (и каждый подкласс отображается в отдельную таблицу).
Атрибуты всех подклассов переносятся на уровень суперкласса (таким образом мы имеем одну таблицу для всей иерархии наследования) [18].
Нет ничего удивительного в том, что существуют определенные ограничения по использованию SQL в низкоуровневой реализации [Недавно был предложен новый стандарт - SQL3, который содержит объектно-ориентированные расширения. Они существенно уменьшают семантические различия между объектно-ориентированным и реляционным взглядом на мир и устраняют многие другие ограничения SQL]. В частности, этот язык поддерживает ограниченный набор типов данных, а именно, символы, строки фиксированной длины, целые числа и вещественные числа с фиксированной и плавающей точкой. Отдельные реализации иногда умеют работать и с другими типами данных; однако представление информации в виде графических элементов или строк произвольной длины напрямую не поддерживается.
Среда разработки: библиотека базовых классов
Основным преимуществом объектно-ориентированных языков программирования, таких, как C++ и Smalltalk, является высокая степень повторного использования в хорошо спроектированных системах. Это означает, что для разработки каждого следующего приложения требуется гораздо меньше нового кода; следовательно, меньшее количество кода требуется сопровождать и поддерживать.
Повторное использование принимает различные формы: заимствование отдельных строк кода, отдельных классов или логически связанных между собой групп классов. Повтор строк наиболее прост (кто из программистов хоть однажды не использовал редактор для копирования реализации того или иного алгоритма из одной программы в другую?), но наименее выгоден (один и тот же фрагмент кода просто дублируется в различных приложениях). Мы поступим гораздо лучше, используя объектно-ориентированные языки программирования и обращаясь к существующим классам, модифицируя их или наследуя от них. Но еще больших успехов можно достичь, используя наборы классов, организованные в инструментальные библиотеки, - среды разработки. Как уже отмечалось в главе 4, под средой разработки понимается совокупность классов, предоставляющих набор услуг в определенной области; таким образом, среда разработки экспортирует ряд отдельных классов и механизмов, которые клиенты могут использовать непосредственно или адаптировать.
Среды разработки могут быть достаточно универсальны и применимы к широкому кругу приложений. К этой категории относятся общие фундаментальные классы, математические библиотеки и библиотеки графического интерфейса пользователя. Среды разработки могут встречаться и в достаточно узких предметных областях, таких, например, как учет пациентов больницы, торговля текстилем, менеджмент, телефонные системы. Там, где существует семейство программ, решающих сходные задачи, появляется повод создать прикладную среду разработки.
В этой главе мы применим объектно-ориентированный подход к созданию библиотеки фундаментальных классов. В предыдущей главе нашими основными задачами были реализация управления системой в реальном времени и оптимальное разделение функциональных свойств между несколькими относительно автономными и статическими объектами. Здесь же будут доминировать два различных аспекта: стремление к гибкости архитектуры с широким выбором альтернатив оптимизации по времени и памяти, и необходимость использования общих механизмов управления памятью и синхронизацией.
9.1. Анализ
Существенное: категории классов
Как объяснялось в главе 3, класс - необходимое, но недостаточное средство декомпозиции. Когда система разрастается до дюжины классов, можно заметить группы классов, связанные внутри, и слабо зацепляющиеся с другими. Мы называем такие группы категориями классов.
Многие объектно-ориентированные языки не поддерживают это понятие. Следовательно, выделение обозначений для категорий классов позволяет выразить важные архитектурные элементы, которые не могли быть непосредственно записаны на языке реализации [Среда программирования Smalltalk поддерживает концепцию категорий классов. Собственно это и подвигло нас на включение категорий в систему обозначений. Однако, в Smalltalk категории классов не имеют семантического содержания: они существуют только для более удобной организации библиотеки классов. В C++ категории классов связаны с концепцией компонент (Страуструп), они еще не являются чертой языка, хотя включение в него семантики пространства имен рассматривается [8]. (В настоящее время пространства имен включены в стандарт. - Примеч. ред.)].
Рис. 5-6. Значок категории классов.
Классы и категории классов могут сосуществовать на одной диаграмме. Верхние уровни логической архитектуры больших систем обычно описываются несколькими диаграммами, содержащими только категории классов.
Категории классов. Категории классов служат для разбиения логической модели системы. Категория классов - это агрегат, состоящий из классов и других категорий классов, в том же смысле, в котором класс - агрегат, состоящий из операций и других классов. Каждый класс системы должен "жить" в единственной категории или находиться на самом верхнем уровне системы. В отличие от класса, категория классов не имеет операций или состояний в явном виде, они содержатся в ней неявно в описаниях агрегированных классов.
На рис. 5-6 показан значок, обозначающий категорию классов. Как и для класса, для категории требуется имя, которое должно быть уникально в данной модели и отлично от имен классов.
Иногда полезно на значке категории перечислить некоторые из содержащихся в ней классов. "Некоторые", потому, что зачастую категории содержат довольно много классов, и перечислять их все было бы хлопотно, да это и не нужно. Так же, как список атрибутов и операций на значке класса, список классов в значке категории представляет сокращенный вид ее спецификации. Если мы хотим видеть на значке категории больше классов, мы можем его увеличить. Можно удалить разделяющую черту и оставить в значке только имя категории.
Категория классов представляет собой инкапсулированное пространство имен. По аналогии с квалификацией имен в C++, имя категории можно использовать для однозначной квалификации имен содержащихся в ней классов и категорий. Например, если дан класс A из категории B, то его полным именем будет A::B. Таким образом, как будет обсуждаться далее, для вложенных категорий квалификация имен простирается на произвольную глубину.
Некоторые классы в категории могут быть открытыми, то есть экспортироваться для использования за пределы категории. Остальные классы могут быть частью реализации, то есть не использоваться никакими классами, внешними к этой категории. Для анализа и проектирования архитектуры это различие очень важно, так как позволяет разделить обязанности между экспортируемыми классами, которые берут на себя общение с клиентами, и внутренними классами в категории, которые, собственно, выполняют работу. На самом деле, во время анализа закрытые аспекты категории классов можно опустить. По умолчанию все классы в категории определяются как открытые, если явно не указано противное. Ограничение доступа будет обсуждаться ниже.
Категория может использовать невложенные категории и классы. С другой стороны, и классы могут использовать категории. Для единообразия мы обозначаем эти экспортно-импортные отношения так же, как отношение использования между классами (см. рис. 5-4). Например, если категория A использует категорию B, это означает, что классы из A могут быть наследниками, или содержать экземпляры, использовать или быть еще как-то ассоциированы с классами из B.
Рис. 5-7. Диаграмма классов верхнего уровня для гидропонной системы.
Когда в категории слишком много общих классов, вроде базовых классов-контейнеров или других базовых классов, подобных Object в Smalltalk, возникают практические затруднения. Такие классы будут использоваться чуть ли не всеми другими категориями, загромождая корневой уровень диаграммы. Чтобы выйти из положения, такие категории помечаются ключевым словом global в левом нижнем углу значка, показывающим, что категория по умолчанию может быть использована всеми остальными.
Диаграммы классов верхнего уровня, содержащие только категории классов, представляют архитектуру системы в самом общем виде. Такие диаграммы чрезвычайно полезны для визуализации слоев и разделов системы. Слой обозначает набор категорий классов одного уровня абстракции. Таким образом, слои представляют набор категорий классов, так же как категории классов - это кластеры классов. Слои обычно нужны, чтобы изолировать верхние уровни абстракции от нижних. Разделы обозначают связанные (каким-либо образом) категории классов на разных уровнях абстракции. В этом смысле слои представляют собой горизонтальные срезы системы, а разделы - вертикальные.
Пример. На рис. 5-7 приведен пример диаграммы классов верхнего уровня для тепличного хозяйства. Это типичная многослойная система. Здесь абстракции, которые ближе к реальности (а именно активаторы и датчики климата и удобрений), располагаются на самых нижних уровнях, а абстракции, отражающие понятия пользователя, - ближе к вершине. Категория классов ТипыПосевов - глобальна, то есть ее услуги доступны всем другим категориям. На значке категории классов Планирование показаны два ее важных класса: GardeningPlan (план выращивания) с рис. 5-5 и PlanAnalyst (анализатор планов). При увеличении любой из восьми категорий классов, показанных на рисунке, обнаружатся составляющие их классы.
Рис. 5-8. Значок параметризованного класса.
Существенное: классы и отношения между ними
Диаграмма классов показывает классы и их отношения, тем самым представляя логический аспект проекта. Отдельная диаграмма классов представляет определенный ракурс структуры классов. На стадии анализа мы используем диаграммы классов, чтобы выделить общие роли и обязанности сущностей, обеспечивающих требуемое поведение системы. На стадии проектирования мы пользуемся диаграммой классов, чтобы передать структуру классов, формирующих архитектуру системы.
Два главных элемента диаграммы классов - это классы и их основные отношения.
Классы. На рис. 5-2 показано обозначение для представления класса на диаграмме. Класс обычно представляют аморфным объектом, вроде облака [Выбор графических обозначении - это трудная задача. Требуется осторожно балансировать между выразительностью и простотой, так что проектирование значков - это в большой степени искусство, а не наука. Мы взяли облачко из материалов корпорации Intel, документировавшей свою оригинальную объектно-ориентированную архитектуру iAPX432 [6]. Форма этого образа намекает на расплывчатость границ абстракции, от которых не ожидается гладкости и простоты. Пунктирный контур символизирует то, что клиенты оперируют обычно с экземплярами этого класса, а не с самим классом. Можно заменить эту форму прямоугольником, как сделал Румбах [7]:
Однако, хотя прямоугольник проще рисовать, этот символ слишком часто используется в разных ситуациях и, следовательно, не вызывает ассоциаций. Кроме того, принятое Румбахом обозначение классов обычными прямоугольниками, а объектов - прямоугольниками с закругленными углами конфликтует с другими элементами его нотации (прямоугольники для актеров в диаграммах потоков данных и закругленные прямоугольники для состояний в диаграммах переходов). Облачко более удобно и для расположения пометок, которые, как мы увидим дальше, потребуются для абстрактных и параметризованных классов, и поэтому оно предпочтительнее в диаграммах классов и объектов. Аргумент простоты рисования прямоугольников спорен при использовании автоматизированной поддержки системы обозначений.
Но чтобы сохранить возможность простого рисования и подчеркнуть связь с методом Румбаха, мы оставляем его обозначения классов и объектов в качестве допустимой альтернативы].
Каждый класс должен иметь имя; если имя слишком длинно, его можно сократить или увеличить сам значок на диаграмме. Имя каждого класса должно быть уникально в содержащей его категории. Для некоторых языков, в особенности - для C++ и Smalltalk, мы должны требовать, чтобы каждый класс имел имя, уникальное в системе.
Рис. 5-2. Значок класса.
На некоторых значках классов полезно перечислять несколько атрибутов и операций класса. "На некоторых", потому что для большинства тривиальных классов это хлопотно и не нужно. Атрибуты и операции на диаграмме представляют прообраз полной спецификации класса, в которой объявляются все его элементы. Если мы хотим увидеть на диаграмме больше атрибутов класса, мы можем увеличить значок; если мы совсем не хотим их видеть - мы удаляем разделяющую черту и пишем только имя класса.
Как мы описывали в главе 3, атрибут обозначает часть составного объекта, или агрегата. Атрибуты используются в анализе и проектировании для выражения отдельных свойств класса [Точнее, атрибут эквивалентен отношению агрегации с физическим включением, метка которого совпадает с именем атрибута, а мощность равна в точности единице]. Мы используем следующий не зависящий от языка синтаксис, в котором атрибут может обозначаться именем или классом, или и тем и другим, и, возможно, иметь значение по умолчанию:
A - только имя атрибута;
:C - только класс;
A:C - имя и класс;
A:C=E - имя, класс и значение по умолчанию.
Имя атрибута должно быть недвусмысленно в контексте класса. В главе 3 говорилось, что операция - это услуга, предоставляемая классом. Операции обычно изображаются внутри значка класса только своим именем. Чтобы отличать их от атрибутов, к их именам добавляются скобки. Иногда полезно указать полную сигнатуру операции:
N() - только имя операции;
RN(Аргументы) - класс возвращаемого значения (R), имя и формальные параметры (если есть).
Имена операций должны пониматься в контексте класса однозначно в соответствии с правилами перегрузки операций выбранного языка реализации.
Общий принцип системы обозначений: синтаксис элементов, таких, как атрибуты и операции, может быть приспособлен к синтаксису выбранного языка программирования. Например, на C++ мы можем объявить некоторые атрибуты как статические, или некоторые операции как виртуальные или чисто виртуальные [В C++ члены, общие для всех объектов класса, объявляются статическими; виртуальной называют полиморфную операцию; чисто виртуальной называют операцию, за реализацию которой отвечает подкласс]; в CLOS мы можем пометить операцию как метод :around. В любом случае мы пользуемся спецификой синтаксиса данного языка, чтобы обозначить детали. Как описывалось в главе 3, абстрактный класс - это класс, который не может иметь экземпляров. Так как абстрактные классы очень важны для проектирования хорошей структуры классов, мы вводим для них специальный значок треугольной формы с буквой А в середине, помещаемый внутрь значка класса (рис. 5-3). Общий принцип: украшения представляют вторичную информацию о некой сущности в системе. Все подобные типы украшений имеют такой же вид вложенного треугольника.
Отношения между классами. Классы редко бывают изолированы; напротив, как объяснялось в главе 3, они вступают в отношения друг с другом. Виды отношений показаны на рис. 5-4: ассоциация, наследование, агрегация (has) и использование. При изображении конкретной связи ей можно сопоставить текстовую пометку, документирующую имя этой связи или подсказывающую ее роль. Имя связи не обязано быть глобальным, но должно быть уникально в своем контексте.
Значок ассоциации соединяет два класса и означает наличие семантической связи между ними. Ассоциации часто отмечаются существительными, например Employment (место работы), описывающими природу связи. Класс может иметь ассоциацию с самим собой (так называемая рефлексивная ассоциация). Одна пара классов может иметь более одной ассоциативной связи.
Возле значка ассоциации вы можете указать ее мощность (см. главу 3), используя синтаксис следующих примеров:
1 - В точности одна связь
N - Неограниченное число (0 или больше)
0..N - Ноль или больше
1..N - Одна или больше
Рис. 5-3. Значок абстрактного класса.
Рис. 5-4. Значки отношений между классами.
0..1 - Ноль или одна
3..7 - Указанный интервал
1..3, 7 - Указанный интервал или точное число
Обозначение мощности пишется у конца линии ассоциации и означает число связей между каждым экземпляром класса в начале линии с экземплярами класса в ее конце. Если мощность явно не указана, то подразумевается, что она не определена.
Обозначения оставшихся трех типов связи уточняют рисунок ассоциации дополнительными пометками. Это удобно, так как в процессе разработки проекта связи имеют тенденцию уточняться. Сначала мы заявляем о семантической связи между двумя классами, а потом, после принятия тактических решений об истинных их отношениях, уточняем эту связь как наследование, агрегацию или использование.
Значок наследования, представляющего отношение "общее/частное", выглядит как значок ассоциации со стрелкой, которая указывает от подкласса к суперклассу. В соответствии с правилами выбранного языка реализации, подкласс наследует структуру и поведение своего суперкласса. Класс может иметь один (одиночное наследование), или несколько (множественное наследование) суперклассов. Конфликты имен между суперклассами разрешаются в соответствии с правилами выбранного языка. Как правило, циклы в наследовании запрещаются. К наследованию значок мощности не приписывается.
Значок агрегации обозначает отношение "целое/часть" (связь "has") и получается из значка ассоциации добавлением закрашенного кружка на конце, обозначающем агрегат. Экземпляры класса на другом конце стрелки будут в каком-то смысле частями экземпляров класса-агрегата. Разрешается рефлексивная и циклическая агрегация. Агрегация не требует обязательного физического включения части в целое.
Знак использования обозначает отношение "клиент/сервер" и изображается как ассоциация с пустым кружком на конце, соответствующем клиенту. Эта связь означает, что клиент нуждается в услугах сервера, то есть операции класса-клиента вызывают операции класса-сервера или имеют сигнатуру, в которой возвращаемое значение или аргументы принадлежат классу сервера.
Пример. Описанные выше значки представляют важнейшие элементы всех диаграмм классов. В совокупности они дают разработчику набор обозначений, достаточный, чтобы описать фундамент структуры классов системы.
Рис. 5-5 показывает, как описывается в этих обозначениях задача обслуживания тепличной гидропонной системы. Эта диаграмма представляет только малую часть структуры классов системы. Мы видим здесь класс GardeningPlan (план выращивания), который имеет атрибут, названный crop (посев), одну операцию-модификатор execute (выполнить) и одну операцию-селектор canHarvest (можно собирать?). Имеется ассоциация между этим классом и классом EnvironmentalController (контроллер среды выращивания): экземпляры плана задают климат, который должны поддерживать экземпляры контроллера.
Рис. 5-5. Диаграмма классов гидропонной системы.
Эта диаграмма также показывает, что класс EnvironmentalController является агрегатом: его экземпляры содержат в точности по одному экземпляру классов Heater (нагреватель) и Cooler (охлаждающее устройство), и любое число экземпляров класса Light (лампочка). Оба класса Heater и Cooler являются подклассами абстрактного запускающего процесс класса Actuator, который предоставляет протоколы startUp и shutDown (начать и прекратить соответственно), и который использует класс Temperature.
Существенное: модули и их зависимость
Диаграмма модулей показывает распределение классов и объектов по модулям в физическом проектировании системы. Каждая отдельная диаграмма модулей представляет некоторый ракурс структуры модулей системы. При разработке мы используем диаграмму модулей, чтобы показать физическое деление нашей архитектуры по слоям и разделам.
Некоторые языки, особенно Smalltalk, не имеют ничего подобного физической архитектуре, сформированной модулями; в таких случаях диаграммы модулей не употребляют.
Основными элементами диаграммы модулей являются модули и их зависимости.
Модули. На рис. 5-32 сведены обозначения различных типов модулей. Первые три значка - это файлы, различающиеся своими функциями. Значок главной программы обозначает файл, содержащий корневую программу. В C++, например, это соответствовало бы некоторому файлу с расширением .cpp содержащему привилегированную функцию-неэлемент, называемую main. Обычно существует ровно один такой модуль на программу. Значок описания и значок тела обозначают файлы, которые содержат, соответственно, описания и реализации. В C++, например, модуль описаний соответствует заголовочному файлу с расширением .h, а модуль тела - файлу с текстом программы с расширением .cpp.
Рис. 5-32. Значки модулей и подсистем.
Смысл значка подсистемы мы раскроем в следующем разделе. Каждый модуль должен иметь имя; обычно это имя соответствующего физического файла в каталоге проекта. Как правило, такие имена пишутся без суффиксов, которые опознаются по типу значка. Если имя чересчур длинно, мы, как обычно, либо сокращаем его, либо расширяем значок. Каждое полное имя файла должно быть уникально в содержащей его подсистеме. В соответствии с правилами конкретной среды разработки, мы можем наложить ограничения на имена, такие, как условие на префиксы или требование уникальности в системе.
Каждый модуль содержит либо описание, либо определение классов и объектов, а также другие конструкции языка. По идее, "раскрыв" значок модуля на диаграмме, мы должны попасть внутрь соответствующего файла.
Рис. 5-33. Диаграмма модулей гидропонной системы.
Зависимости. Единственная связь, которая может существовать между двумя модулями, - компиляционная зависимость - представляется стрелкой, выходящей из зависимого модуля. В C++, например, мы указываем такую зависимость директивой #include. Аналогично, в Ada компиляционная зависимость указывается фразой with. В множестве компиляционных зависимостей не могут встречаться циклы. Чтобы определить частичную упорядоченность компиляций, достаточно выполнить частичное упорядочение структуры модулей системы.
Пример. На рис. 5-33 показан пример обозначений модулей, в архитектуре системы тепличного гидропонного хозяйства. Мы видим здесь шесть модулей. Два из них, climatedefs и cropdefs, являются только описаниями и служат для предоставления общих типов и констант. Остальные четыре включают в себя и тела, и описания: это типичный стиль построения диаграмм модулей, так как описания и тела очень тесно связаны. На рисунке эти две части совмещены, и зависимость тела от описания получилась скрытой, хотя реально она существует. Также оказалось скрытым имя тела, но, по нашему соглашению, имена тела и описания различаются лишь суффиксами (.cpp и .h).
Зависимости на этой диаграмме предполагают частичное упорядочение компиляции. Например, тело модуля climate зависит от описания heater, которое, в свою очередь, зависит от описания climatedefs.Существенное: подсистемы. Как объяснялось в главе 2, большие системы могут быть разложены на несколько сотен, если не тысяч, модулей. Пытаться разобраться в физической архитектуре такой системы без ее дополнительного структурирования почти безнадежно. На практике разработчики стремятся следовать неформальному соглашению собирать связанные между собой модули в структуры типа каталогов. По этим соображениям мы введем понятие подсистемы на диаграмме модулей. Подсистемы представляют собой совокупности логически связанных модулей, примерно как категория классов представляет совокупность классов.
Подсистемы. Подсистемы служат частями физической модели системы. Подсистема - это агрегат, содержащий другие модули и другие подсистемы. Каждый модуль в системе должен жить в одной подсистеме или находиться на самом верхнем уровне.
На рис. 5-32 показано обозначение подсистемы. Как и модуль, подсистема должна быть именованной. Имена подсистем подчиняются тем же правилам, что и имена модулей, хотя полное имя подсистемы обычно не содержит суффиксов.
Некоторые модули, содержащиеся в подсистеме, могут быть общедоступны, то есть экспортированы из системы и видимы снаружи. Другие модули могут быть частью реализации подсистемы и не предназначаться для использования внешними модулями. По соглашению, каждый модуль подсистемы считается общедоступным, если явно не указано обратное. Ограничение доступа к модулям реализации достигается использованием тех же обозначений, что и для ограничения доступа в категории классов.
Подсистема может зависеть от других подсистем и модулей; модуль может также зависеть от подсистемы. Для единообразия мы используем прежнее обозначение зависимости. Система имеет один высший уровень, состоящий из подсистем и модулей высшего уровня абстракции. По его диаграмме разработчик получает представление об общей физической архитектуре системы.
Пример. На рис. 5-34 показан высший уровень диаграммы модулей для нашей системы тепличного хозяйства. Раскрыв любую из показанных семи подсистем, мы обнаружим все ее модули.
Рис. 5-34. Диаграмма модулей верхнего уровня для гидропонной системы.
Рассмотрим, как связаны физическая и логическая (рис. 5-7) архитектуры этой системы. Они практически изоморфны, хотя имеются небольшие различия. В частности, мы приняли решение отделить классы устройств нижнего уровня от категорий классов Климат и Удобрения, и поместить соответствующие им модули в одну подсистему, названную Устройства. Кроме того, мы разделили категорию классов Теплица на две подсистемы, названные УправлениеКлиматом и ВнесениеУдобрений.
Дополнительные понятия
Другие типы модулей. Некоторые языки, прежде всего Ada, определяют типы модулей, отличные от простейших, показанных на рис. 5-32. Например, Ada предусматривает обобщенные пакеты, обобщенные подпрограммы и задачи как раздельно компилируемые единицы. Поэтому есть основания дополнить основные обозначения значками типов модулей, специфических для данного языка.
Сегментация. Для платформ, имеющих ограничения по адресации или физической памяти, может быть принято решение генерировать код в различных сегментах, или даже организовать оверлейную структуру. Чтобы отразить такую сегментацию обозначения диаграммы модулей можно дополнить, снабдив каждый модуль меткой, обозначающей соответствующий сегмент кода или данных.
Спецификации. Так же, как диаграммы классов и объектов, каждый элемент диаграммы модулей может иметь спецификацию, которая определяет его полностью. Спецификации модулей и их зависимостей содержат только ту информацию, которая уже описана в этом разделе, поэтому мы не будем их рассматривать.
В интегрированной инструментальной среде, поддерживающей наши обозначения, разумно использовать диаграммы модулей для визуализации программных модулей системы. "Раскрытие" модуля или подсистемы на диаграмме модулей открывает соответствующий физический файл или каталог и наоборот.
5.7. Диаграммы процессов.
Существенное: процессоры, устройства и соединения
Диаграммы процессов используются, чтобы показать распределение процессов по процессорам в физическом проекте системы. Отдельная диаграмма процессов показывает один ракурс структуры процессов системы. При разработке проекта мы используем диаграмму процессов, чтобы показать физическую совокупность процессоров и устройств, обеспечивающих работу системы.
Основные элементы диаграммы процессов - процессоры, устройства и их соединения.
Процессоры. На рис. 5-35 показано обозначение процессора. Процессор - часть аппаратуры, способная выполнять программы. Каждый процессор должен иметь имя; никаких особых ограничений на имена процессоров нет, так как они обозначают "железо", а не программы.
Мы можем дополнить значок процессора списком процессов. Каждый процесс в таком списке обозначает или главную программу (функцию main из диаграммы модулей) или имя активного объекта (из диаграммы объектов).
Устройства. На рис. 5-35 показано обозначение устройства. Устройство - это часть аппаратной платформы, не способная выполнять программы (по крайней мере, в нашей логической модели). Как и процессорам, устройствам требуются имена, на которые не накладывается никаких существенных ограничений.
Рис. 5-35. Значки процессора и устройства нашей логической модели.
Соединения. Процессоры и устройства должны сообщаться друг с другом. На диаграмме процессов мы изображаем соединения между ними ненаправлеными линиями. Соединение обычно представляет непосредственную связь между аппаратурой, например, RS232, Ethernet, или даже доступ к разделяемой памяти, но эта связь может быть и не прямой, например, "Земля-спутник". Соединения обычно считаются двунаправленными, но при необходимости к их обозначению можно добавить стрелку, чтобы указать направление. Любое соединение может иметь необязательную именующую его метку.
Пример. На рис. 5-36 представлен пример использования этих обозначений, описывающий физическую архитектуру тепличной системы. Мы видим, что разработчики решили использовать четыре компьютера, один в качестве рабочего места оператора и по одному на каждую теплицу. Процессы, запущенные на выделенных теплицам компьютерах, не могут сообщаться друг с другом непосредственно, а только через рабочую станцию. Для простоты мы решили не показывать на этой диаграмме никаких устройств, хотя предполагаем, что система содержит большое число исполнительных устройств и датчиков.
Дополнительные понятия
Обозначения. На рис. 5-35 показаны стандартные обозначения, которые мы используем для процессора и устройства, но разумно и даже желательно учесть возможность их изменения. Например, можно было бы ввести специальные значки для встроенного микрокомпьютера (процессор), диска, терминала и выпрямителя тока (устройства), и использовать их на диаграммах процессов вместо стандартных.
Поступая таким образом, мы предлагаем визуализацию физической платформы нашей реализации, которая предназначена непосредственно техникам и системщикам, а также конечным пользователям системы, которые, вероятно, не являются специалистами в разработке программного обеспечения.
Вложенность. Физическая конфигурация системы бывает очень сложной и может представлять собой иерархию процессоров и устройств. В таких случаях полезно иметь возможность выделить группы процессоров, устройств и соединений, так же, как категории классов представляют логическое группирование классов и объектов. Мы изображаем такие группы именованными пунктирными прямоугольниками с закругленными углами. Мы можем раскрыть такой значок на диаграмме процессов и обнаружить вложенные процессоры и устройства. Не представляет затруднений определить соединения между этими группами.
Рис. 5-36. Диаграмма процессов гидропонной системыми с закругленными углами.
Планирование процессов. Мы должны некоторым образом определить порядок выполнения процессов на каждом процессоре. Имеется пять основных способов планирования, и мы можем указать на диаграмме для каждого процессора, какой из них использован, добавив к его значку одно из следующих имен:
• Вытесняющее | Процесс с более высоким приоритетом, может отнимать процессор у исполняемого процесса с более низким приоритетом; обычно процессы с одинаковым приоритетом получают равные промежутки времени для выполнения, так что вычислительные ресурсы распределены справедливо. |
• Невытесняющее | Текущий процесс продолжает выполняться на процессоре до тех пор, пока сам не уступит контроль над ним. |
• Циклическое | Процессам по очереди выделяется равное количество процессорного времени, обычно называемое квантом времени, по истечении которого управление передается другому процессу. Процесс может получать время в квантах и подквантах. |
• Алгоритмическое | Переключением процессов управляет некоторый алгоритм. |
• Ручное | Пользователь извне системы управляет переключением процессов. |
Для более подробного описания диспетчеризации процессов на конкретном процессоре бывает полезно привести диаграмму объектов или взаимодействий, особенно если используется алгоритмическое переключение.
Спецификации. По аналогии с элементами других диаграмм, процессоры, устройства и соединения могут иметь спецификации, которые дают их исчерпывающее определение. Всю информацию, включаемую в эти спецификации, мы уже обсудили в текущем разделе.
5.8. Применение системы обозначений
Результат объектно-ориентированного проектирования
Обычно результатами анализа системы будут наборы диаграмм объектов (чтобы выразить поведение системы через сценарии), диаграмм классов (чтобы выразить роли и обязанности агентов по поддержанию заданного поведения системы) и диаграммы состояний и переходов (чтобы показать упорядоченное событиями поведение этих агентов). Проектирование системы, в которое входит разработка ее архитектуры и реализации, порождает диаграммы классов, объектов, модулей, процессов, а также динамические ракурсы этих диаграмм.
Существует сквозная связь между этими диаграммами, позволяющая нам проследить требования от реализации обратно к спецификации. Начав с диаграмм процессов, можно найти главную программу, которая определена на некоторой диаграмме модулей. Эта диаграмма модулей содержит наборы классов и объектов, определения которых мы найдем на подходящих диаграммах классов или объектов. Наконец, определения отдельных классов указывают на наши исходные требования, потому что эти классы, в общем, непосредственно отражают словарь предметной области.
Описанной в этой главе системой обозначений можно пользоваться вручную, хотя, конечно, она просто напрашивается на автоматизацию. Автоматизированным инструментам проектирования можно поручить проверку целостности, ограничений и полноты документации. Они также помогают разработчику легко и быстро просматривать результаты анализа и разработки. Например, глядя на диаграмму модулей, разработчик может пожелать выяснить устройство конкретного механизма, и автоматизированный инструмент поможет ему отыскать все классы, объявленные в каком-то модуле.
А от диаграммы объектов, описывающей сценарий, в котором использован один из классов, разработчик может перейти к структуре наследования этого класса. Наконец, если в сценарии есть активный объект, разработчик может использовать автоматизированный инструмент проектирования, чтобы отыскать процессор, которому выделен соответствующий поток управления, и увидеть анимированное поведение конечного автомата класса на этом процессоре. Использование автоматизированных инструментов позволяет освободить разработчика от бремени согласования деталей, позволяя ему сосредоточиться на творческих аспектах процесса проектирования.
Увеличение и уменьшение масштаба
Мы считаем, что описанная здесь система обозначений годится как для маленьких систем, содержащих несколько классов, так и для больших проектов с несколькими тысячами классов. Как мы покажем в следующих двух главах, эта система обозначений особенно удобна для организации итерационного процесса разработки. К диаграммам не следует относиться как к застывшему догмату, а скорее наоборот, нужно постоянно отражать на них все новые решения, принятые в процессе проектирования.
Мы также считаем, что эта система обозначений годится для реализации на разных языках объектно-ориентированного программирования.
В этой главе были описаны основные результаты процесса объектно-ориентированного проектирования, включая синтаксис и семантику. В двух последующих главах процесс разработки будет рассмотрен подробнее. Оставшиеся пять глав повествуют о применении метода на практике.
Выводы
Проектирование - это не рисование диаграмм; диаграммы просто отражают результаты проектирования.
При проектировании сложной системы важно рассмотреть ее в различных ракурсах - как с точки зрения логической/физической структуры, так и статической/динамической семантики.
Система обозначений объектно-ориентированного проектирования включает четыре основных диаграммы (классов, объектов, модулей, процессов) и две дополнительные (состояний и переходов, взаимодействий).
Диаграмма классов показывает, какие существуют классы и связи между ними в логической структуре системы. Конкретная диаграмма классов - один из ракурсов полной структуры классов системы.
Диаграмма объектов показывает, какие существуют объекты и связи между ними в логической структуре системы. Диаграмма объектов используется для представления сценария.
Диаграмма модулей показывает распределение классов и объектов по модулям в физической структуре системы. Диаграмма модулей - один из ракурсов модульной архитектуры системы.
Диаграмма процессов показывает распределение процессов по процессорам в физической структуре системы. Каждая диаграмма процессов - один из ракурсов архитектуры процессов системы.
Диаграмма состояний и переходов показывает: (1) пространство состояний экземпляров данного класса; (2) события, которые влекут переход из одного состояния в другое; (3) действия, которые происходят при изменении состояния.
Диаграмма взаимодействий позволяет следить за выполнением сценария в контексте диаграммы объектов.
Дополнительная литература
Со времени выхода первого издания этой книги я без устали старался ввести в метод Буча лучшие элементы обозначении, принадлежащие другим методологам, особенно Румбаху (Rumbaugh) и Джекобсону (Jacobson), удалял и упрощал элементы, неудачные или имеющие сомнительную пользу. В то же время, концептуальное единство системы обозначений береглось как зеница ока. Данная глава - кульминация этих усилий.
Об обозначениях в разработке программного обеспечения написано чрезвычайно много; книга Мартина и МакКлюра (Martin and McClure) [H 1988] служит хорошим общим справочником по многим традиционным подходам. Грэхам (Graham) [F 1991] дал обзор ряда нотаций, специфичных для объектно-ориентированных методов.
Ранняя форма описанной в этой главе системы обозначений была впервые документирована в работе Буча (Booch) [F 1981]. Эта система в дальнейшем развилась и включила выразительные средства: семантических сетей (Стилингс и др. (Stillings et al.) [A 1987] и Барри Фейгенбаум (Barrand Feigenbaum) [J 1981]),диаграммы "сущность-отношение" (Чэн (Chen) [Е 1976]), модели сущности (Росс (Ross) [F 1987]), сети Петри (Petri) (Пе-терсон (Peterson) [J 1977], Сахару (Sahraoui) [F 1987] и Бруон и Балзамо (Bruon and Balsamo) [F 1986]), ассоциации (Румбах (Rumbaugh) [F 1991]) и карты состояний (Харел (Harel) [F 1987]).Особенно интересна работа Румбаха, поскольку, как он заметил, в наших подходах больше сходства, чем различий.
Значки для объектов и классов были инспирированы iAPX 432 [D 1981]. За основу изображения для объектных диаграмм были взяты обозначения Сейдвица (Seidewitz) [F 1985]. Для семантики параллельности были приспособлены обозначения Бура (Buhr) [F1988,1989].
Чэн (Chang) [G 1990] дал хороший обзор более общих аспектов визуальных языков.
Существенное: объекты и их отношения
Диаграмма объектов показывает существующие объекты и их связи в логическом проекте системы. Иначе говоря, диаграмма объектов представляет собой мгновенный снимок потока событий в некоторой конфигурации объектов. Таким образом, диаграммы объектов являются своего рода прототипами: каждая представляет взаимодействие или структурные связи, которые могут возникнуть у данного множества экземпляров классов, безотносительно к тому, какие конкретно экземпляры участвуют в этом взаимодействии. В таком смысле, отдельная диаграмма объектов есть ракурс структуры объектов системы. При анализе мы используем диаграммы объектов для показа семантики основных и второстепенных сценариев, которые отслеживают поведение системы. При проектировании мы используем диаграммы объектов для иллюстрации семантики механизмов в логическом проектировании системы. Существенные элементы диаграммы объектов - объекты и их отношения.
Рис. 5-23. Значок объекта.
Объекты. На рис. 5-23 показан значок, который изображает объект на диаграмме объектов. Как и в диаграммах классов, можно провести горизонтальную линию, разделяющую текст внутри значка объекта на две части: имя объекта и его атрибуты.
Имя объекта следует синтаксису для атрибутов, и может быть либо записано в одной из трех следующих форм:
A - только имя объекта
:C - только класс объектов
A:C - имя объекта и класса
либо использовать синтаксис выбранного языка реализации. Если текст не умещается внутри значка, то следует или увеличить значок, или сократить текст. Если несколько значков объектов на одной диаграмме используют одно и то же неквалифицированное имя (то есть имя без указания класса), то они означают один и тот же объект. В противном случае каждый значок означает отдельный объект [На одной диаграмме могут присутствовать значки объектов с одинаковыми неквалифицированными именами, но относящиеся к разным классам, в том случае, если эти классы имеют общего предшественника. Это позволяет представить распространение операций от подкласса к суперклассу и наоборот].
Если на разных диаграммах есть объекты с одинаковыми неквалифицированными именами, то это разные объекты, если только их имена не квалифицированы явно.
Смысл неквалифицированных имен зависит от контекста диаграммы объектов. Более точно: диаграммы объектов, определенные на самом верхнем уровне системы, имеют глобальную область видимости; другие диаграммы объектов могут быть определены для категорий классов, отдельных классов или отдельных методов, а, значит, иметь соответствующие области видимости. Квалифицированное имя может быть использовано при необходимости явной ссылки на глобальные объекты, переменные классов (статические элементы в C++), параметры методов, атрибуты или локально определенные объекты в той же области видимости.
Если не указать класс объекта - ни явно, использовав ранее упомянутый синтаксис, ни косвенно, через спецификацию объекта, - то класс рассматривается как анонимный, при этом нельзя провести семантическую проверку ни операций, совершаемых над объектом, ни его связей с другими объектами на диаграмме. Если же указать только класс, то анонимным считается объект. Каждый значок без имени объекта обозначает отдельный анонимный объект.
В любом случае, имя класса объекта должно быть именем настоящего класса (или любого из его суперклассов) в области видимости диаграммы, использованного для инстанцирования объекта, даже если этот класс - абстрактный. Эти правила позволяют написать сценарий, не зная точно, к каким подклассам принадлежат объекты.
Рис. 5-24. Значок связи между объектами.
На значках объектов бывает полезно указать несколько их атрибутов. "Несколько" - так как значок объекта представляет только один какой-то ракурс его структуры. Синтаксис атрибутов совпадает с описанным ранее синтаксисом атрибутов класса и позволяет указать выражение, используемое по умолчанию. Имена атрибутов объектов должны соответствовать атрибутам, определенным в классе объекта, или в любом из его суперклассов. Синтаксис имен атрибутов может быть приспособлен к синтаксису языка реализации.
Диаграмма объектов может также включать значки, обозначающие утилиты классов и метаклассы: эти понятия подобны объектам, так как они могут действовать как объекты, и с ними можно оперировать как с объектами.
Отношения между объектами. Как говорилось в главе 3, объекты взаимодействуют с другими объектами через связи, обозначение которых показано на рис. 5-24. Подобно тому, как объект является экземпляром класса, связь является экземпляром ассоциации.
Связь между двумя объектами (включая утилиты классов и метаклассы) может существовать тогда и только тогда, когда существует ассоциация между соответствующими классами. Ассоциация между классами может проявляться различными способами, например, как простая ассоциация, отношение наследования или отношение включения. Следовательно, существование ассоциаций между двумя классами означает существование коммуникации (то есть канала связи) между их экземплярами, по которой объекты могут посылать друг другу сообщения. Все классы неявно имеют ассоциации сами с собой и, следовательно, объект может послать сообщение сам себе.
Пусть имеются объекты A и B и связь L между ними. Тогда A может вызвать любую операцию, имеющуюся в классе B и доступную A. То же верно для операций над A, вызываемых B. Объект, вызывающий операцию, называется объект-клиент, а объект, который предоставляет операцию, - объект-сервер. Обычно отправитель сообщения знает получателя, но обратное необязательно.
В установившемся состоянии структуры классов и объектов системы должны быть согласованы. Если мы показываем на диаграмме операцию M на классе B, вызванную по связи L, то м должна быть объявлена в спецификации B, или в спецификациях его суперклассов.
Как показано на рис. 5-24, рядом с соответствующей связью на диаграмме можно записать набор сообщений. Каждое сообщение состоит из следующих трех элементов:
D - символ синхронизации, обозначающий направление вызова
M - вызов операции или извещение о событии
S - необязательный порядковый номер.
Мы показываем направление сообщения стрелкой, указывающей на объект-сервер.
Этот символ означает простейшую форму передачи сообщений, семантика которой гарантирована только в присутствии единственного потока контроля. Существуют более развитые формы синхронизации, которые применимы в случае нескольких потоков. О них мы расскажем в следующем разделе.
Вызов операции - наиболее общая форма сообщения. Она подчиняется ранее описанному синтаксису операций, но, в отличие от него, здесь могут быть приведены фактические параметры, подходящие к сигнатуре операции:
N() - только имя операции
RN(arguments) - возвращаемое значение, имя и фактические параметры операции.
Сопоставление фактических параметров с формальными осуществляется в порядке следования. Если возвращаемый операцией объект или фактические параметры используют неквалифицированные имена, совпадающие с другими неквалифицированными именами на диаграмме, то подразумевается, что они именуют одинаковые объекты, а следовательно, их классы должны подходить к сигнатуре операции. Таким образом, мы можем представлять взаимодействия, в ходе которых объекты передаются в качестве параметров или возвращаются, как результат операции.
Сообщение может извещать о событии. Оно подчиняется определенному ранее синтаксису событий, и, следовательно, может представлять символьное имя, объект или имя некоторой операции. Во всяком случае, имя события должно быть определено на соответствующей классу объекта-сервера диаграмме переходов и состояний. Если извещение о событии является операцией, то оно может включать фактические параметры.
Если порядковый номер явно не указан, то сообщение может быть послано независимо от других сообщений, указанных на данной диаграмме объектов. Чтобы указать явный порядок событий, мы можем их пронумеровать. Нумерация начинается с единицы и добавляется как необязательный префикс к вызову операции или извещению о событии. Порядковый номер показывает относительный порядок посылки сообщений. Сообщения с одинаковыми номерами не упорядочены друг относительно друга; сообщение с меньшим порядковым номером посылается до сообщения с большим номером.
Повторение порядковых номеров или их отсутствие говорит о частичной упорядоченности сообщений.
Пример. На рис. 5-25 показана диаграмма объектов для нашего тепличного хозяйства в контексте категории классов Planning (планирование; описана на рис. 5-7). Цель этой диаграммы - проиллюстрировать сценарий выполнения обычной функции системы, а именно, прогнозирование затрат на сбор урожая некоторого посева.
Выполнение этой функции требует сотрудничества нескольких различных объектов. Сценарий начинается с вызова объектом PlanAnalyst (анализатор планов) операции timeToHarvest() (время собирать урожай) над утилитой класса PlanMetrics (параметры планов). При этом объект с передается как фактический параметр операции. Затем утилита PlanMetrics вызывает операцию status() (состояние) на некотором неименованном объекте класса GardeningPlan (план выращивания). В пояснении говорится: "Надо проверить, что этот план действительно выполняется". В свою очередь, объект GardeningPlan вызывает операцию maturationTime() (время созревания) на выбранном объекте класса GrainCrop (посев зерновых), запрашивающую ожидаемое время созревания посева. Когда эта операция-селектор будет выполнена, управление возвращается объекту класса PlanAnalyst, который затем непосредственно вызывает операцию C.yield(), унаследованную от суперкласса (операция Crop::yield()). Управление снова возвращается объекту класса PlanAnalyst, который продолжает сценарий, выполняя над собой операцию netCost().
Рис. 5-25. Диаграмма объектов гидропонной системы.
На диаграмме показана связь между объектами классов PlanAnalyst и GardeningPlan. Хотя сообщения между ними не посылаются, связь отражает существование семантической зависимости между этими объектами.
Существенное: объекты и их взаимодействия
Диаграмма взаимодействии используется, чтобы проследить выполнение сценария в том же контексте, что и диаграмма объектов [Эти диаграммы обобщают диаграммы трассировки событий Румбаха и диаграммы взаимодействий Джекобсона [15]]. В известной степени диаграмма взаимодействия есть просто другой способ представления диаграммы объектов. Например, на рис. 5-30 мы видим диаграмму взаимодействия, которая дублирует большую часть семантики диаграммы объектов, показанной на рис. 5-25. Преимущество диаграммы взаимодействий в том, что на ней легче читается порядок посылки сообщений, а преимущество диаграммы объектов в том, что она лучше подходит для многих объектов со сложными вызовами и допускает включение другой информации: связи, значения атрибутов, роли, блок-схемы и видимость. Так как оба типа диаграмм имеют неоспоримые достоинства, мы пользуемся в нашем методе обоими [Диаграммы объектов и диаграммы взаимодействий настолько близки по семантике, что инструментальные средства могут генерировать одну диаграмму из другой с минимальной потерей информации].
Диаграммы взаимодействия не вводят новых понятий или обозначений. Скорее, они берут существенные элементы диаграммы объектов и перестраивают их. Как показывает рис. 5-30, диаграмма взаимодействий внешне напоминает таблицу. Имена объектов диаграммы взаимодействий (те же, что и на диаграмме объектов) записываются горизонтально в верхней ее строке. Под каждым из них рисуется вертикальная пунктирная линия. Отправления сообщений (которые могут обозначать события или вызовы операций) показываются горизонтальными стрелками, с тем же синтаксисом и обозначениями синхронизации, что и на диаграмме объектов. Линия, обозначающая посылку сообщения, проводится от вертикали клиента к вертикали сервера. Первое сообщение показывается на самом высоком уровне, второе ниже и т.д., таким образом отпадает надобность в их порядковых номерах.
Диаграммы взаимодействий часто лучше диаграмм объектов передают семантику сценариев на ранних этапах жизненного цикла разработки, когда еще не идентифицированы протоколы отдельных классов. Как мы расскажем в следующей главе, в начале разработки диаграммы взаимодействий обычно сконцентрированы скорее на событиях, чем на операциях, потому что события помогают определить границы системы. Когда же уточнились структуры классов, акцент смещается к диаграммам объектов, семантика которых более выразительна.
Рис. 5-31. Пояснения и переход управления.
Существенное: состояния и переходы
Диаграмма состоянии и переходов показывает: пространство состояний данного класса; события, которые влекут переход из одного состояния в другое; действия, которые происходят при изменении состояния. Мы приспособили обозначения, использованные Харелом [11]: его работа предоставляет простой, но очень выразительный подход, который гораздо эффективнее традиционных автоматов с конечным числом состояний [Мы дополнили его работу применительно к объектно-ориентированному программированию, следуя предложениям Румбаха [12] и Беара и др. [13]]. Отдельная диаграмма состояний и переходов представляет определенный ракурс динамической модели отдельного класса или целой системы. Мы строим диаграммы состояний и переходов только для классов, поведение которых (управляемое событиями) для нас существенно. Мы можем также представить диаграмму состояний и переходов для управляемого событиями поведения системы в целом. Эти диаграммы используются в ходе анализа, чтобы показать динамику поведения системы, а в ходе проектирования - для выражения поведения отдельных классов или их взаимодействия.
Рис. 5-18. Значок состояния.
Два основных элемента диаграммы состояний и переходов - это, естественно, состояния и переходы между ними.
Состояния. Состояние представляет собой итоговый результат поведения системы. Например, только что включенный в сеть телефон находится в начальном состоянии: его предыдущее поведение несущественно, при этом он готов к тому, чтобы позвонить или принять звонок. Если кто-нибудь поднимет трубку, телефон перейдет в состояние готовности к набору номера; в этом состоянии мы не ожидаем, что телефон зазвонит, но приготовились к беседе с одним или несколькими абонентами. Если кто-либо наберет ваш номер, а телефон находится в начальном состоянии (трубка положена), то когда вы поднимете трубку, телефон перейдет в состояние с установленным соединением, и вы сможете поговорить со звонившим.
В любой момент времени состояние объекта определяет набор свойств (обычно статический) объекта и текущие (обычно динамические) значения этих свойств.
Под "свойствами" подразумевается совокупность всех связей и атрибутов объекта. Мы можем обобщить понятие состояния так, чтобы оно было применимо и к объекту, и к классу, так как все объекты одного класса "живут" в одном пространстве состояний. Это пространство может представлять собой неопределенное, хотя конечное множество возможных (но не всегда ожидаемых или желаемых) состояний. На рис. 5-18 показано обозначение, которое мы используем для отдельного состояния.
Каждое состояние должно иметь имя; если оно оказывается слишком длинным, то его можно сократить или увеличить значок состояния. Каждое имя состояния должно быть уникально в своем классе. Состояния, ассоциированные со всей системой, глобальны, то есть видимы отовсюду, а область видимости вложенных состояний (дополнительное понятие) - ограничена соответствующей подсистемой. Все одноименные значки состояний на одной диаграмме обозначают одно и то же состояние.
На значках некоторых состояний полезно указать ассоциированные с ними действия. Как показано на рис. 5-18, действия обозначаются так же, как атрибуты и операции в значке класса. Мы можем увеличить значок, чтобы увидеть весь список действий, или, если нет необходимости указывать действия, можно удалить разделяющую линию и оставить только имя [Для совместимости с обозначениями Харела разделяющую линию можно вообще убрать]. Ассоциацию действий с состояниями мы обсудим позднее.
Рис. 5-19. Значок перехода из состояния в состояние.
Переходы. Событием мы называем любое происшествие, которое может быть причиной изменения состояния системы. Изменение состояний называется переходом. На диаграмме переходов и состояний он изображается значком, показанным на рис. 5-19. Каждый переход соединяет два состояния. Состояние может иметь переход само в себя; обычно есть несколько различных переходов в одно и тоже состояние, но все переходы должны быть уникальны в том смысле, что ни при каких обстоятельствах не может произойти одновременно два перехода из одного состояния.
Например, в поведении гидропонной теплицы играют роль следующие события:
Посажена новая партия семян.
Урожай созрел и готов к сбору.
Из-за плохой погоды упала температура в теплице.
Отказало охлаждающее устройство.
Наступил заданный момент времени.
Как будет рассказано в следующей главе, идентификация событий, подобных этим, позволяет определить границы поведения системы и распределить обязанности по осуществлению этого поведения между отдельными классами.
Каждое из первых четырех перечисленных выше событий, вероятно, вызывает некоторое действие - например, начало или остановку выполнения некоторого плана сельскохозяйственных работ по посеву, включение нагревателя или посылку сигнала тревоги технику, обслуживающему систему. Отсчет времени - это другое дело: хотя секунды и минуты не имеют значения (посевы растут, очевидно, не так быстро), наступление нового часа или суток может вызвать некоторый сигнал, например, включить/выключить лампочки и изменить температуру в теплице, чтобы имитировать смену дня и ночи, необходимую для роста растений.
Действием мы называем операцию, которая, с практической точки зрения, требует нулевого времени на выполнение. Например, включение сигнала тревоги - действие. Обычно действие означает вызов метода, порождение другого события, запуск или остановку процесса. Деятельностью мы называем операцию, требующую некоторого времени на свое выполнение. Например, нагрев воздуха в теплице - деятельность, запускаемая включением нагревателя, который может оставаться включенным неопределенное время, до тех пор, пока не будет выключен явной командой.
Модель событий, передающих сообщения, которую предложил Харел, концептуально безупречна, но ее нужно приспособить к объектному подходу. При анализе мы можем давать предварительные названия событиям и действиям, в общих чертах отражая наше понимание предметной области. Однако, отображая эти понятия на классы, мы должны предложить конкретную стратегию реализации.
Событие может быть представлено символическим именем (или именованным объектом), классом или именем некоторой операции.
Например, событие CoolerFailure ( неисправность охлаждающего устройства) может обозначать либо литерал, либо имя объекта. Мы можем придерживаться той стратегии, что все события являются символическими именами и каждый класс с поведением, управляемым событиями, имеет операцию, которая распознает эти имена и выполняет соответствующие действия. Такая стратегия часто используется в архитектурах типа модель-представление-котроллер (model-view-controller), которая пришла из языка Smalltalk. Для большей общности можно считать события объектами и определить иерархию классов, которые представляют собой абстракции этих событий. Например, можно определить общий класс событий DeviceFailure (неисправность устройства) и его специализированные подклассы, такие как CoolerFailure (неисправность охлаждающего устройства) и HeaterFailure (неисправность нагревателя). Теперь извещение о событии можно связать с экземпляром класса-листа (например, CoolerFailure) или более общего суперкласса (DeviceFailure). И если выполнение некоторого действия назначено только при возникновении события класса CoolerFailure, то это означает, что все другие случаи отказа устройств должны намеренно игнорироваться. С другой стороны, если выполнение действия связано с событием DeviceFailure, то действие должно выполняться независимо от того, на каком устройстве произошел сбой. Продолжая в том же духе, мы можем сделать так, чтобы переходы из состояния в состояние были полиморфны относительно классов событий. Наконец, можно определить событие просто как операцию, такую как GardeningPlan::execute(). Это похоже на подход, который трактует события как имена, но в отличие от него здесь не требуется явного диспетчера событий.
Для нашего метода несущественно, какая из этих стратегий выбрана для разработки, если она последовательно проводится во всей системе. Обычно в замечаниях указывается, какая стратегия использована для данного конкретного автомата.
Действие можно записывать, используя синтаксис, показанный в следующих примерах:
heater.startUp() - действие
DeviceFailure - произошло событие
start Heating - начать некоторую деятельность
stop Heating - прекратить деятельность.
Имена операций или событий должны быть уникальны в области видимости диаграммы; там, где необходимо, они могут быть квалифицированы соответствующими именами классов или объектов. В случае начала или прекращения некоторой деятельности, она может быть представлена операцией (такой, как Actuator::shutDown()) или символическим именем (для событий). Когда деятельность соответствует некоторой функции системы, такой, как harvest crop (сбор урожая), мы обычно пользуемся символическими именами.
На каждой диаграмме состояний и переходов должно присутствовать ровно одно стартовое состояние; оно обозначается немаркированным переходом в него из специального значка, изображаемого в виде закрашенного кружка. Иногда бывает нужно указать также конечное состояние (обычно автомат, ассоциированный с классом или системой в целом, никогда не достигает конечного состояния; этот автомат просто перестает существовать после того, как содержащий его объект уничтожается). Мы обозначаем конечное состояние, рисуя немаркированный переход от него к специальному значку, изображаемому как кружок с закрашенной серединой.
Рис. 5-20. Диаграмма состояний и переходов для контроллера тепличной среды (EnvironmentalController).
Пример. До сих пор вводились значки, описывающие существенные элементы диаграмм состояний и переходов. В совокупности они предоставляют разработчику систему обозначений, достаточную для моделирования простого конечного плоского автомата, пригодного для описания приложений с ограниченным числом состояний. Системы, имеющие много состояний или обладающие сильно запутанным событийным поведением, которое описывается переходами по условию или в результате предыдущих состояний, требуют для построения диаграмм переходов более сложных понятий.
На рис. 5-20 показан пример использования существенных обозначений. Пример опять описывает гидропонную систему.
Мы видим диаграмму состояний и переходов для класса EnvironmentalController, впервые введенного на рис. 5-5.
На этой диаграмме все события представляются символическими именами. Мы видим, что все объекты этого класса начинают свою жизнь в начальном состоянии Idle (ожидание); затем они изменяют свое состояние по событию Define climate, для которого не предполагается явных действий (считается, что это событие, то есть ввод климатического задания, происходит только в дневное время). Дальше динамическое поведение этого класса состоит в переключении между со-стояниями Daytime и Nighttime (день и ночь); оно определяется событиями Sunrise и Sunset (восход и закат) соответственно; с этими событиями связаны действия по изменению освещения. В обоих состояниях событие понижения или повышения температуры в теплице вызывает обратную реакцию (операция adjustTemperature(), которая является локальной в этом классе). Мы возвращаемся в состояние Idle, когда поступит событие Terminate climate, то есть будет отменено климатическое задание.
Связи
Семантика. Мы заимствуем понятие связи у Румбаха, который определяет его как "физическое или концептуальное соединение между объектами" [16]. Объект сотрудничает с другими объектами через связи, соединяющие его с ними. Другими словами, связь - это специфическое сопоставление, через которое клиент запрашивает услугу у объекта-сервера или через которое один объект находит путь к другому.
На рис. 3-2 показано несколько разных связей. Они отмечены линиями и означают как бы пути прохождения сообщений. Сами сообщения показаны стрелками (соответственно их направлению) и помечены именем операции. На рисунке объект aController связан с двумя объектами класса DisplayItem (объекты a и b). В свою очередь, оба, вероятно, связаны с aView, но нам была интересна только одна из этих связей. Только вдоль связи один объект может послать сообщение другому.
Связь между объектами и передача сообщений обычно односторонняя (как на рисунке; хотя технически она может быть и взаимной). Как мы увидим в главе 5, подобное разделение прав и обязанностей типично для хорошо структурированных объектных систем [На самом деле организация объектов, показанная на рис. 3-2, встречается настолько часто, что ее можно считать типовым проектным решением. В Smalltalk аналогичный механизм известен как MVC, model/view/controller (модель/представление/контроллер). Как мы увидим в следующей главе, хорошо структурированные системы имеют много таких опознаваемых типовых решений]. Заметьте также, что хотя передаваемое сообщение инициализировано клиентом (в данном случае aController), данные передаются в обоих направлениях. Например, когда aController вызывает операцию move для пересылки данных объекту а, данные передаются от клиента серверу, но при выполнении операции isUnder над объектом b, результат передается от сервера клиенту.
Участвуя в связи, объект может выполнять одну из следующих трех ролей:
• Актер [Actor - это деятель, исполнитель. А исполнитель ролей, это и есть актер. - Примеч. ред.].
Объект может воздействовать на другие объекты, но сам никогда не подвергается воздействию других объектов; в определенном смысле это соответствует понятию активный объект.
Рис. 3-2. Связи.
• Сервер. Объект может только подвергаться воздействию со стороны других объектов, но он никогда не выступает в роли воздействующего объекта.
• Агент. Такой объект может выступать как в активной, так и в пассивной роли; как правило, объект-агент создается для выполнения операций в интересах какого-либо объекта-актера или агента.
На рис. 3-2 объект aController выступает как актер, объект a - как агент и объект aView - как сервер.
Пример. Во многих промышленных процессах требуется непрерывное изменение температуры. Необходимо поднять температуру до заданного значения, выдержать заданное время и понизить до нормы. Профиль изменения температуры у разных процессов разный; зеркало телескопа надо охлаждать очень медленно, а закаляемую сталь очень быстро.
Абстракция нагрева имеет достаточно четкое поведение, что дает нам право на описание такого класса. Сначала определим тип, значение которого задает прошедшее время в минутах.
// Число прошедших минут
typedef unsigned int Minute;
Теперь опишем сам класс TemperatureRamp, который по смыслу задает функцию времени от температуры:
class TemperatureRamp {
public: TemperatureRamp();
virtual ~TemperatureRamp();
virtual void clear();
virtual void bind (Temperature, Minute);
Temperature TemperatureAt (Minute);
protected:
...
};
Выдерживая наш стиль, мы описали некоторые из операций виртуальными, так как ожидаем, что этот класс будет иметь подклассы.
На самом деле в смысле поведения нам надо нечто большее, чем просто зависимость температуры от времени. Пусть, например, известно, что на 60-й минуте должна быть достигнута температура 250¦F, а на 180-й - 150¦F. Спрашивается, какой она должна быть на 120-й минуте? Это требует линейной интерполяции, так что требуемое от абстракции поведение усложняется.
Вместе с тем, управления нагревателем, поддерживающего требуемый профиль, мы от этой абстракции не требуем.
Мы предпочитаем разделение понятий, при котором нужное поведение достигается взаимодействием трех объектов: экземпляра TemperatureRamp, нагревателя и контроллера. Класс TemperatureController можно определить так:
class TemperatureController {
public: TemperatureController(Location);
~TemperatureController();
void process(const TemperatureRamp&);
Minute schedule(const TemperatureRamp&) const;
private:
...
};
Тип Location был определен в главе 2. Заметьте, что мы не ожидаем наследования от этого класса и поэтому не объявляем в нем никаких виртуальных функций.
Операция process обеспечивает основное поведение этой абстракции; ее назначение - передать график изменения температуры нагревателю, установленному в конкретном месте. Например, объявим:
TemperatureRamp growingRamp;
TemperatureController rampController(7);
Теперь зададим пару точек и загрузим план в контроллер::
growingRamp.bind (250, 60);
growingRamp.bind(150, 180);
rampController.process(growingRamp);
В этом примере rampController - агент, ответственный за выполнение температурного плана, он использует объект growingRamp как сервер. Эта связь проявляется хотя бы в том, что rampController явно получает growingRamp в качестве параметра одной из своих операций.
Одно замечание по поводу нашего стиля. На первый взгляд может показаться, что наши абстракции - лишь объектные оболочки для элементов, полученных в результате обычной функциональной декомпозиции. Пример операции schedule показывает, что это не так. Объекты класса TemperatureController имеют достаточно интеллекта, чтобы определять расписание для конкретных профилей, и мы включаем эту операцию как дополнительное поведение нашей абстракции. В некоторых энергоемких технологиях (например, плавка металлов) можно существенно выиграть, если учитывать остывание установки и тепло, остающееся после предыдущей плавки. Поскольку существует операция schedule, клиент может запросить объект TemperatureController, чтобы тот рекомендовал оптимальный момент запуска следующего нагрева.
Видимость. Пусть есть два объекта А и в и связь между ними. Чтобы А мог послать сообщение в, надо, чтобы в был в каком-то смысле видим для А. Мы можем не заботиться об этом на стадии анализа, но когда дело доходит до реализации системы, мы должны обеспечить видимость связанных объектов.
В предыдущем примере объект rampController видит объект growingRamp, поскольку оба они объявлены в одной области видимости и потому, что growingRamp передается объекту rampController в качестве параметра. В принципе есть следующие четыре способа обеспечить видимость.
Сервер глобален по отношению к клиенту.
Сервер (или указатель на него) передан клиенту в качестве параметра операции.
Сервер является частью клиента.
Сервер локально порождается клиентом в ходе выполнения какой-либо операции.
Какой именно из этих способов выбрать - зависит от тактики проектирования.
Синхронизация. Когда один объект посылает по связи сообщение другому, связанному с ним, они, как говорят, синхронизируются. В строго последовательном приложении синхронизация объектов и состоит в запуске метода (см. врезку ниже). Однако в многопоточной системе объекты требуют более изощренной схемы передачи сообщений, чтобы разрешить проблемы взаимного исключения, типичные для параллельных систем. Активные объекты сами по себе выполняются как потоки, поэтому присутствие других активных объектов на них обычно не влияет. Если же активный объект имеет связь с пассивным, возможны следующие три подхода к синхронизации:
Последовательный - семантика пассивного объекта обеспечивается в присутствии только одного активного процесса.
Защищенный - семантика пассивного объекта обеспечивается в присутствии многих потоков управления, но активные клиенты должны договориться и обеспечить взаимное исключение.
Синхронный - семантика пассивного объекта обеспечивается в присутствии многих потоков управления; взаимное исключение обеспечивает сервер.
Все объекты, описанные в этой главе, были последовательными. В главе 9 мы рассмотрим остальные варианты более подробно.
Тактические вопросы
В соответствии с законом разработки программ Коггинса "прагматизм всегда должен быть предпочтительней элегантности, ведь Природу все равно ничем не удивить". Следствие: проектирование никогда не будет полностью независимым от языка реализации проекта. Особенности языка неизбежно наложат отпечаток на те или иные архитектурные решения, и их игнорирование может привести к тому, что нам придется работать в дальнейшем с абстракциями, не в полной мере учитывающими преимущества и недостатки конкретного языка реализации.
Как было отмечено в главе 3, объектно-ориентированные языки предоставляют три основных механизма упорядочения большего числа классов: наследование, агрегацию и параметризацию. Наследование является наиболее популярным свойством объектно-ориентированной технологии, однако далеко не единственным принципом структурирования. Как мы увидим, сочетание параметризации с наследованием и агрегацией помогает создать достаточно мощную и в то же время компактную архитектуру.
Рассмотрим усеченное описание предметно-зависимого класса очереди в C++:
class NetworkEvent... // сетевое событие
class EventQueue { // очередь событий
public: EventQueue();
virtual ~EventQueue();
virtual void clear(); // очистить
virtual void add(const NetworkEvent&); // добавить
virtual void pop(); // продвинуть
virtual const NetworkEvent& front() const; // первый элемент
...
};
Перед нами абстракция, олицетворяющая очередь событий: структура, в которую мы можем добавлять новые элементы в конец очереди и удалять элементы из начала очереди. C++ позволяет скрыть внутренние детали реализации класса очереди за его внешним интерфейсом (операциями clear, add, pop и front ).
Нам могут потребоваться также некоторые другие варианты очереди, например, приоритетная очередь, где события выстраиваются в соответствии с их срочностью. Разумно воспользоваться результатами уже проделанной работы и организовать новый класс на базе ранее определенного:
class PriorityEventQueue : public EventQueue {
public: PriorityEventQueue();
virtual ~PriorityEventQueue();
virtual void add(const NetworkEvent&);
...
};
Виртуальность функций (например функции add) поощряет переопределение операций в подклассах.
Комбинация наследования с параметризованными классами позволяет создавать еще более общие абстракции. Семантика класса очереди не зависит от того, что в ней: волки или овцы. Используя классы-шаблоны, можно переопределить наш базовый класс следующим образом:
template<class Item>
class Queue {
public: Queue();
virtual ~Queue();
virtual void clear();
virtual void add(const Item&);
virtual void pop();
virtual const Item& front() const;
...
};
Это наиболее распространенный способ использования параметризованных классов: взять существующий конкретный класс, выделить в нем то, что не зависит от элементов, с которыми он оперирует, и сделать эти элементы аргументами шаблона.
Наследование и параметризация очень хорошо сочетаются. Наш подкласс PriorityQueue можно, например, обобщить следующим образом:
template<class Item>
class PriorityQueue : public Queue<Item> {
public: PriorityQueue();
virtual ~PriorityQueue();
virtual void add(const Item&);
...
};
Безопасность с точки зрения типов - ключевое преимущество данного подхода. Мы можем создать целый ряд различных классов конкретных очередей:
Queue<char> characterQueue;
typedef Queue<MetworkEvent> EventQueue;
typedef PriorityQueue<NetworkEvent> PriorityEventQueue;
Рис. 9-1. Наследование и параметризация.
При этом язык реализации не позволит нам присоединить событие к очереди символов, а вещественное число - к очереди событий.
Рис. 9-1 иллюстрирует отношения между параметризованным классом (Queue), его подклассом (PriorityQueue), примером этого подкласса (PriorityEventQueue) и одним из его экземпляров (mailQueue).
Этот пример подтверждает правильность одного из самых первых наших архитектурных решений: почти все классы нашей библиотеки должны быть параметризованными.Тогда будет выполнено и требование защищенности.
Тенденции в проектировании
Поколения языков программирования. Оглядываясь на короткую, но колоритную историю развития программирования, нельзя не заметить две сменяющих друг друга тенденции:
смещение акцентов от программирования отдельных деталей к программированию более крупных компонент;
развитие и совершенствование языков программирования высокого уровня.
Большинство современных коммерческих программных систем больше и существенно сложнее, чем были их предшественники даже несколько лет тому назад. Этот рост сложности вызвал большое число прикладных исследований по методологии проектирования, особенно, по декомпозиции, абстрагированию и иерархиям. Создание более выразительных языков программирования пополнило достижения в этой области. Возникла тенденция перехода от языков, указывающих компьютеру, что делать (императивные языки), к языкам, описывающим ключевые абстракции проблемной области (декларативные языки).
Вегнер сгруппировал некоторые из наиболее известных языков высокого уровня в четыре поколения в зависимости от того, какие языковые конструкции впервые в них появились:
Первое поколение (1954-1958)
Второе поколение (1959-1961)
Третье поколение(1962-1970)
Потерянное поколение (1970-1980)
Много языков созданных, но мало выживших [Последняя фраза, очевидно, следует евангельскому "...много званных, но мало избранных" (Матф. 22:14). - Примеч. ред.] [2].
В каждом следующем поколении менялись поддерживаемые языками механизмы абстракции. Языки первого поколения ориентировались на научно-инженерные применения, и словарь этой предметной области был почти исключительно математическим.
Такие языки, как FORTRAN I, были созданы для упрощения программирования математических формул, чтобы освободить программиста от трудностей ассемблера и машинного кода. Первое поколение языков высокого уровня было шагом, приближающим программирование к предметной области и удаляющим от конкретной машины. Во втором поколении языков основной тенденцией стало развитие алгоритмических абстракций. В это время мощность компьютеров быстро росла, а компьютерная индустрия позволила расширить области их применения, особенно в бизнесе. Главной задачей стало инструктировать машину, что делать: сначала прочти эти анкеты сотрудников, затем отсортируй их и выведи результаты на печать. Это было еще одним шагом к предметной области и от конкретной машины. В конце 60-х годов с появлением транзисторов, а затем интегральных схем, стоимость компьютеров резко снизилась, а их производительность росла почти экспоненциально. Появилась возможность решать все более сложные задачи, но это требовало умения обрабатывать самые разнообразные типы данных. Такие языки как ALGOL-68 и затем Pascal стали поддерживать абстракцию данных. Программисты смогли описывать свои собственные типы данных. Это стало еще одним шагом к предметной области и от привязки к конкретной машине.
70-е годы знаменовались безумным всплеском активности: было создано около двух тысяч различных языков и их диалектов. Неадекватность более ранних языков написанию крупных программных систем стала очевидной, поэтому новые языки имели механизмы, устраняющие это ограничение. Лишь немногие из этих языков смогли выжить (попробуйте найти свежий учебник по языкам Fred, Chaos, Tranquil), однако многие их принципы нашли отражение в новых версиях более ранних языков. Таким образом, мы получили языки Smalltalk (новаторски переработанное наследие Simula), Ada (наследник ALGOL-68 и Pascal с элементами Simula, Alphard и CLU), CLOS (объединивший Lisp, LOOPS и Flavors), C++ (возникший от брака С и Simula) и Eiffel (произошел от Simula и Ada). Наибольший интерес для дальнейшего изложения представляет класс языков, называемых объектными и объектно-ориентированными, которые в наибольшей степени отвечают задаче объектно-ориентированной декомпозиции программного обеспечения.
Топология языков первого и начала второго поколения. Для пояснения сказанного рассмотрим структуры, характерные для каждого поколения. На рис. 2-1 показана топология, типичная для большинства языков первого поколения и первой стадии второго поколения. Говоря "топология", мы имеем в виду основные элементы языка программирования и их взаимодействие. Можно отметить, что для таких языков, как FORTRAN и COBOL, основным строительным блоком является подпрограмма (параграф в терминах COBOL). Программы, реализованные на таких языках, имеют относительно простую структуру, состоящую только из глобальных данных и подпрограмм. Стрелками на рисунке обозначено влияние подпрограмм на данные. В процессе разработки можно логически разделить разнотипные Данные, но механизмы языков практически не поддерживают такого разделения. Ошибка в какой-либо части программы может иметь далеко идущие последствия, так как область данных открыта всем подпрограммам. В больших системах трудно гарантировать целостность данных при внесении изменений в какую-либо часть системы. В процессе эксплуатации уже через короткое время возникает путаница из-за большого количества перекрестных связей между подпрограммами, запутанных схем управления, неясного смысла данных, что угрожает надежности системы и определенно снижает ясность программы.
Рис. 2-1. Топология языков первого и начала второго поколения.
Топология языков позднего второго и раннего третьего поколения. Начиная с середины 60-х годов стали осознавать роль подпрограмм как важного промежуточного звена между решаемой задачей и компьютером [3]. Шоу отмечает: "Первая программная абстракция, названная процедурной абстракцией, прямо вытекает из этого прагматического взгляда на программные средства... Подпрограммы возникли до 1950 года, но тогда они не были оценены в качестве абстракции... Их рассматривали как средства, упрощающие работу... Но очень скоро стало ясно, что подпрограммы это абстрактные программные функции" [4].
Использование подпрограмм как механизма абстрагирования имело три существенных последствия.
Во-первых, были разработаны языки, поддерживавшие разнообразные механизмы передачи параметров. Во-вторых, были заложены основания структурного программирования, что выразилось в языковой поддержке механизмов вложенности подпрограмм и в научном исследовании структур управления и областей видимости. В-третьих, возникли методы структурного проектирования, стимулирующие разработчиков создавать большие системы, используя подпрограммы как готовые строительные блоки. Архитектура языков программирования этого периода (рис. 2-2), как и следовало ожидать, представляет собой вариации на темы предыдущего поколения. В нее внесены кое-какие усовершенствования, в частности, усилено управление алгоритмическими абстракциями, но остается нерешенной проблема программирования "в большом" и проектирования данных.
Топология языков конца третьего поколения. Начиная с FORTRAN II и далее, для решения задач программирования "в большом" начал развиваться новый важный механизм структурирования. Разрастание программных проектов означало увеличение размеров и коллективов программистов, а, следовательно, необходимость независимой разработки отдельных частей проекта. Ответом на эту потребность стал отдельно компилируемый модуль, который сначала был просто более или менее случайным набором данных и подпрограмм (рис. 2-3). В такие модули собирали подпрограммы, которые, как казалось, скорее всего будут изменяться совместно, и мало кто рассматривал их как новую технику абстракции. В большинстве языков этого поколения, хотя и поддерживалось модульное программирование, но не вводилось никаких правил, обеспечивающих согласование интерфейсов модулей. Программист, сочиняющий подпрограмму в одном из модулей, мог, например, ожидать, что ее будут вызывать с тремя параметрами: действительным числом, массивом из десяти элементов и целым числом, обозначающим логическое значение. Но в каком-то другом модуле, вопреки предположениям автора, эта подпрограмма могла по ошибке вызываться с фактическими параметрами в виде: целого числа, массива из пяти элементов и отрицательного числа.
Аналогично, один из модулей мог завести общую область данных и считать, что это его собственная область, а другой модуль мог нарушить это предположение, свободно манипулируя с этими данными. К сожалению, поскольку большинство языков предоставляло в лучшем случае рудиментарную поддержку абстрактных данных и типов, такие ошибки выявлялись только при выполнении программы.
Рис. 2-2. Топология языков позднего второго и раннего третьего поколения.
Топология объектных и объектно-ориентированных языков. Значение абстрактных типов данных в разрешении проблемы сложности систем хорошо выразил Шанкар: "Абстрагирование, достигаемое посредством использования процедур, хорошо подходит для описания абстрактных действий, но не годится для описания абстрактных объектов. Это серьезный недостаток, так как во многих практических ситуациях сложность объектов, с которыми нужно работать, составляет основную часть сложности всей задачи" [5]. Осознание этого влечет два важных вывода. Во-первых, возникают методы проектирования на основе потоков данных, которые вносят упорядоченность в абстракцию данных в языках, ориентированных на алгоритмы. Во-вторых, появляется теория типов, которая воплощается в таких языках, как Pascal.
Естественным завершением реализации этих идей, начавшейся с языка Simula и развитой в последующих языках в 1970-1980-е годы, стало сравнительно недавнее появление таких языков, как Smalltalk, Object Pascal, C++, CLOS, Ada и Eiffel. По причинам, которые мы вскоре объясним, эти языки получили название объектных или объектно-ориентированных. На рис. 2-4 приведена топология таких языков применительно к задачам малой и средней степени сложности. Основным элементом конструкции в указанных языках служит модуль, составленный из логически связанных классов и объектов, а не подпрограмма, как в языках первого поколения.
Рис. 2-3. Топология языков конца третьего поколения.
Другими словами: "Если процедуры и функции - глаголы, а данные - существительные, то процедурные программы строятся из глаголов, а объектно-ориентированные - из существительных" [6].
По этой же причине структура программ малой и средней сложности при объектно-ориентированном подходе представляется графом, а не деревом, как в случае алгоритмических языков. Кроме того, уменьшена или отсутствует область глобальных данных. Данные и действия организуются теперь таким образом, что основными логическими строительными блоками наших систем становятся классы и объекты, а не алгоритмы.
В настоящее время мы продвинулись много дальше программирования "в большом" и предстали перед программированием "в огромном". Для очень сложных систем классы, объекты и модули являются необходимыми, но не достаточными средствами абстракции. К счастью, объектный подход масштабируется и может быть применен на все более высоких уровнях. Кластеры абстракций в больших системах могут представляться в виде многослойной структуры. На каждом уровне можно выделить группы объектов, тесно взаимодействующих для решения задачи более высокого уровня абстракции. Внутри каждого кластера мы неизбежно найдем такое же множество взаимодействующих абстракций (рис. 2-5). Это соответствует подходу к сложным системам, изложенному в главе 1.
Типизация
Что такое типизация? Понятие типа взято из теории абстрактных типов данных. Дойч определяет тип, как "точную характеристику свойств, включая структуру и поведение, относящуюся к некоторой совокупности объектов" [68]. Для наших целей достаточно считать, что термины тип и класс взаимозаменяемы [Тип и класс не вполне одно и то же; в некоторых языках их различают. Например, ранние версии языка Trellis/Owl разрешали объекту иметь и класс, и тип. Даже в Smalltalk объекты классов SmallInteger, LargeNegativeInteger, LargePositiveInteger относятся к одному типу Integer, хотя и к разным классам [69]. Большинству смертных различать типы и классы просто противно и бесполезно. Достаточно сказать, что класс реализует понятие типа]. Тем не менее, типы стоит обсудить отдельно, поскольку они выставляют смысл абстрагирования в совершенно другом свете. В частности, мы утверждаем, что:
Типизация - это способ защититься от использования объектов одного класса вместо другого, или по крайней мере управлять таким использованием.
Типизация заставляет нас выражать наши абстракции так, чтобы язык программирования, используемый в реализации, поддерживал соблюдение принятых проектных решений. Вегнер замечает, что такой способ контроля существенен для программирования "в большом" [70].
Идея согласования типов занимает в понятии типизации центральное место. Например, возьмем физические единицы измерения [71]. Деля расстояние на время, мы ожидаем получить скорость, а не вес. В умножении температуры на силу смысла нет, а в умножении расстояния на силу - есть. Все это примеры сильной типизации, когда прикладная область накладывает правила и ограничения на использование и сочетание абстракций.
Примеры сильной и слабой типизации. Конкретный язык программирования может иметь сильный или слабый механизм типизации, и даже не иметь вообще никакого, оставаясь объектно-ориентированным. Например, в Eiffel соблюдение правил использования типов контролируется непреклонно, - операция не может быть применена к объекту, если она не зарегистрирована в его классе или суперклассе.
В сильно типизированных языках нарушение согласования типов может быть обнаружено во время трансляции программы. С другой стороны, в Smalltalk типов нет: во время исполнения любое сообщение можно послать любому объекту, и если класс объекта (или его надкласс) не понимает сообщение, то генерируется сообщение об ошибке. Нарушение согласования типов может не обнаружиться во время трансляции и обычно проявляется как ошибка исполнения. C++ тяготеет к сильной типизации, но в этом языке правила типизации можно игнорировать или подавить полностью.
Рассмотрим абстракцию различных типов емкостей, которые могут использоваться в нашей теплице. Вероятно, в ней есть емкости для воды и для минеральных удобрений; хотя первые предназначены для жидкостей, а вторые для сыпучих веществ, они имеют достаточно много общего, чтобы устроить иерархию классов. Начнем с типов.
// Число, обозначающее уровень от 0 до 100 процентов
typedef float Level;
Операторы typedef в C++ не вводят новых типов. В частности, и Level и Concentration - на самом деле другие названия для float, и их можно свободно смешивать в вычислениях. В этом смысле C++ имеет слабую типизацию: значения примитивных типов, таких, как int или float неразличимы в пределах данного типа. Напротив, Ada и Object Pascal предоставляют сильную типизацию для примитивных типов. В Ada можно объявить самостоятельным типом интервал значений или подмножество с ограниченной точностью.
Строгая типизация предотвращает смешивание абстракций.
Построим теперь иерархию классов для емкостей:
class StorageTank {
public: StorageTank();
virtual ~StorageTank();
virtual void fill();
virtual void startDraining();
virtual void stopDraining();
Boolean isEmpty() const;
Level level() const;
protected:
...
};
class WaterTank : public StorageTank{
public: WaterTank();
virtual ~WaterTank();
virtual void fill();
virtual void startDraining();
virtual void stopDraining();
void startHeating();
void stopHeating();
Temperature currentTemperature() const;
protected:
.
..
};
class NutrientTank : public StorageTank {
public: NutrientTank();
virtual ~NutrientTank();
virtual void startDrainingt();
virtual void stopDraining();
protected:
...
};
Класс StorageTank - это базовый класс иерархии. Он обеспечивает структуру и поведение общие для всех емкостей: возможность их наполнять или опустошать. Классы WaterTank (емкость для воды) и NutrientTank (для удобрений) наследуют свойства StorageTank, частично переопределяют их и добавляют кое-что свое: например, класс WaterTank вводит новое поведение, связанное с температурой.
Предположим, что мы имеем следующие описания:
StorageTank s1, s2;
WaterTank w;
NutrientTank n;
Заметьте, переменные такие как s1, s2, w или n - это не экземпляры соответствующих классов. На самом деле, это просто имена, которыми мы обозначаем объекты соответствующих классов: когда мы говорим "объект s1" мы на самом деле имеем ввиду экземпляр StorageTank, обозначаемый переменной s1. Мы вернемся к этому тонкому вопросу в следующей главе.
При проверке типов у классов, C++ типизирован гораздо строже. Под этим понимается, что выражения, содержащие вызовы операций, проверяются на согласование типов во время компиляции. Например, следующее правильно:
Level l = s1.level();
w.startDrainingt();
n.stopDraining();
Действительно, такие селекторы есть в классах, к которым принадлежат соответствующие переменные. Напротив, следующее неправильно и вызовет ошибку компиляции:
s1.startHeating(); // Неправильно
n.stopHeating(); // Неправильно
Таких функций нет ни в самих классах, ни в их суперклассах. Но следующее
n.fill();
совершенно правильно: функции fill нет в определении NutrientTank, но она есть в вышестоящем классе.
Итак, сильная типизация заставляет нас соблюдать правила использования абстракций, поэтому она тем полезнее, чем больше проект. Однако у нее есть и теневая сторона. А именно, даже небольшие изменения в интерфейсе класса требуют перекомпиляции всех его подклассов. Кроме того, не имея параметризованных классов, о которых речь пойдет в главах 3 и 9, трудно представить себе, как можно было бы создать собрание разнородных объектов.
Предположим, что мы хотим ввести абстракцию инвентарного списка, в котором собирается все имущество, связанное с теплицей. Обычная для С идиома применима и в C++: нужно использовать класс-контейнер, содержащий указатели на void, то есть на объекты произвольного типа.
class Inventory {
public: Inventory();
~Inventory();
void add(void*);
void remove(void*);
void* mostRecent() const;
void apply(Boolean (*)(void*));
private:
...
};
Операция apply - это так называемый итератор, который позволяет применить какую-либо операцию ко всем объектам в списке. Подробнее об итераторах см. в следующей главе.
Имея экземпляр класса Inventory, мы можем добавлять и уничтожать указатели на объекты любых классов. Но эти действия не безопасны с точки зрения типов - в списке могут оказаться как осязаемые объекты (емкости), так и неосязаемые (температура или план выращивания), что нарушает нашу абстракцию материального учета. Более того, мы могли бы внести в список объекты классов WaterTank и TemperatureSensor, и по неосторожности ожидая от функции mostRecent объекта класса WaterTank получить StorageTank.
Вообще говоря, у этой проблемы есть два общих решения. Во-первых, можно сделать контейнерный класс, безопасный с точки зрения типов. Чтобы не манипулировать с нетипизированными указателями void, мы могли бы определить инвентаризационный класс, который манипулирует только с объектами класса TangibleAsset (осязаемого имущества), а этот класс будет подмешиваться ко всем классам, такое имущество представляющим, например, к WaterTank, но не к GrowingPlan. Тем самым можно отсечь проблему первого рода, когда неправомочно смешиваются объекты разных типов. Во-вторых, можно ввести проверку типов в ходе выполнения, для того, чтобы знать, с объектом какого типа мы имеем дело в данный момент. Например, в Smalltalk можно запрашивать у объектов их класс. В C++ такая возможность не входила в стандарт до недавнего времени, хотя на практике, конечно, можно ввести в базовый класс операцию, возвращающую код класса (строку или значение перечислимого типа).
Однако для этого надо иметь очень серьезные причины, поскольку проверка типа в ходе выполнения ослабляет инкапсуляцию. Как будет показано в следующем разделе, необходимость проверки типа можно смягчить, используя полиморфные операции.
В языках с сильной типизацией гарантируется, что все выражения будут согласованы по типу. Что это значит, лучше пояснить на примере. Следующие присваивания допустимы:
s1 = s2;
s1 = w;
Первое присваивание допустимо, поскольку переменные имеют один и тот же класс, а второе - поскольку присваивание идет снизу вверх по типам. Однако во втором случае происходит потеря информации (известная в C++ как "проблема срезки"), так как класс переменной w, WaterTank, семантически богаче, чем класс переменной s1, то есть StorageTank.
Следующие присваивания неправильны:
w = s1; // Неправильно
w = n; // Неправильно
В первом случае неправильность в том, что присваивание идет сверху вниз по иерархии, а во втором классы даже не находятся в состоянии подчиненности.
Иногда необходимо преобразовать типы. Например, посмотрите на следующую функцию:
void checkLevel(const StorageTank& s);
Мы можем привести значение вышестоящего класса к подклассу в том и только в том случае, если фактическим параметром при вызове оказался объект класса WaterTank. Или вот еще случай:
if (((WaterTank&)s).currentTemperature() < 32.0) ...
Это выражение согласовано по типам, но не безопасно. Если при выполнении программы вдруг окажется, что переменная s обозначала объект класса NutrientTank, приведение типа даст непредсказуемый результат во время исполнения. Вообще говоря, преобразований типа надо избегать, поскольку они часто представляют собой нарушение принятой системы абстракций.
Теслер отметил следующие важные преимущества строго типизированных языков:
"Отсутствие контроля типов может приводить к загадочным сбоям в программах во время их выполнения.
В большинстве систем процесс редактирование-компиляция-отладка утомителен, и раннее обнаружение ошибок просто незаменимо.
Объявление типов улучшает документирование программ.
Многие компиляторы генерируют более эффективный объектный код, если им явно известны типы" [72].
Языки, в которых типизация отсутствует, обладают большей гибкостью, но даже в таких языках, по мнению Борнинга и Ингалса: "Программисты обычно знают, какие объекты ожидаются в качестве аргументов и какие будут возвращаться" [73]. На практике, особенно при программировании "в большом", надежность языков со строгой типизацией с лихвой компенсирует некоторую потерю в гибкости по сравнению с нетипизированными языками.
Примеры типизации: статическое и динамическое связывание. Сильная и статическая типизация - разные вещи. Строгая типизация следит за соответствием типов, а статическая типизация (иначе называемая статическим или ранним связыванием) определяет время, когда имена связываются с типами. Статическая связь означает, что типы всех переменных и выражений известны во время компиляции; динамическое связывание (называемое также поздним связыванием) означает, что типы неизвестны до момента выполнения программы. Концепции типизации и связывания являются независимыми, поэтому в языке программирования может быть: типизация - сильная, связывание - статическое (Ada), типизация - сильная, связывание - динамическое (C++, Object Pascal), или и типов нет, и связывание динамическое (Smalltalk). Язык CLOS занимает промежуточное положение между C++ и Smalltalk: определения типов, сделанные программистом, могут быть либо приняты во внимание, либо не приняты.
Прокомментируем это понятие снова примером на C++. Вот "свободная", то есть не входящая в определение какого-либо класса, функция [Свободная функция - функция, не входящая ни в какой класс. В чисто объектно-ориентированных языках, типа Smalltalk, свободных процедур не бывает, каждая операция связана с каким-нибудь классом]:
void balanceLevels(StorageTank& s1, StorageTank& s2);
Вызов этой функции с экземплярами класса StorageTank или любых его подклассов в качестве параметров будет согласован по типам, поскольку тип каждого фактического параметра происходит в иерархии наследования от базового класса StorageTank.
При реализации этой функции мы можем иметь что-нибудь вроде:
if (s1.level()> s2.level()) s2.fill();
В чем особенность семантики при использовании селектора level? Он определен только в классе StorageTank, поэтому, независимо от классов объектов, обозначаемых переменными в момент выполнения, будет использована одна и та же унаследованная ими функция. Вызов этой функции статически связан при компиляции - мы точно знаем, какая операция будет запущена.
Иное дело fill. Этот селектор определен в StorageTank и переопределен в WaterTank, поэтому его придется связывать динамически. Если при выполнении переменная s2 будет класса WaterTank, то функция будет взята из этого класса, а если - NutrientTank, то из StorageTank. В C++ есть специальный синтаксис для явного указания источника; в нашем примере вызов fill будет разрешен, соответственно, как WaterTank::fill или StorageTank::fill [Так синтаксис C++ определяет явную квалификацию имени].
Это особенность называется полиморфизмом: одно и то же имя может означать объекты разных типов, но, имея общего предка, все они имеют и общее подмножество операций, которые можно над ними выполнять [74]. Противоположность полиморфизму называется мономорфизмом; он характерен для языков с сильной типизацией и статическим связыванием (Ada).
Полиморфизм возникает там, где взаимодействуют наследование и динамическое связывание. Это одно из самых привлекательных свойств объектно-ориентированных языков (после поддержки абстракции), отличающее их от традиционных языков с абстрактными типами данных. И, как мы увидим в следующих главах, полиморфизм играет очень важную роль в объектно-ориентированном проектировании.
Типы отношений
Сами по себе объекты не представляют никакого интереса: только в процессе взаимодействия объектов реализуется система. По выражению Ингалса: "Вместо процессора, беззастенчиво перемалывающего структуры данных, мы получаем сообщество хорошо воспитанных объектов, которые вежливо просят друг друга об услугах" [13]. Самолет, по определению, "совокупность элементов, каждый из которых по своей природе стремится упасть на землю, но за счет совместных непрерывных усилий преодолевающих эту тенденцию" [14]. Он летит только благодаря согласованным усилиям своих компонентов.
Отношения двух любых объектов основываются на предположениях, которыми один обладает относительно другого: об операциях, которые можно выполнять, и об ожидаемом поведении. Особый интерес для объектно-ориентированного анализа и проектирования представляют два типа иерархических соотношений объектов:
связи;
агрегация.
Зайдевиц и Старк назвали эти два типа отношений отношениями старшинства и "родитель/потомок" соответственно [15].
ассоциация
наследование
агрегация
использование
инстанцирование
метакласс.
Альтернативой наследованию является делегирование, при этом объекты рассматриваются как прототипы, которые делегируют свое поведение родственным им объектам. Таким образом, классы становятся не нужны [23].
Из шести перечисленных видов отношений наиболее общим и неопределенным является ассоциация. Как мы увидим в главе 6, обычно аналитик констатирует наличие ассоциации и, постепенно уточняя проект, превращает ее в какую-то более специализированную связь.
Наследование, вероятно, следует считать самым интересным семантически. Оно выражает отношение общего и частного. Однако, по нашему опыту, одного наследования недостаточно, чтобы выразить все многообразие явлений и отношений жизни. Полезна также агрегация, отражающая отношения целого и части между экземплярами классов. Нелишне добавить отношение использования, означающее наличие связи между экземплярами классов. Имея дело с языками Ada, Eiffel и C++, нам не обойтись без инстанцирования, которое, подобно наследованию, является специфической разновидностью обобщения. "Метаклассовые" отношения - это нечто совершенно иное, в явном виде встречающееся только в языках Smalltalk и CLOS. Метакласс - это класс классов, что позволяет нам трактовать классы как объекты.
Рис. 3-4. Ассоциация.
Требования к библиотеке базовых классов
Библиотека должна содержать универсальные структуры данных и алгоритмы, способные удовлетворить потребности большинства стандартных приложении C++. Кроме того, библиотека должна быть:
• Полной | Библиотека должна содержать семейство классов, объединенных согласованным внешним интерфейсом, но с разными представлениями, так чтобы разработчики могли выбрать то, семантика которого наиболее точно соответствует приложению. |
• Адаптируемой | Все фрагменты кода, зависящие от платформы, должны быть выделены и изолированы в отдельные классы для обеспечения возможности локальных изменений в них. В частности, разработчик должен иметь контроль над механизмами хранения данных и синхронизации процессов. |
• Эффективной | Процедура подключения различных фрагментов библиотеки к приложению должна быть простой (эффективность при компиляции). Непроизводительные затраты оперативной памяти и процессорного времени на обслуживание и подключение должны быть сведены к минимуму (эффективность при исполнении). Библиотека должна обеспечивать более надежную работу, чем механизмы, разработанные пользователем вручную (эффективность при разработке). |
• Безопасной | Каждая абстракция должна быть безопасной с точки зрения типов, так чтобы статические предположения о поведении класса могли быть обеспечены компилятором. Для выявления нарушений динамической семантики классов должен быть использован механизм исключений. Возбуждение исключения не должно испортить состояние объекта, вызвавшего исключение. |
• Простой | Библиотека должна иметь прозрачную структуру, дающую возможность пользователю легко находить и подключать к приложению ее фрагменты. |
• Расширяемой | Для пользователя должна быть реализована возможность включения в библиотеку новых классов. При этом архитектурная целостность среды разработки не должна нарушаться. |
Библиотека должна быть относительно небольших размеров; надо всегда помнить, что пользователь с большей охотой займется разработкой собственного кода, чем изучением чужого малопонятного класса.
Предполагается наличие трансляторов языка C++, поддерживающих параметризованные классы и обработку исключений. В целях обеспечения переносимости библиотеки она не должна зависеть от служб операционной системы.
Таким образом, первым результатом нашего анализа будет разделение всех абстракций на две категории:
• Структуры | Содержит все структурные абстракции. |
• Инструменты | Содержит все алгоритмические абстракции. |
Как мы скоро увидим, между этими двумя категориями существует отношение использования: некоторые инструменты построены на базе более примитивных свойств, обеспечиваемых структурами.
На втором этапе анализа мы постараемся выделить базовые классы, которые могут быть использованы в различных стандартных программах (чем шире будет круг рассмотренных приложений, тем лучше). Если в результате окажется, что некоторые из данных классов имеют много общего с абстракциями, определенными на первой стадии анализа, это будет знаком того, что ключевые абстракции были выявлены правильно. Можно составить длинный список специфических абстракций, присущих конкретным видам человеческой деятельности: валюта, астрономические координаты, единицы измерения массы и длины. Мы не будем включать подобные абстракции в нашу библиотеку, так как они либо слишком плохо поддаются формализации (валюта), либо очень специфичны (астрономические координаты), либо настолько примитивны, что нет смысла организовывать специально для них отдельные классы (единицы измерения массы и длины).
Проведя анализ, мы выделим следующие типы структур:
• Набор | Множество различных элементов (в том числе дубликатов). |
• Множество | Набор неповторяющихся элементов. |
• Коллекция | Индексируемое множество элементов. |
• Список | Последовательность элементов, имеющая начало; структурное разделение допускается. |
• Стек | Последовательность элементов; элементы могут удаляться и добавляться только с одного конца. |
• Очередь | Последовательность элементов, к которой можно добавлять элементы с одного конца, а удалять - с другого. |
• Дека | Последовательность элементов, к которой можно добавлять и из которой можно удалять элементы с обоих концов. |
• Кольцо | Последовательность элементов, к которой можно добавлять и из которой можно удалять элементы, находящиеся на вершине круговой структуры. |
• Строка | Индексируемая последовательность элементов, в которой возможны операции с подстроками. |
• Ассоциативный массив | Словарь пар "элемент/значение". |
• Дерево | Набор (имеющий начало - корень дерева) вершин и ребер, которые не могут образовывать циклы и пересекаться; структурное разделение допускается. |
• Граф | Множество вершин и ребер (без выделенного начального элемента), которое может содержать циклы и пересечения; структурное разделение допускается. |
Как уже говорилось в главе 4, упорядочение представленных выше абстракций есть проблема классификации. Мы выбрали именно такую модель из-за того, что она обеспечивает закрепление определенного поведения за каждой категорией объектов.
Обратите внимание на типы поведения, которые использовались в качестве критериев при разбиении на классы: некоторые структуры ведут себя как коллекции (наборы и множества), а другие - как последовательности (деки и стеки). В некоторых структурах (графы, списки и деревья) возможно структурное разделение, в то время как остальные более монолитны и не допускают структурного разделения своих элементов. Как мы увидим далее, подобная классификация поможет в дальнейшем сформировать достаточно простую архитектуру системы.
Для некоторых классов в процессе анализа выявилась желательность их функциональной изменчивости. В частности, нам могут понадобиться упорядоченные коллекции, деки и очереди (последние часто называют приоритетными очередями). Кроме того, мы можем различать ориентированные и неориентированные графы, односвязные и двусвязные списки, бинарные, множественные и AVL-деревья [AVL-дерево - предложенная Г.М.Адельсон-Вольским и Е.М.Ландисом конструкция подравниваемого бинарного дерева. - Примеч. ред.]. Эти специализированные абстракции могут быть получены уточнением одной из вышеперечисленных; их не следует выделять в отдельные большие категории.
Несмотря на то, что мы уже обнаружили признаки общности поведения, мы пока не будем заниматься проработкой иерархической структуры. На этапе анализа важно разобраться в ролях каждой абстракции.
Мы выделим следующие типы инструментов:
• Дата/Время | Операции с датой и временем. |
• Фильтры | Ввод, обработка и вывод. |
• Поиск по образцу | Операции поиска последовательностей внутри других последовательностей. |
• Поиск | Операции поиска элементов внутри структур. |
• Сортировка | Операции упорядочивания структур. |
• Утилиты | Составные операции, базирующиеся на базовых структурных операциях. |
Несомненно, существует масса различных функциональных вариантов этих абстракций. Можно, например, выделить несколько видов сортировок (быстрая сортировка методом пузырька, сортировка кучи и т.д.) или поиска (последовательный, двоичный, различные способы обхода дерева и т.д.). Как и раньше, мы отложим решения относительно наследования этих абстракций.
Трудности классификации
Примеры классификации. В главе 3 мы определили объект как нечто, имеющее четкие границы. На самом деле это не вполне так. Границы предметов часто неопределенны. Например, посмотрите на вашу ногу. Попытайтесь определить, где начинается и кончается колено. В разговорной речи трудно понять, почему именно эти звуки определяют слово, а не являются частью какого-то более длинного слова. Представьте себе, что вы проектируете текстовый редактор. Что считать классом - буквы или слова? Как понимать отдельные фразы, предложения, параграфы, документы? Как обращаться с произвольными, не обязательно осмысленными, блоками текста? Что делать с предложениями, абзацами и целыми документами - соответствуют ли такие классы нашей задаче?
То, что разумная классификация - трудная проблема, новостью не назовешь. И поскольку есть параллели с аналогичными трудностями в объектно-ориентированном проектировании, рассмотрим примеры классификации в двух других научных дисциплинах: биологии и химии.
Вплоть до XVIII века идея о возможности классификации живых организмов по степени сложности была господствующей. Мера сложности была субъективной, поэтому неудивительно, что человек оказался в списке на первом месте. В середине XVIII века шведский ботаник Карл Линней предложил более подробную таксономию для классификации организмов: он ввел понятия рода и вида. Век спустя Дарвин выдвинул теорию, по которой механизмом эволюции является естественный отбор и ныне существующие виды животных - продукт эволюции древних организмов. Теория Дарвина основывалась на разумной классификации видов. Как утверждает Дарвин, "натуралисты пытаются расположить виды, роды, семейства в каждом классе в то, что называется натуральной системой. Что подразумевается под этой системой? Некоторые авторы понимают некоторую простую схему, позволяющую расположить наиболее похожие живые организмы в один класс и различные - в разные классы" [4]. В современной биологии термин "классификация" обозначает "установление иерархической системы категорий на основе предположительно существующих естественных связей между организмами" [5].
Наиболее общее понятие в биологической таксономии - царство, затем, в порядке убывания общности: тип (отдел), класс, отряд (порядок), семейство, род и, наконец, вид. Исторически сложилось так, что место каждого организма в иерархической системе определяется на основании внешнего и внутреннего строения тела и эволюционных связей. В современной классификации живых существ выделяются группы организмов, имеющих общую генетическую историю, то есть организмы, имеющие сходные ДНК, включаются в одну группу. Классификация по ДНК полезна, чтобы различить организмы, которые похожи внешне, но генетически сильно отличаются. По современным воззрениям дельфины ближе к коровам, чем к форели [6].
Возможно, для программиста биология представляется зрелой, вполне сформировавшейся наукой с определенными критериями классификации организмов. Но это не так. Биолог Мэй сказал: "На сегодняшний день мы даже не знаем порядок числа видов растений и животных, населяющих нашу планету: классифицировано менее, чем 2 млн. видов, в то время как возможное число видов оценивается от 5 до 50 млн." [7]. Более того, различные критерии классификации одних и тех же животных приводят к разным результатам. Мартин утверждает, что "все зависит от того, что вы хотите получить. Если вы хотите, чтобы классификация говорила о кровном родстве видов, вы получите один ответ, если вы желаете отразить уровень приспособления, ответ будет другой" [8]. Можно заключить, что даже в строгих научных дисциплинах методы и критерии классификации сильно зависят от цели классификации.
Аналогичная ситуация сложилась и в химии [9]. В древние времена считалось, что все вещества суть комбинации земли, воздуха, огня и воды. В настоящее время такая классификация не может считаться сколько-нибудь удовлетворительной. В середине XVII в. Роберт Бойль предложил элементы как примитивные химические абстракции, из которых составляются более сложные вещества. Век спустя, в 1789 г., Лавуазье опубликовал первый список, содержащий 23 элемента, хотя впоследствии было открыто, что некоторые из них таковыми не являются.
Но открытие новых элементов продолжалось, список увеличивался. Наконец, в 1869 г. Менделеев предложил периодический закон, который давал точные критерии для классификации известных элементов и даже мог предсказывать свойства еще не открытых элементов. Но даже периодический закон не был концом истории о классификации элементов. В начале XX в. были открыты элементы с одинаковыми химическими свойствами, но с разными атомными весами - изотопы.
Вывод прост. Как утверждал Декарт: "Открытие порядка - нелегкая задача, но если он найден, понять его совсем не трудно" [10]. Лучшие программистские решения выглядят просто, но, как показывает опыт, добиться простой архитектуры очень трудно.
Итеративная суть классификации. Все эти сведения мы привели здесь не для того, чтобы оправдать "долгострой" в программном обеспечении, хотя на самом деле многим менеджерам и пользователям кажется, что необходимы века, чтобы закончить начатую работу. Мы просто хотели подчеркнуть, что разумная классификация - работа интеллектуальная и лучший способ ее ведения - последовательный, итеративный процесс. Это становится очевидным при анализе разработки таких программных продуктов, как графический интерфейс, стандарты баз данных и языки программирования четвертого поколения. Шоу утверждает, что в разработке программного обеспечения "развитие какой-либо абстракции часто следует общей схеме. В начале проблема решается ad hoc, то есть как-нибудь, для каждого частного случая. По мере накопления опыта некоторые решения оказываются более удачными, чем другие, и возникает род фольклора, переходящего от человека к человеку. Удачные решения изучаются более систематически, они программируются и анализируются. Это позволяет развить модели, осуществить их автоматическую реализацию, и разработать теорию, обобщающую найденное решение. Это в свою очередь поднимает практику на более высокий уровень и позволяет взяться за еще более сложную задачу, к которой, в свою очередь, мы подходим ad hoc, тем самым начиная новый виток спирали" [11].
Итеративный подход к классификации накладывает соответствующий отпечаток и на процедуру конструирования иерархии классов и объектов при разработке сложного программного обеспечения. На практике обычно за основу берется какая-то определенная структура классов, которую постепенно совершенствуют.
Разные наблюдатели классифицируют один и тот же объект по-разному.
И только на поздней стадии разработки, когда уже получен некоторый опыт использования такой структуры, мы можем критически оценить качество получившейся классификации. Основываясь на полученном опыте, мы можем создать новый подкласс из уже существующих (вывод), или разделить большой класс на много маленьких (факторизация), или, наконец, слить несколько существующих в один (композиция). Возможно, в процессе разработки будут найдены новые общие свойства, ранее не замеченные, и мы сможем определить новые классы (абстракция) [12].
Почему же классификация так сложна? Мы объясняем это двумя причинами. Во-первых, отсутствием "совершенной" классификации, хотя, естественно, одни классификации лучше других. Кумбс, Раффья и Трал утверждают, что "существует столько способов деления мира на объектные системы, сколько ученых принимается за эту задачу" [13]. Любая классификация зависит от точки зрения субъекта. Флуд и Кэрсон приводят пример: "Соединенное Королевство... экономисты могут рассматривать как экономический институт, социологи - как общество, защитники окружающей среды - как гибнущий уголок природы, американские туристы - как достопримечательность, советские руководители - как военную угрозу, наконец, наиболее романтичные из нас, британцев - как зеленые луга родины" [14]. Во-вторых, разумная классификация требует изрядной доли творческого озарения. Бертвистл, Даль, Мюрхауг и Нюгард заключают, что "иногда ответ очевиден, иногда он - дело вкуса, а бывает, что все зависит от умения заметить главное" [15]. Все это напоминает загадку: "Почему лазерный луч похож на золотую рыбку?..Потому, что ни тот, ни другой не умеют свистеть" [16]. Надо быть очень творческим мыслителем, чтобы найти общее в настолько несвязанных предметах.
4.2. Идентификация классов и объектов
Управление: контроль за движением поездов
Программная индустрия развилась настолько, что охватывает многие новые области приложений: от встроенных микрокомпьютеров для управления двигателем автомобиля до выполнения рутинной работы при изготовлении фильмов и обеспечения интерактивного доступа миллионов телезрителей к базам видеоинформации. Отличительной особенностью таких больших систем является их чрезвычайная сложность. Конечно, построить компактную реализацию системы - задача почетная, но некоторые большие задачи несомненно требуют большого объема кода. В крупных проектах нередко участвуют программистские организации в сотни человек, которые должны написать миллионы строк кода. Программы должны удовлетворять требованиям, неизбежно меняющимся в процессе работы. Как правило, в рамках таких проектов создается не одна программа, работающая на одном компьютере, а комплекс программ, функционирующих в параллельной распределенной среде на нескольких компьютерах, связанных между собой разнообразными каналами передачи информации. Для того, чтобы уменьшить вероятность неудачи, в таких проектах предусматривается обычно центральная организация, отвечающая за архитектуру и целостность системы. Некоторые части системы нередко выполняются по субконтрактам другими компаниями. Таким образом, команда разработчиков никогда не собирается вместе, она распределена в пространстве и, - так как в больших проектах происходит постоянное обновление кадров, - во времени.
Если за создание большой системы возьмется разработчик, который занимался написанием в оконной среде небольших программ, рассчитанных на одного пользователя, его несомненно испугают возникающие проблемы; возможно, даже настолько, что он сочтет глупостью попытку создать такую программу. Но действительность такова, что большие системы должны строиться. И в некоторых случаях глупо не попытаться. Вообразим себе ручное управление авиационными полетами во круг столичного аэропорта, систему жизнеобеспечения космической станции, зависящую от "человеческого фактора" или ведение учета в международном банке, выполняемое на счетах. Успешная автоматизация таких систем приводит не только к решению очевидных проблем, но и приносит множество неожиданных выгод: снижение эксплуатационных расходов, повышение надежности, увеличение функциональных возможностей. Конечно же, ключевое слово здесь - успешная. Из всего сказанного понятно, что создание больших систем - чрезвычайно трудная задача. Поэтому при ее решении необходимо применять все лучшее из инженерной практики и использовать интуицию ведущих проектировщиков.
В этой главе представлена как раз такая задача. Она демонстрирует, как объектно-ориентированное проектирование облегчает выполнение сверхбольших программных проектов.
12.1. Анализ
Управление памятью
Задача управления памятью возникает для неограниченных форм реализации. В этом случае разработчик библиотеки должен определить политику выделения и освобождения памяти из кучи при осуществлении операций над узлами. Наивный подход просто использует глобальные функции new и delete, что не может обеспечить достаточной производительности системы. Кроме того, на некоторых компьютерных платформах управление памятью крайне усложнено (например, при наличии сегментированного адресного пространства в некоторых операционных системах персональных компьютеров) и требует разработки специальной стратегии, жестко привязанной к определенной операционной среде. Для нашей библиотеки надо четко выделить подсистему управления памятью.
На рис. 9-5 приведен выбранный для данной библиотеки механизм управления памятью [Историческое замечание: потребовалось около четырех итераций архитектуры библиотеки, чтобы придти именно к этому механизму, который - что не удивительно - оказался самым простым. Предыдущие варианты, от которых мы в конце концов отказались, были недостаточно гибкими, трудными для объяснения и стремились навязать особенности реализации безразличным к ней клиентам]. Рассмотрим сценарий, иллюстрацией которого служит данная диаграмма:
Клиент (aClient) вызывает операцию добавления (append) для экземпляра класса UnboundedQueue (более точно, экземпляра класса, инстанцированного из UnboundedQueue).
UnboundedQueue, в свою очередь, передает выполнение операции своему элементу rep, который является экземпляром класса unbounded.
Unbounded, вызывая свою статическую функцию new, выделяет необходимый объем адресного пространства для размещения нового экземпляра Node.
Этот экземпляр Node, в свою очередь, делегирует ответственность за выделение памяти своему StorageManager, который доступен классу, инстанцируемому из UnboundedQueue (и, следовательно, классам Unbounded и Node), как аргумент шаблона. StorageManager разделяется всеми экземплярами и служит для обеспечения последовательной политики выделения памяти на уровне класса.
Рис. 9-5. Механизм управления памятью.
Передавая StorageManager в качестве аргумента всем неограниченным структурам, мы четко отделяем политику организации доступа к памяти от ее реализации и даем пользователям возможность добавлять в программу свои собственные концепции управления памятью, не меняя при этом содержания библиотеки. Это классический пример того, как можно добиться открытости программной системы через инстанцирование, не прибегая к наследованию.
Единственное требование, предъявляемое к вариантам StorageManager, заключается в необходимости сохранения единого протокола. В частности, все они должны содержать открытые функции-члены allocate и deallocate, предназначенные соответственно для выделения и освобождения памяти. Рассмотрим в качестве примера простейший вариант такого класса:
class Unmanaged {
public: static void* allocate(size_t s) {return ::operator new(s);}
static void deallocate(void* p, size_t) {::operator delete(p);}
private: Unmanaged() {}
Unmanaged(Unmanaged&) {}
void operator=(Unmanaged&) {}
void operator==(Unmanaged&) {}
void operator!=(Unmanaged&) {}
};
Обратите внимание на идиому, которая применяется, чтобы пользователь не мог копировать, присваивать и сравнивать экземпляры данного класса.
Протокол класса Unmanaged реализован через встроенные вызовы глобальных операторов new и delete. Мы назвали данную абстракцию Unmanaged, не требующей управления, так как она фактически не представляет собой ничего нового, а просто повторяет уже существующий системный механизм. Требующей управления названа другая абстракция, реализующая гораздо более эффективный алгоритм. В соответствии с этим алгоритмом память под узлы выделяется из некоего общего пула памяти. Если узел не используется, он помечается как свободный. Если возникает необходимость в новом узле, используется один из списка свободных. Выделение новой памяти из кучи происходит только в случае, если этот список пуст. Таким образом, часто удается избежать обращения к сервисным функциям операционной системы: выделение памяти сводится лишь к манипулированию указателями, что гораздо быстрее [В языке C++ глобальный оператор new так или иначе вызывает какой-либо вариант функции malloc - операции довольно дорогой].
При желании можно еще улучшить наш механизм, например, введя новую операцию для выделения памяти заранее, до того, как она понадобится. И наоборот, в определенных ситуациях, когда неиспользованных участков становится слишком много, можно дефрагментировать пул, и вернуть освободившуюся память в кучу. Можно предусмотреть операцию, позволяющую пользователю определить размер кластера памяти, и, таким образом, настроить класс под конкретное приложение.
В соответствии с приведенными выше соображениями, соответствующий класс поддержки можно определить следующим образом:
class Pool {
public: Pool(size_t chunkSize);
~Pool();
void* allocate(size_t);
void deallocate(void*, size_t);
void preallocate(unsigned int numberOfChunks);
void reclaimUnusedChunks();
void purgeUnusedChunks();
size_t chunkSize() const;
unsigned int totalChunks() const;
unsigned int numberOfDirtyChunks() const;
unsigned int numberOfUnusedChunks() const;
protected: struct Element ...
struct Chunk ...
Chunk* head;
Chunk* unusedChunks;
size_t repChunkSize;
size_t usableChunkSize;
Chunk* getChunk(size_t s);
};
Описание содержит два вложенных класса Element и chunk (отрезок). Каждый экземпляр класса Pool управляет связным списком объектов chunk, представляющих собой отрезки "сырой" памяти, но трактуемых как связные списки экземпляров класса Element (это один из важных аспектов, управляемых классом pool). Каждый отрезок может отводиться элементам разного размера и для эффективности мы сортируем список отрезков в порядке возрастания их размеров. Менеджер памяти может быть определен следующим образом:
class Managed {
public: static Pool& pool;
static void* allocate(size_t s) {return pool.allocate(s); }
static void deallocate(void* p, size_t s) {pool.deallocate(p, s);}
private: Managed() {}
Managed(Managed&) {}
void operator=(Managed&) {}
void operator==(Managed&) {}
void operator!=(Managed&) {}
};
Этот класс имеет тот же внешний протокол, что и Unmanaged. Из-за того, что в C++ шаблоны сознательно недостаточно четко определены, соответствие данному протоколу проверяется только при трансляции инстанцированного класса типа UnboundedQueue, в тот момент, когда конкретный класс сопоставляется с формальным аргументом StorageManager.
Объект класса Pool, принадлежащий классу Managed, является статическим. Это позволяет нескольким конкретным структурам (требующим управления) делить между собой единый пул памяти. Различные структуры, не требующие управления, могут, конечно, определить своего менеджера и свой пул памяти, предоставляя таким образом разработчику полный контроль над политикой выделения памяти.
Рис. 9-6. Классы управления памятью.
На рис. 9-6 приведена диаграмма классов, иллюстрирующая схему взаимодействия различных классов, обеспечивающих управление памятью. Мы показали только ассоциативную связь между классом Managed и его клиентами Unbounded и UnboundedQueue; эта ассоциация будет уточнена при конкретном инстанцировании классов.
Физическая компоновка классов поддержки тоже является частью архитектурного решения. Рис. 9-7 иллюстрирует их модульную архитектуру. Мы выбрали именно такую схему, чтобы изолировать классы, которые, по-видимому, будут чаще всего подвергаться изменениям.
Рис. 9-7. Модули управления памятью.
Исключения
Несмотря на то, что язык C++ можно заставить соблюдать многие статические предположения (нарушение которых повлечет ошибку компиляции), для выявления динамических нарушений (таких, как попытка добавить элемент к полностью заполненной ограниченной очереди или удалить элемент из пустого списка) приходится использовать и другие механизмы. В данной библиотеке используются средства обработки исключений, предоставляемые C++ [14]. Наша архитектура включает в себя иерархию классов исключений и, отдельно от нее, ряд механизмов по выявлению таких ситуаций.
Начнем с базового класса Exception (исключение), обладающего несложным протоколом:
class Exception {
public: Exception(const char* name, const char* who, const char* what);
void display() const;
const char* name() const;
const char* who() const;
const char* what() const;
protected:
...
};
Каждой особой ситуации можно сопоставить имя ее источника и причину возникновения. Кроме того, мы можем обеспечить скрытые от клиентов средства для вывода информации об ошибке в соответствующий поток.
Анализ различных классов нашей библиотеки подсказывает возможные типы исключений, которые можно оформить в виде подклассов базового класса Exception:
ContainerError
Duplicate
IllegalPattern
IsNull
LexicalError
MathError
NotFound
NotNull
NotRoot
Overflow
RangeError
StorageError
Underflow
Объявление класса overflow (переполнение) может выглядеть следующим образом:
class Overflow : public Exception {
public: Overflow(const char* who, const char* what)
: Exception("Overflow", who, what) {}
};
Обязанность этого класса состоит лишь в знании своего имени, которое он передает конструктору суперкласса.
В данном механизме функции-члены классов библиотеки только возбуждают исключения; они не в состоянии перехватить исключение, главным образом, потому, что ни одна из них не может осмысленно отреагировать на эту ситуацию. По соглашению мы возбуждаем исключение при нарушении условий, предполагавшихся относительно некоторого состояния. Условие представляет собой обычное булевское выражение, которое должно быть истинным в нормальной ситуации. Чтобы упростить библиотеку, мы ввели следующую функцию, не принадлежащую ни одному из классов:
inline void _assert(int expression, const Exception& exception)
{ if (!expression) throw(exception);
}
Для эффективности мы определили эту функцию как встроенную. Преимущество подобной схемы состоит в том, что она локализует все исключения (в C++ throw имеет синтаксис вызова функции). Так, для трансляторов, которые до сих пор не поддерживают исключений, можно использовать специальную директиву (-D для большинства трансляторов C++) для переопределения вызова throw в вызов другой функции-не-члена, выводящей сообщение на экран и останавливающей выполнение программы:
void _catch(const Exception& e)
{ cerr << "EXCEPTION: ";
e.display();
exit(1);
}
Рассмотрим реализацию функции insert класса Bounded:
template<class Item, unsigned int Size>
void Bounded<Item, Size>::insert(const Item& item)
{ unsigned int count = length();
_assert((count < Size), Overflow("Bounded::Insert", "structure is full"));
if (!count) start = stop = 1;
else
{ start--;
if (!start) start = Size;
}
rep[start - 1] = item;
}
Предусмотрено, что в процессе выполнения функции проверяется, что размер структуры не превосходит максимально допустимого. Если это не так, возбуждается исключение Overflow.
Важнейшим преимуществом этого подхода является гарантия того, что состояние объекта, возбудившего исключение, не будет нарушено (не считая случая исчерпания оперативной памяти, когда уже в принципе ничего нельзя поделать). Любая функция, прежде чем произвести действия, способные изменить состояние объекта, проверяет предположение. В приведенной выше функции insert, например, прежде, чем добавить элемент в массив, мы сначала вызываем селектор (который не может вызвать изменения состояния объекта), затем проверяем все предусловия функции и лишь затем изменяем состояние объекта. Мы скрупулезно придерживались подобного стиля при реализации всех функций и настоятельно советуем не отходить от него при конструировании подклассов, основанных на нашей библиотеке.
Рис. 9-8 иллюстрирует схему взаимодействия классов, обеспечивающих реализацию механизма обработки исключений.
Рис. 9-8. Классы обработки исключений.
Управление релизами
Теперь, полностью определив архитектурный каркас системы складского учета, мы можем приступить к последовательному развитию. Выберем сначала наиболее важные транзакции в нашей системе (ее вертикальный срез) и выпустим продукт, который по крайней мере симулирует выполнение транзакций.
Для примера остановимся на трех простых транзакциях: занесение в базу нового клиента, добавление товара и принятие заказа. При реализации этих транзакций мы в той или иной степени затронем практически все архитектурные интерфейсы. Если мы сможем успешно преодолеть этот ключевой этап, то дальше будем выпускать релизы в следующем порядке:
Модификация или удаление данных о клиентах; модификация или удаление данных о продуктах: модификация заказа; запросы о клиентах, заказах и продуктах.
Интеграция всех похожих транзакции, связанных с поставщиками: создание заказа и выписка счета.
Интеграция всех оставшихся транзакций, связанных со складом: составление отчетов и выписка расходных накладных.
Интеграция всех оставшихся транзакций, связанных с бухгалтерией: поступление оплаты.
Интеграция всех оставшихся транзакции, связанных с отгрузкой.
Интеграция всех оставшихся транзакций, связанных с планированием.
При общем сроке проектирования системы в 12-18 месяцев необходимо каждые 3 месяца выпускать рабочий релиз программы. К окончанию срока все необходимые для работы системы транзакции будут охвачены.
В главе 6 уже упоминалось, что ключевым моментом при такой стратегам является выявление риска, поэтому для каждого релиза мы находим самое опасное место и активно прорабатываем его. Для приложений клиент/сервер это связано, в первую очередь, с возможно более ранним тестированием вместимости и масштабируемости (чтобы как можно раньше найти узкие места системы и сделать с ними что-нибудь). При этом в каждый релиз следует включать транзакции из разных функциональных элементов системы - тогда будет меньше шансов столкнуться с неожиданностями.
Управление риском
В конечном счете, главная обязанность менеджера программного продукта - управление как техническим, так и нетехническим риском. Технический риск для объектно-ориентированной системы содержится в решении таких проблем, как выбор структуры наследования классов, обеспечивающий наилучший компромисс между удобством и гибкостью программного продукта. Серьезное решение приходится также принимать при выборе механизмов упрощения архитектуры и улучшения эффективности. Нетехнический риск содержит в себе такие вопросы, как контроль своевременности поставки программных продуктов от третьих фирм или регулирование отношений заказчика и разработчиков, что необходимо для выяснения реальных требований к системе на стадии анализа.
Как было описано в предыдущей главе, микропроцесс объектно-ориентированной разработки нестабилен по своей природе и требует активного управления, концентрации усилий. К счастью, существует макропроцесс разработки, который выдвигает ряд конкретных требований и характеристик. Менеджер проекта, изучая соответствие требований и фактических результатов, может оценить состояние разработки и, при необходимости, перенаправить ресурсы команды. Эволюционная суть макропроцесса разработки означает, что можно распознать проблемы в начале жизненного цикла и продуманно учесть связанный с ними риск прежде, чем проект окажется в опасности.
Многие виды деятельности по управлению разработкой программного обеспечения, например, планирование задач и просмотры, предусмотрены не только в объектно-ориентированной технологии. Однако при управлении объектно-ориентированным проектом намечаемые задачи и рассматриваемые результаты не совсем такие, как в других системах.
Выбор реализации
Внутреннее строение (реализация) классов и объектов разрабатывается только после завершения проектирования их внешнего поведения. При этом необходимо принять два проектных решения: выбрать способ представления класса или объекта и способ размещения их в модуле.
Представление. Представление классов и объектов почти всегда должно быть инкапсулировано (скрыто). Это позволяет вносить изменения (например, перераспределение памяти и временных ресурсов) без нарушения функциональных связей с другими классами и объектами. Как мудро отметил Вирт: "выбор способа представления является нелегкой задачей и не определяется одними лишь техническими средствами. Он всегда должен рассматриваться с точки зрения операций над данными" [60]. Рассмотрим, например, класс, соответствующий расписаниям полетов самолетов. Как его нужно оптимизировать - по эффективности поиска или по скорости добавления/удаления рейса? Поскольку невозможно реализовать и то, и другое одновременно, нужно сделать выбор, исходя из целей системы. Иногда такой выбор сделать непросто, и тогда создается семейство классов с одинаковым интерфейсом, но с принципиально разной реализацией для обеспечения вариативности поведения.
Одним из наиболее трудных решений является выбор между вычислением элементов состояния объекта и хранением их в виде полей данных. Рассмотрим, например, класс Cone (конус) с соответствующим ему методом volume (объем). Этот метод возвращает значение объема объекта. В структуре конуса в виде отдельных полей хранятся данные о его высоте и радиусе основания. Следует ли еще создать поле данных для объема или следует вычислять его по мере необходимости внутри метода volume [60]? Если мы хотим получать значение объема максимально быстро, нужно создавать соответствующее поле данных. Если важнее экономия памяти, лучше вычислить это значение. Оптимальный способ представления объекта всегда определяется характером решаемой задачи. В любом случае этот выбор не должен влиять на интерфейс класса.
Модульная структура. Аналогичные вопросы возникают при распределении деклараций классов и объектов по модулям.
В языке Smalltalk эта проблема отсутствует, здесь модульный механизм не реализован. В языках Object Pascal, C++, CLOS и Ada существует понятие модуля как отдельной языковой конструкции. Решение о месте декларирования классов и объектов в этих языках является компромиссом между требованиями видимости и скрытия информации. В общем случае модули должны быть функционально связными внутри и слабо связанными друг с другом. При этом следует учитывать ряд нетехнических факторов, таких, как повторное использование, безопасность, документирование. Проектирование модулей - не более простой процесс, чем проектирование классов и объектов. О скрытии информации Парнас, Клеменс и Вейс говорят следующее: "Применение этого принципа не всегда очевидно. Принцип нацелен на минимизацию стоимости программных средств (в целом за время эксплуатации), для чего от проектировщика требуется способность оценивать вероятность изменений. Такие оценки основываются на практическом опыте и знаниях предметной области, включая понимание технологии программирования и аппаратных особенностей" [61].
Выводы
Объект характеризуется состоянием, поведением и идентичностью.
Структура и поведение одинаковых объектов описывается в общем для них классе.
Состояние объекта определяет его статические и динамические свойства.
Поведение объекта характеризуется изменением его состояния в процессе взаимодействия (посредством передачи сообщений) с другими объектами.
Идентичность объекта - это его отличия от всех других объектов.
Иерархия объектов может строиться на принципах связи или агрегации.
Множество объектов с одинаковой структурой и поведением является классом.
Шесть типов иерархий классов включают: ассоциирование, наследование, агрегация, использование, инстанцирование и метаклассирование.
Классы и объекты, образующие словарь предметной области, называются ключевыми абстракциями.
Структура, объединяющая множество объектов и обеспечивающая их совместное целенаправленное функционирование, называется механизмом.
Качество абстракций измеряется их зацеплением, связностью, достаточностью, полнотой и примитивностью.
Дополнительная литература
МакЛеннан (MacLennan) [G 1982] обсуждал различие между значениями и объектами. Работа Мейера (Meyer) [F 1987] предлагает контрактный подход к программированию.
По поводу иерархии классов было написано много, особое внимание уделялось наследованию и полиморфизму. Работы Альбано (Albano) [G 1983], Аллена (Allen) [A 1982], Брахмана (Brachman) [J 1983], Хайлперна и Нгуена (Hailpern and Nguyen) [G 1987], и Вегнера и Здоника (Wegner and Zdonik) [J 1988] создали блестящее теоретическое обоснование всех основных вопросов и концепций. Кук и Палсберг (Cook and Palsberg) [I 1989] и Турецкий (Touretzky) [G 1986] дали формальное истолкование семантики наследования. Вирт (Wirth) [G 1987] предложил сходные решения для обобщенных структурных типов в Oberon. Ингалс (Ingalls) [G 1986] дал полезное обсуждение вопроса множественного полиморфизма. Грогоно (Grogono) [G 1989] изучает взаимодействие полиморфизма и проверки типов, а Пондер и Бач (Ponder and Buch) [G 1992] предупреждают об опасностях безграничного полиморфизма. Практические рекомендации по эффективному использованию наследования предложили Мейер (Meyer) [G 1988] и Халберд и О'Брайан (Halberd and O'Brien) [G 1988]. ЛаЛонд и Пух (LaLonde and Pugh) [I 1985] изучали задачи обучения эффективному использованию специализации и обобщения.
Природа ролей и обязанностей абстракции подробна рассмотрена в работе Рубина и Голдберга (Rubin and Goldberg) [В 1992], а также Вирфс-Брока, Вилкерсона и Винера (Wirfs-Brock, Wilkerson and Wiener) [F 1990]. Качество классов рассматривал также Коад (Coad) [F 1991].
Мейер (Meyer) [G 1986] изучал связи между обобщенными функциями и наследованием применительно к языку Eiffel. Страуструп (Stroustrup) [G 1988] предложил механизм параметризованных типов в C++. Протокол метаобъектов в CLOS описали в деталях Кишалец, Ривьерес и Бобров (Kiczales, Rivieres, and Bobrow) [G 1991].
Альтернативу иерархии, основанной на классах, предоставляет делегирование, использующее только экземпляры. Этот подход детально рассмотрел Стейн (Stein) [G 1987].
Выяснение семантики классов и объектов
Цель. Цель выяснения семантики классов и объектов - определить поведение и атрибуты каждой абстракции, выявленной на предыдущем шаге. При этом мы уточняем намеченные абстракции, продуманно и измеримо распределяя между ними обязанности.
На стадии анализа мы применяем этот шаг, чтобы распределить обязанности между различными видами поведения системы. На стадии проектирования мы применяем процедуру выяснения семантики, чтобы четко распределить обязанности между частями реализации. При реализации мы продвигаемся от описаний ролей и обязанностей в свободной форме к спецификациям конкретных протоколов для каждой абстракции и, в конечном счете, - к точным сигнатурам каждой операции.
Результаты. На этом шаге получаются несколько результатов. Первым является уточнение словаря данных, с помощью которого мы изначально присвоили обязанности абстракциям. В ходе проектирования мы можем выработать спецификации к каждой абстракции (как описано в главе 5), перечисляя имена операций в протоколе каждого класса. Затем, как можно скорее, мы выразим интерфейсы этих классов на языке реализации. Для C++ это означает создание .h-файлов, в Ada - спецификаций пакетов, в CLOS - обобщенных функций для каждого класса, в Smalltalk - это объявление, но не реализация методов каждого класса. Если проект связан с базой данных, особенно с объектно-ориентированной, на этом шаге мы получаем общий каркас нашей схемы данных.
В добавление к этим, по сути тактическим решениям, мы составляем диаграммы объектов и диаграммы взаимодействий, передающие семантику сценариев, создаваемых в ходе макропроцесса. Эти диаграммы формально отражают рас-кадровку каждого сценария и, таким образом, описывают явное распределение обязанностей среди взаимодействующих объектов. На этом шаге впервые появляются конечные автоматы для представления некоторых абстракций.
Чтобы команда разработчиков могла развивать согласованный язык обозначений и для учета обязанностей каждой абстракции, мы можем, как и на предыдущем шаге, использовать специализированную базу данных или другие, более специфические инструменты проектирования.
Когда мы напишем на выбранном языке формальные интерфейсы классов, мы можем использовать наши инструменты проектирования для проверки и гарантии выполнения принятых решений.
Главная выгода большей формальности результатов на этом шаге состоит в том, что она помогает разработчику увидеть назначение всех протоколов абстракции. Невозможность четко определить смысл - признак зыбкости самих абстракций.
Виды деятельности. С этим шагом связано три вида деятельности: раскадровка, проектирование изолированных классов и поиск шаблонов.
Главными объектами раскадровки являются основные и второстепенные сценарии, полученные в макропроцессе. В ходе этой деятельности происходит нисходящее выяснение семантики. Там, где это касается функциональных точек системы, принимаются стратегические решения. Типичный ход выполнения действий может быть таким:
Выбрать сценарий (или группу сценариев), связанный с отдельной функциональной точкой; на основании результатов предыдущего шага определить относящиеся к этому сценарию абстракции.
Проследить действия в этом сценарии, наделяя каждую абстракцию обязанностями, достаточными, чтобы получить требуемое общее поведение. Если необходимо - выбрать атрибуты, которые будут представлять структурные элементы, требуемые для выполнения отдельных обязанностей.
По ходу раскадровки перераспределить обязанности так, чтобы сбалансировать поведение. Где возможно, использовать или адаптировать уже существующие обязанности. Очень распространенным приемом является деление больших обязанностей на малые; иногда тривиальные обязанности объединяются в более сложные.
Неформально мы можем использовать для раскадровки CRC-карточки. Для большей формальности команде разработчиков следует составить диаграммы объектов и взаимодействий. На стадии анализа раскадровка обычно выполняется командой, включающей, как минимум, аналитика, эксперта в предметной области, архитектора и контролера качества. На стадии проектирования и позже, при реализации, раскадровка выполняется архитектором и старшими разработчиками для доводки стратегических решений, и отдельными разработчиками - для доводки тактических решений.
Привлечение дополнительных членов команды к участию в раскадровке - в высшей степени эффективный путь обучения начинающих разработчиков и передачи им сложившегося видения архитектуры.
В начале разработки проекта мы можем задавать семантику классов и объектов в свободной форме, просто описывая обязанности каждой абстракции. Обычно достаточно фразы или предложения; если этого мало - мы встречаем верный признак того, что данная обязанность является чрезмерно сложной и должна разделиться на меньшие. На более поздних стадиях разработки, когда мы будем заниматься доводкой протоколов отдельных абстракций, можно указать имена специфических операций, не определяя их полные сигнатуры, которые мы выясним потом. Таким образом, мы получим соответствие: каждая обязанность выполняется набором операций, а каждая операция как-либо участвует в выполнении обязанностей соответствующей абстракции. После этого, чтобы отразить динамическую семантику протоколов классов [Как мы описывали в главе 3, протокол определяет, что некоторые операции должны вызываться в определенном порядке. Для всех случаев кроме самых тривиальных операции редко встречаются в одиночестве; выполнение каждой из них имеет свои предусловия, проверка которых часто требует вызова других операции], имеющих управляемое событиями или зависящее от состояния поведение, мы можем построить конечные автоматы для них.
На этом шаге важно сосредоточить внимание больше на поведении, чем на структуре. Атрибуты представляют структурные элементы, а, значит, есть опасность, особенно на ранних стадиях анализа, преждевременным указанием некоторых атрибутов стеснить реализационные решения. Атрибуты должны идентифицироваться на этом этапе лишь настолько, насколько они необходимы в построении концептуальной модели сценария.
Проектирование изолированных классов - это восходящее выяснение семантики. Здесь мы концентрируем наше внимание на отдельных абстракциях и, применяя описанные в главе 3 эвристики для проектирования классов, рассматриваем их операции.
Это действие по своей природе более тактическое, потому что здесь мы затрагиваем проектирование классов, а не архитектуры. Порядок его выполнения может быть следующим:
Выбрать одну абстракцию и перечислить ее роли и обязанности.
Определить необходимое множество операций, удовлетворяющих этим обязанностям. Попытаться, где возможно, использовать операции для концептуально схожих ролей и обязанностей повторно.
Рассмотреть каждую операцию абстракции: если она не примитивна - выделить и определить примитивы. Составные операции могут быть оставлены в самом классе (либо из-за их общности, либо по соображениям эффективности) или могут быть отправлены в утилиту классов (если они будут часто изменяться). Где это возможно следует рассмотреть минимальный набор примитивных операций.
Учесть конструирование, копирование и уничтожение объектов [13]. Если не имеется причин поступить иначе, лучше иметь общие стратегические принципы для таких операций, чем позволить отдельным классам вводить свои собственные решения.
Придать завершенность: добавить другие примитивные действия, которые не нужны существующим клиентам, но "округляют" абстракцию, что повышает вероятность использования ее новыми клиентами. Помня, что невозможно иметь полную завершенность, стремиться к простоте.
Важно избегать преждевременного определения отношения наследования - это часто ведет к потере целостности типа.
На ранних этапах разработки проектировать отдельные классы можно изолировано. Однако, как только мы определим структуры наследования, этот шаг будет включать в себя размещение операций в иерархии классов. Рассматривая операции, связанные с некоторым уровнем абстракции, мы должны решить, на каком уровне абстракции их разместить. Операции, которые могут быть использованы несколькими классами одного уровня, должны быть помещены в их общий суперкласс, который, возможно, придется создать. Действия, которые совместно используются никак не связанными классами, должны быть инкапсулированы в класс-примесь.
Третий вид деятельности - поиск шаблонов - связан с обобществлением абстракций. Выявляя семантику классов и объектов, мы должны отмечать шаблоны поведения, которые могут пригодиться где-нибудь еще. Этот процесс может проистекать в следующем порядке:
Имея полный набор сценариев на этом уровне абстракции, найти шаблоны взаимодействия абстракций. Такие взаимодействия могут представлять неявные идиомы или механизмы. Они должны быть исследованы, чтобы гарантировать, что не имеется никаких необоснованных различий в вызовах операций. Нетривиальные шаблоны взаимодействия нужно явно документировать как стратегические решения, чтобы они по возможности могли быть повторно использованы, а не изобретались заново. Это повышает архитектурную целостность.
Имея набор обязанностей для данного уровня абстракции, отыскать шаблоны поведения. Общие роли и обязанности должны быть унифицированы в форме общих классов - базовых, абстрактных или примесей.
Если уже специфицированы конкретные операции, найти шаблоны среди сигнатур операций. Если среди них встречаются часто повторяющиеся, устранить все непринципиальные различия и ввести классы-примеси или утилиты классов.
Выяснение и описание семантики применяется к категориям классов так же, как к отдельным классам. Семантика классов и их категорий определяет роли, обязанности и операции. Для отдельного класса операции могут быть со временем выражены как его функции-члены; в случае категории классов эти операции представляют экспортируемые из категории услуги, и в конечном счете реализуются набором сотрудничающих классов или отдельным классом. Таким образом, действия, описанные выше, применимы и к проектированию классов, и к проектированию архитектуры.
Путевые вехи и характеристики. Мы благополучно завершим этот шаг, когда будем иметь более или менее достаточный, примитивный и полный набор обязанностей и/или операций для каждой абстракции. В начале разработки достаточно иметь неформальный список обязанностей, а в дальнейшем мы постепенно уточняем семантику.
Качественные показатели включают все эвристики классов, описанные в главе 3. Сложные и туманные обязанности и операции говорят о том, что абстракции еще недостаточно определены. Невозможность написать конкретный файл заголовков или как-либо по другому формализовать интерфейс классов также говорит о том, что абстракции плохо сформулированы, или что основные понятия определяли не те люди [Остерегайтесь аналитиков и архитекторов, если они не хотят или не могут выразить конкретно семантику своих абстракции; это признак надменности или беспомощности].
При просмотре сценариев ожидайте бурных дебатов. Это помогает разработчикам делиться архитектурными представлениями и развивать искусство определения абстракций. Не проверенные абстракции не стоит пытаться кодировать.
Выявление классов и объектов
Цель. Цель выявления классов и объектов состоит в том, чтобы найти границы предметной области. Кроме того, эта деятельность является первым шагом в продумывании объектно-ориентированной декомпозиции разрабатываемой системы.
Мы применяем этот шаг в анализе, когда обнаруживаем абстракции, составляющие словарь предметной области и ограничиваем нашу задачу, решая, что важно, а что - нет. Такие действия необходимы при проектировании, когда мы изобретаем новые абстракции, которые являются составными частями решения. Переходя к программной реализации, мы применяем процедуру выявления, чтобы изобрести простые абстракции, из которых строятся более сложные, и обнаружить общие черты существующих абстракций, дабы упростить архитектуру системы.
Результаты. Главным результатом этого шага является обновляющийся по мере развития проекта словарь данных. Вначале достаточно составить список действующих лиц, состоящий из всех заметных классов и объектов, названых именами, отражающими их смысл [12]. Когда словарь разрастется, можно сделать простейшую базу данных, или более специальный инструмент проектирования, непосредственно поддерживающий выбранный метод разработки [Формально, словарь данных объектно-ориентированной разработки должен содержать спецификации каждого элемента архитектуры]. В своих более формальных разновидностях словарь данных служит предметным указателем для всех остальных компонентов проекта, включая диаграммы и спецификации обозначений объектно-ориентированного проектирования.
Рис. 6-1. Микропроцесс.
Таким образом, словарь данных - центральное хранилище относящихся к системе абстракций. Вначале допустимо держать словарь данных открытым для изменений: некоторые персонажи могут оказаться классами, некоторые - объектами, другие - атрибутами, а иные - просто синонимами других абстракций. Постепенно содержимое словаря уточняется путем введения новых, исключения лишних и объединения схожих абстракций.
Создание словаря данных на этом шаге дает три существенных выигрыша.
Во-первых, сама работа с ним помогает выработать общепринятую и исчерпывающую терминологию, которой можно пользоваться на протяжении всего проекта. Во-вторых, словарь - естественное оглавление ко всем материалам проекта и система точек входа для доступа к проекту в произвольном порядке. Это особенно полезно, когда в команду принимается новый разработчик, который должен быстро войти в курс дел. В-третьих, словарь данных позволяет архитектору окинуть весь проект единым взглядом, что может привести к открытию новых общностей, которые иначе могли бы быть упущены.
Виды деятельности. Как мы описывали в главе 4, выявление классов и объектов связано с двумя видами творческих актов: открытием и изобретением.
Не каждый член команды должен быть равно искусен во всем. Аналитики, особенно работающие с экспертами в предметной области, должны уметь хорошо обнаруживать абстракции, то есть находить осмысленные классы и объекты в предметной области. Тем временем архитекторы и старшие разработчики придумывают классы и объекты, решающие чисто программистские проблемы. Мы обсудим природу этих творческих актов в следующей главе.
В любом случае основой для выявления классов и объектов служат методы классификации, описанные в главе 4. Обычный порядок действий таков:
Применить классический подход к классификации (см. раздел 4.2, "Объектно-ориентированный анализ"), чтобы получить множество кандидатов в классы и объекты. В начале жизненного цикла хорошими стартовыми точками являются материальные элементы и их роли. Затем исследовать последовательности событий, что даст другие абстракции первого и второго порядка: в конце концов, для каждого события мы должны иметь объект, который отвечает за его обнаружение и/или обработку.
Применить технику анализа поведения (см. там же) и выявить абстракции, которые непосредственно связаны с функциональными точками системы. Функциональные точки системы, как будет сказано подробнее в этой главе, берутся из макропроцесса и представляют отдельные проверяемые и внешне наблюдаемые поведения системы.
Как и в случае событий, для каждого поведения можно найти классы и объекты, которые инициируют его и участвуют в нем.
Для соответствующих сценариев, созданных в макропроцессе, применить технику анализа вариантов (см. там же). В начале жизненного цикла мы исследуем самые общие сценарии поведения системы. В процессе разработки мы постепенно переходим ко все более детализированным сценариям, добираясь до самых темных уголков поведения системы.
В каждом из этих подходов CRC-карточки являются эффективным катализатором "мозгового штурма" и помогают теснее сплотить коллектив, подталкивая его членов к общению [Это ужасно банально, но некоторые проектировщики программ и в самом деле не очень общительны].
Некоторые классы и объекты будут определены в начале жизненного цикла проекта неправильно, но это не всегда плохо. Многие осязаемые вещи и роли, которые мы перечислим в жизненном цикле, пройдут через весь путь вплоть до реализации - настолько они фундаментальны для нашей концептуальной модели. Разбираясь в задаче, мы, вероятно, будем изменять границы некоторых абстракций, перераспределяя ответственности, объединяя подобные или (чаще всего), разбивая большие абстракции на группы взаимодействующих, формируя таким образом некоторые механизмы решения.
Путевые вехи и характеристики. Мы благополучно завершим эту фазу, когда будем иметь достаточно стабильный словарь данных. Поскольку микропроцесс развивается итеративно, следует ожидать, что словарь будет закончен и закрыт лишь на очень поздней стадии проекта. Пока нас удовлетворяет обильный, даже избыточный набор абстракций с содержательными именами и разумным распределением обязанностей.
Признаком качества, следовательно, будет то, что словарь не подвергается серьезным изменениям каждый раз, когда мы проходим новую итерацию микропроцесса. Неустойчивость словаря показывает, что разработчики еще не достигли желаемого, или в архитектуре что-то не так. По ходу разработки мы можем контролировать устойчивость нижних уровней архитектуры, отслеживая результаты локальных изменений взаимодействующих абстракций.
Выявление связей между классами и объектами
Цель. Цель выявления связей между классами и объектами - уточнить границы каждой обнаруженной ранее в микропроцессе абстракции и опознать все сущности, с которыми она взаимодействует. Это действие формализует концептуальное и физическое размежевание между абстракциями, начатое на предыдущем шаге.
Мы применяем этот шаг в анализе для спецификации связей между классами и объектами (включая некоторые важные отношения наследования и агрегации).
Существование ассоциации подразумевает некоторую семантическую зависимость между двумя абстракциями и возможность перехода от одной сущности к другой. Этот этап проектирования нужен, чтобы специфицировать взаимодействия, которые формируют механизмы нашей архитектуры и группирование классов в категории и модулей в подсистемы. В ходе реализации мы приводим ассоциации к более конкретному виду: инстанцирование, использование и т.д.
Результаты. Основными результатами этого шага являются диаграммы классов, объектов и модулей. Хотя в конце концов мы должны выразить наши решения, принятые при анализе и проектировании, на языке программирования, диаграммы дают более широкий обзор архитектуры и, кроме того, позволяют раскрыть отношения, которые с трудом формулируются на используемом языке реализации.
При анализе мы составляем диаграммы классов, на которых указываются ассоциации между абстракциями, и добавляем к ним детали, полученные на предыдущем шаге (операции и атрибуты некоторых абстракций), необходимые, чтобы передать суть наших решений. При проектировании мы уточняем эти диаграммы, чтобы отразить принятые тактические решения о наследовании, агрегации, инстанцировании и использовании.
Нет ни возможности, ни необходимости создавать исчерпывающий набор диаграмм, которые определили бы все возможные виды связей между нашими абстракциями. Нужно сосредоточиться на "интересных отношениях", причем подразумевается, что в число "интересных" входят те связи между абстракциями, которые отражают фундаментальные архитектурные решения или выражают детали, необходимые для реализации.
Результатом анализа на данном этапе являются диаграммы классов, которые содержат категории классов, идентифицирующие кластеры абстракций, сгруппированные по слоям и разделам. Эти результаты пригодятся и для документирования.
При анализе мы также строим диаграммы объектов, завершая тем самым просмотр сценариев, начатый на предыдущем шаге. Отличие в том, что мы можем теперь рассмотреть взаимодействия между классами и объектами и обнаружить скрытые ранее общие механизмы взаимодействия, которыми следует воспользоваться. Обычно это приводит к локальным перестройкам структуры наследования. При проектировании мы пользуемся диаграммами объектов вместе с более детализированным описанием состояний, чтобы показать действие наших механизмов в динамике. Явный результат этого шага - набор диаграмм, которые идентифицируют механизмы взаимодействия.
При реализации мы должны принять решения о физическом разбиении нашей системы на модули и о распределении процессов по процессорам. Эти решения мы можем выразить на диаграммах модулей и процессов.
На этом же шаге также обновляется словарь данных. В нем отражаются распределения классов и объектов по категориям и модулей по подсистемам.
Основная польза полученных результатов в том, что они помогают наглядно показать и понять отношения, которыми связаны концептуально и физически далекие сущности.
Виды деятельности. С этим шагом связано три вида деятельности: спецификация ассоциаций, идентификация различных взаимодействий и уточнение ассоциаций. Спецификация ассоциаций является одним из основных действий в анализе и на ранней стадии проектирования. Как объяснялось в главе 3, ассоциации семантически слабы: они обозначают только некоторую семантическую зависимость, роль каждого участника связи и кардинальность связи и, возможно, направление допустимого перехода. Однако для анализа и ранней стадии проектирования этого часто достаточно, ибо передаются все важные детали связей между двумя абстракциями, при этом предохраняя нас от поспешных решений о реализации.
Типичный порядок выполнения данного этапа таков:
Выбрать множество классов данного уровня абстракции или ассоциированных с некоторым набором сценариев; нанести на диаграммы все важнейшие операции и атрибуты, необходимые для иллюстрации существенных свойств моделируемой задачи.
Выяснить наличие зависимости между каждыми двумя классами и установить ассоциацию, если она присутствует. Необходимость перехода от одного объекта к другому и неизбежность использования некоторого поведения другого объекта являются причиной введения ассоциации. Чтобы устранить косвенные зависимости, следует ввести новые абстракции, которые служили бы агентами или посредниками. Некоторые ассоциации могут быть сразу идентифицированы как отношение "частное/общее" или агрегации.
Для каждой ассоциации определить роль каждого участника, если необходимо уточнить кардинальность и выявить другие ограничения.
Проверить годность этих решений, для чего просмотреть сценарий и убедиться, что имеющиеся ассоциации необходимы и достаточны для получения требуемых переходов и поведения абстракций этого сценария.
Диаграммы классов - основные модели, получаемые на данном этапе. Идентификация взаимодействий происходит главным образом при проектировании и, как описано в главе 4, является задачей классификации. А, значит, она также требует творчества и интуиции. В зависимости от текущего состояния макропроцесса, мы должны рассмотреть несколько различных типов взаимодействия:
Как часть формулировки наших стратегических решений, мы должны составить для каждого определенного на предыдущем шаге механизма диаграмму объектов, иллюстрирующую его динамическую семантику. Проверить каждый механизм в центральных и периферийных сценариях. Где возможен параллелизм, назначить объекты - актеры, агенты и серверы и способы синхронизации между ними. При этом может понадобиться ввести новые связи между объектами и устранить неиспользованные или избыточные.
Если между классами наблюдается общность, необходимо поместить эти классы в иерархию "общее/частное".
Как говорилось в главе 3, обычно лучше создать "лес" классов, чем единое дерево. На предыдущем шаге мы уже определили кандидатов на базовые, абстрактные классы и классы-примеси; теперь нужно разместить их в структуре наследования. Для существенных классов следует рассмотреть диаграммы классов и оценить их качество, согласно эвристикам главы 3. В частности, требует особого внимания иерархическая структура: она не должна быть слишком высокой или слишком короткой, чересчур широкой или узкой. Там, где встречаются шаблоны в структуре или поведении, нужно реорганизовать иерархию так, чтобы максимизировать общность (но не в ущерб простоте).
Как часть архитектурного проектирования, мы должны рассмотреть группирование классов в категории и организацию модулей в подсистемы. Это - стратегические решения. Архитекторы могут использовать диаграммы классов, чтобы определить иерархию категорий классов, которая формирует слои и разделы разрабатываемой системы. Обычно это делается сверху вниз. Имея глобальное представление о системе, выделяют основные абстракции, выполняющие главные обязанности системы, которые являются логически связными и могут изменяться независимо. Архитектуру также можно модернизировать снизу вверх, когда при каждом прохождении через микропроцесс идентифицируются семантически замкнутые группы классов. Нужно также принять решения о распределении классов по категориям. Если существующие категории слишком раздуваются или обнаруживаются новые группы классов, можно ввести новые категории или реорганизовать старые. Выявление модулей (для физической модели системы) выполняется аналогично и принятые решения отражаются на диаграммах модулей.
Распределение классов и объектов по модулям является до некоторой степени локальным решением и чаще всего отражает отношения видимости абстракций. Как мы указывали в главе 5, отображение логической модели в физическую дает возможность разработчику открыть или ограничить доступ к каждой абстракции или упаковать вместе логически связанные абстракции, которые предполагается изменять по отдельности.
Как мы обсудим в следующей главе, на отображение логической модели в физическую влияет также распределение обязанностей в команде проектировщиков. В любом случае все принятые решения можно выразить в виде диаграммы модулей.
Третий вид деятельности в этой фазе микропроцесса - уточнение ассоциаций - относится и к анализу, и к проектированию. При анализе мы можем провести вместо некоторых ассоциаций другие, семантически более точные связи, чтобы отразить наши достижения в понимании прикладной области. Таким образом, преобразовывая ассоциации и добавляя новые конкретные связи, мы готовим набросок реализации.
Отношения наследования, агрегации, инстанцирования и использования - важнейшие типы ассоциаций, представляющие для нас интерес вместе с такими свойствами, как метки, роли, кардинальность и т.д. Типичный порядок уточнения ассоциаций таков:
Имея набор классов, уже разбитый на группы, следует найти шаблоны поведения, указывающие на возможную связь "общее/частное". Далее необходимо разместить эти классы в существующей структуре наследования или построить новую подходящую структуру.
Если имеются шаблоны структуры, то, используя наследование с классами-примесями или агрегацию, попробовать ввести новые классы, отражающие общность структуры.
Найти классы с похожим поведением, которые либо находятся на одном уровне, либо еще не входят в структуру наследования и рассмотреть возможность введения общих параметризованных классов.
Рассмотреть существующие ассоциации с точки зрения переходов между ними и ограничить их насколько возможно. Если не требуется двустороннего перехода, считать связь простым отношением использования.
Определить тактические детали: указать роли, ключи, кардинальность, дружественность и т.д. Не требуется излишне детализировать: достаточно включить лишь важные результаты анализа и проектирования или то, что необходимо для реализации.
Путевые вехи и характеристики. Мы благополучно завершим эту фазу, когда достаточно полно определим семантику и связи интересующих абстракций, чтобы приступить к началу реализации.
Меры качества - связность, зацепление и полнота. Пересматривая связи, которые мы обнаружили или изобрели в течение этой фазы, мы хотим получить связные и слабо зацепленные между собой абстракции. При этом мы должны идентифицировать все важные связи на данном уровне абстракции, чтобы реализация не требовала введения новых существенных связей или неестественного использования тех, которые мы уже определили. Если на следующем шаге обнаружится, что наши абстракции неудобны для реализации, то это будет признаком того, что мы еще не определили подходящего набора связей между ними.
Жизненный цикл класса
В поведении простых классов можно разобраться, изучая операции их открытой части. Однако поведение более интересных классов (такое как перемещение объекта класса DisplayItem или составление расписания для экземпляра класса TemperatureController) включает взаимодействие разных операций, входящих в класс. Как уже говорилось выше, объекты таких классов действуют как маленькие машины, части которых взаимодействуют друг с другом, и так как все такие объекты имеют одно и то же поведение, можно использовать класс для описания их общей семантики, упорядоченной по времени и событиям. Как будет показано в главе 5, мы можем описывать динамику поведения объектов, используя модель конечного автомата.
3.4. Отношения между классами