Э. Гамма, Р. Хелм
Скачать 6.37 Mb.
|
34 Глава 1. Введение в паттерны проектирования ОПРЕДЕЛЕНИЕ РЕАЛИЗАЦИИ ОБЪЕКТОВ До сих пор мы почти ничего не сказали о том, как же в действительности определяется объект. Реализация объекта определяется его классом. Класс определяет внутренние данные объекта и его представление, а также опера- ции, которые объект может выполнять. В нашей нотации, основанной на OMT (см. приложение Б), класс изобра- жается прямоугольником, внутри которого жирным шрифтом написано имя класса. Ниже обычным шрифтом перечислены операции. Все данные, определенные для класса, следуют после операций. Имя класса, операции и данные разделяются горизонтальными линиями: Operation1() Type Operation2() instanceVariable1 Type instanceVariable2 Имя класса Типы возвращаемого значения и переменных экземпляра необязательны, поскольку мы не ограничиваем себя языками программирования с сильной типизацией. Объекты создаются посредством создания экземпляров класса. Говорят, что объект является экземпляром класса. В процессе создания экземпляров вы- деляется память для внутренних данных объекта (переменных экземпляра), а с этими данными связываются операции. Создавая экземпляры одного класса, можно создать много сходных объектов. Пунктирная линия со стрелкой обозначает класс, который создает объекты другого класса. Стрелка направлена в сторону класса создаваемых объектов. Создающий объект Создаваемый объект Новые классы могут определяться в контексте существующих с использова- нием наследования классов. Если подкласс наследует родительскому классу, то он включает определения всех данных и операций, определенных в ро- дительском классе. Объекты, являющиеся экземплярами подкласса, будут содержать все данные, определенные как в самом подклассе, так и во всех 1.6. Как решать задачи проектирования с помощью паттернов 35 его родительских классах. Такой объект сможет выполнять все операции, определенные в этом подклассе и его предках. Отношение «является под- классом» обозначается вертикальной линией с треугольником. Родительский класс Operation() Подкласс Класс называется абстрактным, если его единственное назначение — опре- делить общий интерфейс для всех своих подклассов. Абстрактный класс делегирует реализацию всех или части своих операций подклассам, по- этому у него не может быть экземпляров. Операции, объявленные, но не реализованные в абстрактном классе, называются абстрактными. Класс, не являющийся абстрактным, называется конкретным. Подклассы могут уточнять или переопределять поведение своих предков. Точнее, класс может заместить операцию, определенную в родительском классе. Замещение дает подклассам возможность обрабатывать запросы, адре- сованные родительским классам. Наследование позволяет определять новые классы, просто расширяя возможности старых. Таким образом можно без тру- да определять семейства объектов, обладающих сходной функциональностью. Имена абстрактных классов записываются курсивом, чтобы отличать их от конкретных. Курсив используется также для обозначения абстрактных опе- раций. На диаграмме может изображаться псевдокод, описывающий реализа- цию операции; в таком случае код представлен в прямоугольнике с загнутым уголком, соединенном пунктирной линией с операцией, которую он реализует. Абстрактный класс Operation() Конкретный подкласс Operation() Псевдокод реализации 36 Глава 1. Введение в паттерны проектирования Примесью (mixin class) называется класс, назначение которого — предоста- вить дополнительный интерфейс или функциональность другим классам. Он отчасти похож на абстрактные классы в том смысле, что не предполагает непосредственного создания экземпляров. Для работы с примесями необ- ходимо множественное наследование: Расширенный класс ExistingOperation() MixinOperation() Существующий класс ExistingOperation() Примесь MixinOperation() НАСЛЕДОВАНИЕ КЛАССА И НАСЛЕДОВАНИЕ ИНТЕРФЕЙСА Важно понимать различие между классом объекта и его типом. Класс объекта определяет реализацию объекта, то есть внутреннее состояние и реализацию операций объекта. Напротив, тип относится только к интер- фейсу объекта — множеству запросов, на которые объект способен ответить. У объекта может быть много типов, и объекты разных классов могут иметь один и тот же тип. Разумеется, между классом и типом существует тесная связь. Поскольку класс определяет операции, которые может выполнять объект, то он также определяет и его тип. Когда мы говорим «объект является экземпляром класса», то подразумеваем, что он поддерживает интерфейс, определяемый этим классом. В языках вроде C++ и Eiffel классы определяют как тип объекта, так и его реализацию. В программах на языке Smalltalk типы переменных не объяв- ляются, поэтому компилятор не проверяет, что тип объекта, присваиваемого переменной, является подтипом типа переменной. При отправке сообщения необходимо проверять, что класс получателя реализует реакцию на сообще- ние, но проверка того, что получатель является экземпляром определенного класса, не нужна. Важно также понимать различие между наследованием класса и наследо- ванием интерфейса (или порождением подтипов). В случае наследования 1.6. Как решать задачи проектирования с помощью паттернов 37 класса реализация объекта определяется в терминах реализации другого объекта. Проще говоря, это механизм разделения кода и представления. Напротив, наследование интерфейса (порождение подтипов) описывает, когда один объект можно использовать вместо другого. Эти две концепции легко спутать, поскольку во многих языках явное раз- личие отсутствует. В таких языках, как C++ и Eiffel, под наследованием понимается одновременно наследование интерфейса и реализации. Стан- дартный способ реализации наследования интерфейса в C++ — это открытое наследование классу, в котором есть (чисто) виртуальные функции. Чистое наследование интерфейса можно смоделировать в C++ посредством от- крытого наследования чисто абстрактному классу. Чистая реализация или наследование классов может моделироваться посредством закрытого насле- дования. В Smalltalk под наследованием понимается только наследование реализации. Переменной можно присвоить экземпляры любого класса при условии, что они поддерживают операции, выполняемые над значением этой переменной. Хотя в большинстве языков программирования различие между насле- дованием интерфейса и реализации не поддерживается, на практике оно существует. Программисты на Smalltalk обычно считают, что подклассы — это подтипы (хотя имеются и хорошо известные исключения [Coo92]). Программисты на C++ манипулируют объектами через типы, определяемые абстрактными классами. Многие паттерны проектирования зависят от этого различия. Например, объекты, построенные в соответствии с паттерном цепочка обязанностей (263), должны иметь общий тип, но обычно они не используют общую реализацию. В паттерне компоновщик (196) отдельный объект (компонент) определяет общий интерфейс, но реализацию часто определяет составной объект (композиция). Паттерны команда (275), наблюдатель (339), состо- яние (352) и стратегия (362) часто реализуются абстрактными классами, которые представляют чистые интерфейсы. Программирование в соответствии с интерфейсом, а не с реализацией Наследование классов — это не что иное, как механизм расширения функ- циональности приложения путем повторного использования функциональ- ности родительских классов. Оно позволяет быстро определить новый вид объектов через уже имеющийся. Новую реализацию можно получить почти без всякого труда посредством наследования большей части необходимого кода из существующих классов. 38 Глава 1. Введение в паттерны проектирования Впрочем, повторное использование реализации — лишь полдела. Не менее важно, что наследование позволяет определять семейства объектов с иден- тичными интерфейсами (обычно за счет наследования от абстрактных классов). Почему? Потому что от этого зависит полиморфизм. Если пользоваться наследованием осторожно (некоторые сказали бы пра- вильно), то все классы, производные от некоторого абстрактного класса, будут обладать его интерфейсом. Отсюда следует, что подкласс добавляет новые или замещает старые операции и не скрывает операции, опреде- ленные в родительском классе. Все подклассы могут отвечать на запросы, соответствующие интерфейсу абстрактного класса, поэтому они являются подтипами этого абстрактного класса. У манипулирования объектами строго через интерфейс абстрактного класса есть два преимущества: клиенту не нужно располагать информацией о конкретных типах объ- ектов, которыми он пользуется, при условии что все они имеют ожидае- мый клиентом интерфейс; клиенту необязательно «знать» о классах, с помощью которых реали- зованы объекты. Клиенту известно только об абстрактном классе (или классах), определяющих интерфейс. Данные преимущества настолько существенно уменьшают число зависи- мостей между подсистемами, что можно даже сформулировать принцип объектно-ориентированного проектирования для повторного использо- вания: программируйте в соответствии с интерфейсом, а не с реализацией. Не объявляйте переменные как экземпляры конкретных классов. Вместо этого придерживайтесь интерфейса, определенного абстрактным классом. Этот принцип проходит через все паттерны, описанные в книге. Конечно, где-то в системе вам придется создавать экземпляры конкретных классов, то есть определить конкретную реализацию. Как раз это и позволя- ют сделать порождающие паттерны: абстрактная фабрика (113), строитель (124), фабричный метод (135), прототип (146) и одиночка (157). Абстра- гируя процесс создания объекта, эти паттерны предоставляют вам разные способы прозрачного связывания интерфейса с его реализацией в момент создания экземпляра. Использование порождающих паттернов гарантирует, что система написана в категориях интерфейсов, а не реализации. 1.6. Как решать задачи проектирования с помощью паттернов 39 МЕХАНИЗМЫ ПОВТОРНОГО ИСПОЛЬЗОВАНИЯ Большинству проектировщиков известны концепции объектов, интерфей- сов, классов и наследования. Трудность в том, чтобы применить эти знания для построения гибких, повторно используемых программ. С помощью паттернов проектирования вам будет проще это сделать. Наследование и композиция Два наиболее распространенных приема повторного использования функ- циональности в объектно-ориентированных системах — это наследование класса и композиция объектов. Как мы уже объясняли, наследование класса позволяет определить реализацию одного класса через другой. Повторное использование за счет порождения подкласса называют еще повторным использованием по принципу прозрачного ящика (white box reuse). Такой термин подчеркивает, что внутреннее устройство родительских классов часто видимо подклассам. Композиция объектов — альтернатива наследованию класса. В этом случае новая, более сложная функциональность получается путем объединения или композиции объектов. Для композиции требуется, чтобы объединяемые объекты имели четко определенные интерфейсы. Такой способ называют повторным использованием по принципу черного ящика (blackbox reuse), поскольку детали внутреннего устройства объектов остаются скрытыми. И у наследования, и у композиции есть достоинства и недостатки. Насле- дование класса определяется статически на этапе компиляции, его проще использовать, поскольку оно напрямую поддержано языком программиро- вания. В случае наследования классов упрощается также задача модифика- ции существующей реализации. Если подкласс замещает лишь некоторые операции, то могут оказаться затронутыми и остальные унаследованные операции (при условии что они вызывают замещенные). Впрочем, у наследования класса есть и минусы. Во-первых, унаследованную от родителя реализацию не удастся изменить во время выполнения програм- мы, поскольку наследование определяется на этапе компиляции. Во-вторых (и это более серьезно), родительский класс нередко хотя бы частично опре- деляет физическое представление своих подклассов. Поскольку подклассу доступны детали реализации родительского класса, то часто говорят, что наследование нарушает инкапсуляцию [Sny86]. Реализации подкласса и родительского класса связываются настолько тесно, что любые изменения последней требуют изменять и реализацию подкласса. 40 Глава 1. Введение в паттерны проектирования Зависимость от реализации может повлечь за собой проблемы при по- пытке повторного использования подкласса. Если хотя бы один аспект унаследованной реализации непригоден для новой предметной области, то приходится переписывать родительский класс или заменять его чем-то более подходящим. Такая зависимость ограничивает гибкость и возмож- ности повторного использования. С проблемой можно справиться, если наследовать только абстрактным классам, поскольку в них обычно совсем нет реализации или она минимальна. Композиция объектов определяется динамически во время выполнения за счет того, что объекты получают ссылки на другие объекты. Композиция требует, чтобы объекты соблюдали интерфейсы друг друга. Для этого, в свою очередь, требуется тщательно проектировать интерфейсы, чтобы один объект можно было использовать вместе с широким спектром дру- гих. Но и выигрыш велик: поскольку доступ к объектам осуществляется только через их интерфейсы, инкапсуляция не нарушается. Во время выполнения программы любой объект можно заменить другим, лишь бы он имел тот же тип. Более того, поскольку реализация объекта пишется прежде всего в категориях его интерфейсов, то зависимость от реализации резко снижается. Композиция объектов влияет на дизайн системы и еще в одном аспекте. От- давая предпочтение композиции объектов перед наследованием классов, вы инкапсулируете каждый класс и даете ему возможность выполнять только свою задачу. Классы и их иерархии остаются небольшими, и вероятность их разрастания до неуправляемых размеров невелика. С другой стороны, дизайн, основанный на композиции, будет содержать больше объектов (хотя число классов, возможно, уменьшится), а поведение системы начнет зависеть от их взаимодействия, тогда как при другом подходе оно было бы определено в одном классе. Это подводит нас ко второму правилу объектно-ориентированного про- ектирования: предпочитайте композицию наследованию класса. В идеале для достижения повторного использования вообще не следовало бы создавать новые компоненты. Было бы лучше, если бы всю необходи- мую функциональность можно было получить простым объединением уже существующих компонентов. На практике, однако, так получается редко, поскольку набор имеющихся компонентов все же недостаточно широк. Повторное использование за счет наследования упрощает создание новых 1.6. Как решать задачи проектирования с помощью паттернов 41 компонентов, которые можно было бы применять со старыми. Поэтому на- следование и композиция часто используются вместе. Тем не менее наш опыт показывает, что проектировщики злоупотребляют наследованием. Нередко дизайн мог бы стать лучше (и проще), если бы автор больше полагался на композицию объектов. Композиция будет снова и снова встречаться вам в паттернах этой книги. Делегирование С помощью делегирования композицию можно сделать столь же мощным инструментом повторного использования, сколь и наследование [Lie86, JZ91]. При делегировании в процессе обработки запроса задействованы два объекта: получатель поручает выполнение операций другому объек- ту — уполномоченному (делегату). Примерно так же подкласс делегирует ответственность своему родительскому классу. Но унаследованная опе- рация всегда может обратиться к объекту-получателю через переменную класса (в C++) или переменную self (в Smalltalk). Чтобы достичь того же эффекта для делегирования, получатель передает указатель на самого себя соответствующему объекту, дабы при выполнении делегированной операции последний мог обратиться к непосредственному адресату запроса. Например, вместо того чтобы делать класс Window (окно) подклассом класса Rectangle (прямоугольник) — ведь окно является прямоугольником, — мы можем воспользоваться внутри Window поведением класса Rectangle , поместив в класс Window переменную экземпляра типа Rectangle и делегируя ей операции, специфические для прямоугольников. Другими словами, окно не является прямоугольником, а содержит его. Теперь класс Window может явно перена- правлять запросы своей переменной Rectangle , а не наследовать ее операции. На схеме ниже изображен класс Window , который делегирует операцию Area() переменной экземпляра Rectangle Window Area() Rectangle Area() width height rectangle return rectangle>Area() return width * height 42 Глава 1. Введение в паттерны проектирования Сплошная линия со стрелкой обозначает, что класс содержит ссылку на экземпляр другого класса. Эта ссылка может иметь необязательное имя, в данном случае rectangle Главное достоинство делегирования в том, что оно упрощает композицию по- ведений во время выполнения. При этом способ комбинирования поведений можно изменять. Например, внутреннюю область окна можно преобразовать в круг во время выполнения, просто подставив вместо экземпляра класса Rectangle экземпляр класса Circle (предполагается, конечно, что оба эти класса имеют одинаковый тип). У делегирования есть и недостаток, свойственный и другим подходам, при- меняемым для повышения гибкости за счет композиции объектов. Дина- мическую программу с высокой степенью параметризации труднее понять, нежели статическую. Также присутствует и некоторая потеря машинной эффективности, но в долгосрочной перспективе неэффективность работы проектировщика гораздо более существенна. Делегирование можно считать хорошим вариантом только тогда, когда оно позволяет достичь упрощения, а не усложнения дизайна. Нелегко сформулировать правила, которые бы однозначно определяли, когда следует пользоваться делегированием, по- скольку эффективность его зависит от контекста и вашего личного опыта. Лучше всего делегирование работает при использовании в составе привыч- ных идиом, то есть в стандартных паттернах. Делегирование используется в нескольких паттернах проектирования: со- стояние (352), стратегия (362), посетитель (379). В первом объект делеги- рует запрос объекту, представляющему его текущее состояние. В паттерне стратегия обработка запроса делегируется объекту, который представляет стратегию его исполнения. У объекта может быть только одно состояние, но много стратегий для исполнения различных запросов. Назначение обоих паттернов — изменить поведение объекта за счет замены объектов, которым делегируются запросы. В паттерне посетитель операция, которая должна быть выполнена над каждым элементом составного объекта, всегда делегируется посетителю. В других паттернах делегирование используется не так интенсивно. Паттерн посредник (319) вводит объект, осуществляющий посредничество при вза- имодействии других объектов. Иногда объект-посредник реализует опера- ции, переадресуя их другим объектам; в других случаях он передает ссылку на самого себя, используя тем самым делегирование как таковое. Паттерн цепочка обязанностей (263) обрабатывает запросы, перенаправляя их от одного объекта другому по цепочке. Иногда вместе с запросом передается 1.6. Как решать задачи проектирования с помощью паттернов 43 ссылка на исходный объект, получивший запрос, и в этом случае мы снова сталкиваемся с делегированием. Паттерн мост (184) отделяет абстракцию от ее реализации. Если между абстракцией и конкретной реализацией имеется существенное сходство, то абстракция может просто делегировать операции своей реализации. Делегирование — особый случай композиции. Оно показывает, что насле- дование как механизм повторного использования всегда можно заменить композицией. Наследование и параметризованные типы Еще один (хотя и не в точности объектно-ориентированный) метод по- вторного использования имеющейся функциональности — это применение параметризованных типов, известных также как обобщенные типы (Ada, Eiffel) или шаблоны (C++). Данная техника позволяет определить тип, не задавая типы, которые он использует. Отсутствующие типы передаются в параметрах в точке использования. Например, класс List (список) можно параметризовать типом помещаемых в список элементов. Чтобы объявить список целых чисел, вы передаете тип integer в качестве параметра параме- тризованному типу List . Если же надо объявить список строк, то в качестве параметра передается тип String . Для каждого типа элементов компилятор языка создаст отдельный вариант шаблона класса List Параметризованные типы предоставляют третий (после наследования класса и композиции объектов) способ комбинировать поведение в объектно-ори- ентированных системах. Многие задачи можно решить с помощью любого из этих трех методов. Чтобы параметризовать процедуру сортировки операцией сравнения элементов, сравнение можно было бы сделать: операцией, реализуемой подклассами (применение паттерна шаблонный метод (373)); функцией объекта, передаваемого процедуре сортировки (страте- гия (362)); аргументом шаблона в C++ или обобщенного типа в Ada, который за- дает имя функции, вызываемой для сравнения элементов. Но между этими тремя подходами есть важные различия. Композиция объ- ектов позволяет изменять поведение во время выполнения, но для этого требуются косвенные вызовы, что снижает эффективность. Наследование разрешает предоставить реализацию по умолчанию, которую можно заме- щать в подклассах. Параметризованные типы позволяют изменять типы, 44 Глава 1. Введение в паттерны проектирования используемые классом. Но ни наследование, ни параметризованные типы не могут изменяться во время выполнения. Выбор того или иного подхода зависит от проекта и ограничений реализации. Ни в одном из паттернов, описанных в этой книге, параметризованные типы не используются, хотя изредка мы прибегаем к ним для реализации паттернов в C++. В языке вроде Smalltalk, где нет проверки типов во время компиляции, параметризованные типы не нужны вовсе. СРАВНЕНИЕ СТРУКТУР ВРЕМЕНИ ВЫПОЛНЕНИЯ И ВРЕМЕНИ КОМПИЛЯЦИИ Структура объектно-ориентированной программы на этапе выполнения часто имеет мало общего со структурой ее исходного кода. Последняя фик- сируется на этапе компиляции; код состоит из классов, отношения насле- дования между которыми неизменны. На этапе же выполнения структура программы — быстро изменяющаяся сеть из взаимодействующих объектов. Две эти структуры почти независимы. Пытаться понять одну по другой — все равно что пытаться понять динамику живых экосистем по статической таксономии растений и животных, или наоборот. Рассмотрим различие между агрегированием и осведомленностью (acquaintance) объектов и его проявления на этапах компиляции и вы- полнения. Агрегирование подразумевает, что один объект владеет другим или несет за него ответственность. В общем случае мы говорим, что объект содержит другой объект или является его частью. Агрегирование означает, что агрегат и его составляющие имеют одинаковое время жизни. Говоря же об осведомленности, мы имеем в виду, что объекту известно о другом объекте. Иногда осведомленность называют ассоциацией или от- ношением «использует». Осведомленные объекты могут запрашивать друг у друга операции, но они не несут никакой ответственности друг за друга. Осведомленность — это более слабое отношение, чем агрегирование; оно предполагает гораздо менее тесную связь между объектами. На наших схемах осведомленность будет обозначаться сплошной линией со стрелкой. Линия со стрелкой и ромбиком в начале обозначает агреги- рование. Агрегирующий объект Агрегируемый объект Экземпляр агрегирования 1.6. Как решать задачи проектирования с помощью паттернов 45 Агрегирование и осведомленность легко спутать, поскольку они часто реа- лизуются одинаково. В языке Smalltalk все переменные являются ссылками на объекты, здесь нет различия между агрегированием и осведомленностью. В C++ агрегирование можно реализовать путем определения переменных класса, которые являются реальными экземплярами, но чаще они опреде- ляются в виде указателей или ссылок. Осведомленность также реализуется с помощью указателей и ссылок. Различие между осведомленностью и агрегированием в конечном итоге определяется, скорее, предполагаемым использованием, а не языковыми механизмами. В структуре, существующей на этапе компиляции, увидеть различие нелегко, но тем не менее оно существенно. Обычно отношений агрегирования меньше, чем отношений осведомленности, и они более посто- янны. Напротив, отношения осведомленности возникают и исчезают чаще и иногда длятся лишь во время исполнения некоторой операции. Кроме того, отношения осведомленности более динамичны, что затрудняет их выявление в исходном тексте программы. Коль скоро несоответствие между структурой программы на этапах ком- пиляции и выполнения столь велико, ясно, что изучение исходного кода может сказать о работе системы совсем немного. Структура системы на стадии выполнения должно определяться проектировщиком, а не языком. Соотношения между объектами и их типами нужно проектировать очень аккуратно, поскольку именно от них зависит, насколько удачной или не- удачной окажется структура во время выполнения. Многие паттерны проектирования (особенно уровня объектов) явно подчер- кивают различие между структурами на этапах компиляции и выполнения. Паттерны компоновщик (196) и декоратор (209) особенно полезны для построения сложных структур времени выполнения. В работе паттерна на- блюдатель (339) задействованы структуры времени выполнения, которые часто трудно понять, не зная паттерна. Паттерн цепочка обязанностей (263) также приводит к таким схемам взаимодействия, в которых наследование неочевидно. В общем можно утверждать, что без понимания специфики паттернов разобраться в структурах времени выполнения невозможно. ПРОЕКТИРОВАНИЕ С УЧЕТОМ БУДУЩИХ ИЗМЕНЕНИЙ Системы должны проектироваться с учетом их дальнейшего развития. Для проектирования системы, устойчивой к таким изменениям, следует предпо- ложить, как она будет изменяться на протяжении отведенного ей времени жизни. 46 Глава 1. Введение в паттерны проектирования Если при проектировании системы не принималась во внимание возмож- ность изменений, то есть вероятность, что в будущем ее придется полностью перепроектировать. Это может повлечь за собой переопределение и новую реализацию классов, модификацию клиентов и повторный цикл тестирова- ния. Перепроектирование отражается на многих частях системы, поэтому непредвиденные изменения всегда оказываются дорогостоящими. Благодаря паттернам систему всегда можно модифицировать определенным образом. Каждый паттерн позволяет изменять некоторый аспект системы независимо от всех прочих, таким образом, она менее подвержена влиянию изменений конкретного вида. Вот некоторые типичные причины перепроектирования, а также паттерны, которые позволяют этого избежать: при создании объекта явно указывается класс. Задание имени класса привязывает вас к конкретной реализации, а не к конкретному интер- фейсу. Это может осложнить изменение объекта в будущем. Чтобы уйти от такой проблемы, создавайте объекты косвенно. Паттерны проектирования: абстрактная фабрика (113), фабричный метод (135), прототип (146); зависимость от конкретных операций. Задавая конкретную операцию, вы ограничиваете себя единственным способом выполнения запроса. Если же не включать запросы в код, то будет проще изменить способ удовлетворения запроса как на этапе компиляции, так и на этапе вы- полнения. Паттерны проектирования: цепочка обязанностей (263), команда (275); зависимость от аппаратной и программной платформ. Внешние ин- терфейсы операционной системы и интерфейсы прикладных программ (API) различны на разных программных и аппаратных платформах. Если программа зависит от конкретной платформы, ее будет труднее перенести на другие. Возможно, даже на «родной» платформе такую программу трудно поддерживать. Поэтому при проектировании систем так важно ограничивать платформенные зависимости. Паттерны проектирования: абстрактная фабрика (113), мост (184); зависимость от представления или реализации объекта. Если клиент располагает информацией о том, как объект представлен, хранится или реализован, то, возможно, при изменении объекта придется изменять 1.6. Как решать задачи проектирования с помощью паттернов 47 и клиента. Сокрытие этой информации от клиентов поможет уберечься от каскадных изменений. Паттерны проектирования: абстрактная фабрика (113), мост (184), хра- нитель (330), заместитель (246); зависимость от алгоритмов. Во время разработки и последующего ис- пользования алгоритмы часто расширяются, оптимизируются и заме- няются. Зависящие от алгоритмов объекты придется переписывать при каждом изменении алгоритма. Поэтому алгоритмы, которые с большой вероятностью будут изменяться, следует изолировать. Паттерны проектирования: мост (184), итератор (302), стратегия (362), шаблонный метод (373), посетитель (379); сильная связанность. Сильно связанные между собой классы трудно ис- пользовать порознь, так как они зависят друг от друга. Сильная связан- ность приводит к появлению монолитных систем, в которых нельзя ни изменить, ни удалить класс без знания деталей и модификации других классов. Такую систему трудно изучать, переносить на другие платфор- мы и сопровождать. Слабая связанность повышает вероятность того, что класс можно будет повторно использовать сам по себе. При этом изучение, перенос, модифи- кация и сопровождение системы намного упрощаются. Для поддержки слабосвязанных систем в паттернах проектирования применяются такие методы, как абстрактные связи и разбиение на слои. Паттерны проектирования: абстрактная фабрика (113), мост (184), це- почка обязанностей (263), команда (275), фасад (221), посредник (319), наблюдатель (339); расширение функциональности за счет порождения подклассов. Специ- ализация объекта путем создания подкласса часто оказывается непро- стым делом. С каждым новым подклассом связаны фиксированные из- держки реализации (инициализация, очистка и т. д.). Для определения подкласса необходимо так же ясно представлять себе устройство ро- дительского класса. Например, замещение одной операции может по- требовать замещения и других. Замещение операции может оказаться необходимым для того, чтобы можно было вызвать унаследованную операцию. Кроме того, порождение подклассов ведет к разрастанию ко- личества классов, поскольку даже для реализации простого расширения приходится создавать новые подклассы. 48 Глава 1. Введение в паттерны проектирования Композиция объектов и делегирование — гибкие альтернативы наследо- ванию для комбинирования поведений. Приложению можно добавить новую функциональность, меняя способ композиции объектов, а не определяя новые подклассы уже имеющихся классов. С другой стороны, интенсивное использование композиции объектов может усложнить понимание кода. С помощью многих паттернов проектирования уда- ется построить такое решение, где специализация достигается за счет определения одного подкласса и комбинирования его экземпляров с уже существующими. Паттерны проектирования: мост (184), цепочка обязанностей (263), ком- поновщик (196), декоратор (209), наблюдатель (339), стратегия (362); неудобства при изменении классов. Иногда нужно модифицировать класс, но делать это неудобно. Допустим, вам нужен исходный код, а он недоступен (так обстоит дело с коммерческими библиотеками классов). Или любое изменение тянет за собой модификации множества суще- ствующих подклассов. Благодаря паттернам проектирования можно модифицировать классы и при таких условиях. Паттерны проектирования: адаптер (171), декоратор (209), посети- тель (379). Приведенные примеры демонстрируют ту гибкость, которой можно добить- ся, используя паттерны при проектировании приложений. Насколько эта гибкость необходима, зависит, конечно, от особенностей вашей программы. Давайте посмотрим, какую роль играют паттерны при разработке приложе- ний, инструментальных библиотек и каркасов приложений. Приложения Если вы проектируете приложения — например, редактор документов или электронную таблицу, — то наивысший приоритет имеют внутреннее по- вторное использование, удобство сопровождения и расширяемость. Первое подразумевает, что вы не проектируете и не реализуете больше, чем необ- ходимо. Повысить степень внутреннего повторного использования помогут паттерны, уменьшающие число зависимостей. Ослабление связанности увеличивает вероятность того, что некоторый класс объектов сможет взаи- модействовать с другими. Например, устраняя зависимости от конкретных операций путем изолирования и инкапсуляции каждой операции, вы упро- щаете задачу повторного использования любой операции в другом контек- сте. К тому же результату приводит устранение зависимостей от алгоритма и представления. 1.6. Как решать задачи проектирования с помощью паттернов 49 Паттерны проектирования также упрощают сопровождение приложения, если использовать их для ограничения платформенных зависимостей и раз- биения системы на уровни. Они способствуют и наращиванию функциональ- ности системы, показывая, как расширять иерархии классов и когда следует применять композицию объектов. Уменьшение степени связанности также увеличивает возможность развития системы. Расширение класса становится проще, если он не зависит от множества других. Инструментальные библиотеки Часто приложение включает классы из одной или нескольких библиотек заранее определенных классов. Такие библиотеки называются инструмен- тальными. Инструментальная библиотека — это набор взаимосвязанных, повторно используемых классов, спроектированный с целью предоставления полезной функциональности общего назначения. Примеры инструменталь- ной библиотеки — набор контейнерных классов для списков, ассоциативных массивов, стеков и т. д., библиотека потокового ввода/вывода в C++. Ин- струментальные библиотеки не определяют какой-то конкретный дизайн приложения, а просто предоставляют средства, упрощающие решение определенных задач в приложениях, позволяют разработчику не изобре- тать стандартную функциональность. Таким образом, в инструментальных библиотеках упор делается на повторном использовании кода. Это объектно- ориентированные эквиваленты библиотек подпрограмм. Существует мнение, что проектировать инструментальные библиотеки сложнее, чем приложения, поскольку библиотеки должны использоваться во многих приложениях (иначе они бесполезны.) К тому же автор библиотеки не знает заранее, какие специфические требования будут предъявляться конкретными приложениями. Поэтому ему необходимо избегать любых предположений и зависимостей, способных ограничить гибкость библио- теки — а следовательно, сферу ее применения и эффективность. Каркасы приложений Каркас — это набор взаимодействующих классов, составляющих повторно используемый дизайн для конкретного класса программ [Deu89, JF88]. Например, можно создать каркас для разработки графических редакто- ров в разных областях: рисовании, сочинении музыки или САПР [VL90, Joh92]. Другой каркас может специализироваться на создании компиля- торов для разных языков программирования и целевых машин [JML92]. Третий упростит построение приложений для финансового моделирова- ния [BE93]. Каркас можно адаптировать для конкретного приложения 50 Глава 1. Введение в паттерны проектирования путем порождения специализированных подклассов от входящих в него абстрактных классов. Каркас диктует определенную архитектуру приложения. Он определяет общую структуру, ее разделение на классы и объекты, ключевые обязан- ности тех и других, методы взаимодействия объектов и классов и потоки управления. Данные параметры проектирования задаются каркасом, а про- ектировщики или разработчики приложений могут сконцентрироваться на специфике приложения. В каркасе отражены проектные решения, общие для данной предметной области. Акцент в каркасе делается на повторном использовании дизайна, а не кода, хотя обычно он включает и конкретные подклассы, которые можно применять непосредственно. Повторное использование на данном уровне означает инверсию контроля между приложением и программным обеспечением, лежащим в его осно- ве. При использовании инструментальной библиотеки (или, если хотите, обычной библиотеки подпрограмм) вы пишете основной код приложения и вызываете из него код, который планируете использовать повторно. При работе с каркасом вы, наоборот, повторно используете основной код и пишете код, который он вызывает. Вам приходится кодировать операции с предопределенными именами и параметрами вызова, но зато число при- нимаемых вами проектных решений сокращается. В результате не только ускоряется построение приложений, но и все прило- жения имеют схожую структуру. Их проще сопровождать, и пользователям они представляются более знакомыми. С другой стороны, вы в какой-то мере жертвуете свободой творчества, поскольку многие проектные решения уже приняты за вас. Если проектировать приложения нелегко, инструментальные библиоте- ки — еще сложнее, то проектирование каркасов — задача самая трудная. Проектировщик каркаса рассчитывает, что единая архитектура будет пригодна для всех приложений в данной предметной области. Любое не- зависимое изменение дизайна каркаса приведет к утрате его преимуществ, поскольку основной «вклад» каркаса в приложение — это определяемая им архитектура. Поэтому каркас должен быть максимально гибким и рас- ширяемым. Поскольку приложения так сильно зависят от каркаса, они особенно чув- ствительны к изменениям его интерфейсов. По мере усложнения каркаса приложения должны эволюционировать вместе с ним. В результате су- щественно возрастает значение слабой связанности, в противном случае малейшее изменение каркаса приведет к целой волне модификаций. 1.6. Как решать задачи проектирования с помощью паттернов 51 Рассмотренные выше проблемы проектирования критичны именно для каркасов. Каркас, в котором они решены путем применения паттернов, может лучше обеспечить высокий уровень проектирования и повторного использования кода, чем тот, где паттерны не применялись. В каркасах, прошедших проверку временем, обычно задействовано несколько разных паттернов проектирования. Паттерны помогают адаптировать архитектуру каркаса ко многим приложениям без повторного проектирования. Дополнительное преимущество проявляется при документировании каркаса с указанием тех паттернов, которые в нем использованы [BJ94]. Тот, кто знает паттерны, способен быстрее разобраться в тонкостях каркаса. Но даже не работающие с паттернами увидят их преимущества, поскольку паттерны помогают удобно структурировать документацию по каркасу. Повышение качества документирования важно для любых программных продуктов, но для каркасов этот аспект важен вдвойне. Для освоения работы с каркасами надо потратить немало усилий, и только после этого они начнут приносить реальную пользу. Паттерны могут существенно упростить задачу, явно вы- деляя ключевые элементы дизайна каркаса. Поскольку между паттернами и каркасами много общего, часто возникает вопрос, в чем же различия между ними и есть ли они вообще. Так вот, су- ществуют три основных различия: паттерны проектирования более абстрактны, чем каркасы. В код мо- гут быть включены целые каркасы, но только отдельные воплощения паттернов. Каркасы можно писать на разных языках программирова- ния и не только изучать, но и непосредственно исполнять и повторно использовать. В противоположность этому паттерны проектирования, описанные в данной книге, необходимо реализовывать всякий раз, ког- да в них возникает необходимость. Паттерны объясняют намерения проектировщика, сильные и слабые стороны, а также последствия вы- бранного дизайна; как архитектурные элементы, паттерны проектирования мельче, чем каркасы. Типичный каркас содержит несколько паттернов. Обратное утверждение неверно; паттерны проектирования менее специализированы, чем каркасы. Кар- кас всегда создается для конкретной предметной области. В принципе каркас графического редактора можно использовать для моделирова- ния работы фабрики, но его никогда не спутаешь с каркасом, предназна- ченным специально для моделирования. Напротив, включенные в наш каталог паттерны могут использоваться в приложениях почти любого 52 Глава 1. Введение в паттерны проектирования вида. Хотя, безусловно, существуют и более специализированные пат- терны (скажем, паттерны для распределенных систем или параллель- ного программирования), но даже они не диктуют выбор архитектуры приложения в той же мере, что и каркасы. Каркасы встречаются все чаще, а их роль в разработке растет. Именно с их помощью объектно-ориентированные системы можно использовать повторно в максимальной степени. Крупные объектно-ориентированные приложения в конечном итоге строятся из каркасов, взаимодействующих друг с другом на разных уровнях. Дизайн и код приложения в значитель- ной мере определяются теми каркасами, которые применялись при его создании. 1.7. КАК ВЫБИРАТЬ ПАТТЕРН ПРОЕКТИРОВАНИЯ Если учесть, что каталог содержит более 20 паттернов, выбрать паттерн, лучше всего подходящий для решения конкретной задачи проектирования, будет непросто. Ниже представлены некоторые подходы к выбору подхо- дящего паттерна: подумайте, как паттерны решают проблемы проектирования. В разде- ле 1.6 обсуждается то, как с помощью паттернов найти подходящие объ- екты, определить нужную степень их детализации, специфицировать их интерфейсы. Здесь же говорится и о некоторых иных подходах к реше- нию задач с помощью паттернов; пролистайте разделы каталога, описывающие назначение паттернов. В разделе 1.4 (с. 24) перечислены назначения всех представленных паттернов. Ознакомьтесь с назначением каждого паттерна, когда будете искать тот, что в наибольшей степени относится к вашей проблеме. Что- бы сузить поиск, воспользуйтесь схемой в таблице 1.1 (с. 28); изучите взаимосвязи паттернов. На рис. 1.1 (с. 30) графически изо- бражены соотношения между различными паттернами проектирования. Данная информация поможет вам найти нужный паттерн или группу паттернов; проанализируйте паттерны со сходными целями. Каталог (с. 108) со- стоит из трех частей: порождающие паттерны, структурные паттерны и паттерны поведения. Каждая часть начинается со вступительных за- мечаний о паттернах соответствующего вида и заканчивается разделом, где они сравниваются друг с другом. Эти разделы позволяют лучше по- 1.7. Как выбирать паттерн проектирования 53 нять сходства и различия между паттернами, имеющими похожее на- значение; разберитесь в причинах, вызывающих перепроектирование. Взгляните на перечень причин, приведенный выше, и проверьте, нет ли в нем ва- шей проблемы. Затем обратитесь к описаниям паттернов, помогающим устранить эту причину; посмотрите, какие аспекты вашего дизайна могут измениться. Такой подход противоположен анализу причин, вызвавших необходимость перепроектирования. Вместо того чтобы думать, что могло бы заста- вить изменить дизайн, подумайте о том, что бы вам хотелось иметь воз- можность изменять без перепроектирования. Акцент здесь делается на инкапсуляции концепций, подверженных изменениям — основной теме многих паттернов. В табл. 1.2 перечислены те аспекты дизайна, которые разные паттерны позволяют модифицировать независимо, чтобы вы могли изменять их без перепроектирования. Таблица 1.2. Аспекты дизайна, которые могут изменяться при применении паттернов проектирования Назначение Паттерн проектирования Переменные аспекты Порождающие паттерны Абстрактная фабрика (113) Семейства порождаемых объектов Одиночка (157) Единственный экземпляр класса Прототип (146) Класс, на основе которого создается объект Строитель (124) Способ создания составного объекта Фабричный метод (135) Подкласс создаваемого объекта Структурные паттерны Адаптер (171) Интерфейс к объекту Декоратор (209) Обязанности объекта без порождения под- класса Заместитель (246) Способ доступа к объекту, его местоположение Компоновщик (196) Структура и состав объекта Мост (184) Реализация объекта Приспособленец (231) Затраты на хранение объектов Фасад (221) Интерфейс к подсистеме 54 Глава 1. Введение в паттерны проектирования Назначение Паттерн проектирования Переменные аспекты Паттерны поведения Интерпретатор (287) Грамматика и интерпретация языка Итератор (302) Способ перебора элементов агрегата Команда (275) Время и способ выполнения запроса Наблюдатель (339) Множество объектов, зависящих от другого объекта; способ, которым зависимые объекты поддерживают себя в актуальном состоянии Посетитель (379) Операции, которые могут применяться к объ- екту или объектам, не меняя класса Посредник (319) Взаимодействующие объекты и механизм их совместной работы Состояние (352) Состояние объекта Стратегия (362) Алгоритм Хранитель (330) Закрытая информация, хранящаяся вне объ- екта, и время ее сохранения Цепочка обязанностей (263) Объект, выполняющий запрос Шаблонный метод (373) Шаги алгоритма 1.8. КАК ПОЛЬЗОВАТЬСЯ ПАТТЕРНОМ ПРОЕКТИРОВАНИЯ Допустим, вы выбрали паттерн проектирования. Как теперь им пользо- ваться? Ниже описана последовательность действий, которая поможет вам эффективно применить паттерн: 1. Прочитайте описание паттерна, чтобы получить о нем общее представ- ление. Особое внимание обратите на разделы «Применимость» и «Резуль- таты» — убедитесь, что выбранный вами паттерн действительно подходит для решения ваших задач. 2. Вернитесь назад и изучите разделы «Структура», «Участники» и «От- ношения». Убедитесь, что вы понимаете упоминаемые в паттерне классы и объекты и то, как они взаимодействуют друг с другом. Таблица 1.2 (окончание) 1.8. Как пользоваться паттерном проектирования 55 3. Просмотрите раздел «Пример кода» с конкретным примером примене- ния паттерна в программе. Изучение кода поможет понять, как нужно реализовывать паттерн. 4. Выберите для участников паттерна подходящие имена. Имена участ- ников паттерна обычно слишком абстрактны, чтобы вставлять их непо- средственно в код. Тем не менее бывает полезно включить имя участника как составную часть имени, используемого в программе. Это сделает факт применения паттерна более очевидным в реализации. Например, если вы пользуетесь паттерном стратегия в алгоритме размещения текста, то клас- сы могли бы называться SimpleLayoutStrategy или TeXLayoutStrategy 5. Определите классы. Объявите их интерфейсы, установите отношения наследования и определите переменные экземпляра, представляющие данные объекта и ссылки на другие объекты. Выявите в своем приложе- нии классы, на которые паттерн оказывает влияние, и соответствующим образом модифицируйте их. 6. Определите имена операций, встречающихся в паттерне. Здесь, как и в предыдущем случае, имена обычно зависят от приложения. Руковод- ствуйтесь теми функциями и взаимодействиями, которые ассоциированы с каждой операцией. Кроме того, будьте последовательны при выборе имен. Например, для обозначения фабричного метода можно было бы всюду использовать префикс Create- 7. Реализуйте операции, которые выполняют обязанности и обеспечивают взаимодействия, определенные в паттерне. Советы о том, как это лучше сделать, вы найдете в разделе «Реализация». Пригодится и раздел «При- мер кода». Все вышесказанное — не более чем рекомендации. Со временем вы вырабо- таете собственный подход к работе с паттернами проектирования. Никакое обсуждение применения паттернов проектирования нельзя счи- тать полным, если не сказать о том, как не надо их применять. Паттерны не должны применяться без разбора. Нередко за гибкость и простоту измене- ния, которые дают паттерны, приходится платить усложнением дизайна и/ или ухудшением производительности. Паттерн проектирования стоит при- менять, только когда дополнительная гибкость действительно необходима. В оценке достоинств и недостатков паттерна большую помощь могут оказать разделы каталога «Результаты». |