Объектно-ориентированное программирование. Объектно-ориентированное программирование в действии. Объектноориентированное программирование
Скачать 5.29 Mb.
|
Глава 9: Повторное использование кода Объектно-ориентированное программирование было объявлено как технология, которая позволит наконец конструировать программы из многократно используемых компонент общего назначения. Такие авторы, как Брэд Кокс, зашли так далеко, что уже говорили об объектно-ориентированном подходе как о предвестнике «промышленной революции» в разработке программного обеспечения [Cox 1986]. Пока действительность не вполне соответствует ожиданиям пионеров ООП (тема, к которой мы еще обратимся в конце этой главы). Что действительно справедливо — так это то, что ООП позволяет встраивать многократно используемые программные компоненты гораздо интенсивнее, чем раньше. В этой главе мы рассмотрим два наиболее общих механизма многократного использования программного обеспечения, которые известны как наследование и композиция. Механизмы многократного использования — это только первый шаг. Наследование и композиция обеспечивают средства многократного использования, но чтобы быть эффективными, они должны, вообще говоря, применяться в единой среде разработки, которая располагает поддержкой многократного использования. Схемы и среды разработки, которые предоставляют такое окружение, будут рассмотрены в главе 18. 9.1. Наследование и принцип подстановки Наследованиеикомпозициювкачестветехникимногократного использования кода, возможно, легче понять в их связи с принципом подстановки. Вспомните главу 8, в которой мы ссылались на этот принцип в связи с переменной, объявленной с одним классом, которая получает значение из другого класса. Принцип подстановки утверждает, что допустимо присваивать значение переменной, если класс значения является классом переменной или его подклассом. PDF created with pdfFactory Pro trial version www.pdffactory.com Мы видели примеры переопределения при моделировании игры в бильярд в главе 6. В процедуре, которая рисует образ экрана, переменная была объявлена как принадлежащая классу GraphicalObject, но на самом деле она последовательно содержала в качестве значений различные объекты, каждый из которых являлся экземпляром подкласса класса GraphicalObject. В этом разделе мы обсудим не настоящие классы, а абстрактные концепции, программной реализацией которых выступают классы. При каких условиях одно абстрактное понятие можно подставить вместо другого? То есть при каких условиях экземпляр некоторого абстрактного понятия перестановочен с экземпляром другого абстрактного понятия? Одно из классических правил применяемых здесь, ставшее основным для объектно-ориентированного проектирования, известно как «быть экземпляром» (правило «is-a»). 9.1.1. «Быть экземпляром» и «включать как часть» Знание двух различных форм отношений — основа понимания того, как и когда применять приемы многократного использования кода. Имеются два типа отношений, известных как быть экземпляром и включать как часть (is-a и has-a). Отношение быть экземпляром имеет место между двумя понятиями, если первое является уточнением второго. То есть для всех практических целей поведение и данные, связанные с более конкретным понятием, составляют подмножество поведения и данных, связанных с более абстрактным понятием. Например, все примеры наследования, описанные нами в предыдущих главах, удовлетворяют отношению быть экземпляром (хозяйка цветочного магазина Florist является экземпляром класса владельцев магазина Shopkeeper, собака Dog является экземпляром класса млекопитающих Mammal, бильярдный шар Ball является экземпляром класса графических объектов GraphicalObject, и т. д.). Название этого отношения происходит из простого правила проверки. Чтобы определить, является ли понятие X уточненным вариантом Y, просто составьте предложение «X является экземпляром Y». Если утверждение звучит корректно, то есть оно соответствует вашему жизненному опыту, то вы можете заключить, что X и Y связаны отношением быть экземпляром. Напротив, отношение включать как часть имеет место, когда второе понятие является компонентой первого, но оба эти понятия не совпадают ни в каком смысле независимо от уровня общности абстракции. Например, автомобиль Car имеет двигатель Engine, хотя ясно, что это не тот случай, когда Car является экземпляром Engine или Engine является экземпляром Car. Car тем не менее является экземпляром класса автомобилей Vehicle, который в свою очередь является экземпляром класса средств передвижения MeansOtTransportation 1. 1. Условие «X является экземпляром Y» не должно трактоваться ни как утверждение «X является подмножеством Y», рассматриваемое в теоретико-множественном смысле, ни как утверждение «X является наращиванием Y» в смысле расширения структуры данных. Условие «X является экземпляром Y» справедливо, если сущность X — это уточнение, детализация, конкретизация, специализированная форма, и т. д. сущности Y, и ни в каком другом случае. В частности, приводимый далее пример с автомобилем и двигателем выглядит следующим образом: двигатель входит в автомобиль в смысле теории множеств (двигатель — более PDF created with pdfFactory Pro trial version www.pdffactory.com мелкая «подробность» и фрагмент устройства автомобиля), автомобиль усложняет двигатель (автомобиль — это двигатель целиком плюс еще много другого), но ни в том, ни в другом случае не справедливо условие «X является экземпляром Y». — Примеч. перев. Еще раз, чтобы проверить отношение включать как часть, просто составьте предложение «X включает Y как часть» и предоставьте решать здравому смыслу. В большинстве случаев различие ясно. Но иногда оно может быть сомнительно или зависеть от обстоятельств. В следующем разделе мы анализируем один такой случай, чтобы проиллюстрировать два метода разработки программного обеспечения, которые естественно основываются на этих двух отношениях. 9.2. Композиция и наследование: описание Чтобы проиллюстрировать композицию и наследование, мы построим тип данных set — абстракцию множества — на основе существующего класса List. Экземпляры класса List содержат списки целочисленных величин. Допустим, что мы уже создали класс List со следующим интерфейсом: class List { public: // конструктор List (); // методы void addToFront (int); int firstElement (); int length (); int includes (int); int remove (int); }; Наша абстракция списка позволяет нам добавлять новый элемент в начало списка, выдавать его первый элемент, находить количество элементов, проверять, содержится ли значение в списке, и удалять элемент из списка. Мы хотим создать абстракцию множества, чтобы выполнять такие операции, как добавление значения к множеству, определение количества элементов, выяснение принадлежности к множеству. 9.2.1. Использование композиции Сначала мы исследуем, может ли абстракция множества быть создана с помощью композиции. Напомним, что объект — это просто инкапсуляция данных и поведения. Когда для многократного использования существующей абстракции данных при создании нового типа используется композиция, то часть новой структуры данных является просто экземпляром существующей структуры. Это показано ниже, где тип данных Set содержит поле, названное theData, которое объявлено с типом List. Class Set { public: Set (); // конструктор PDF created with pdfFactory Pro trial version www.pdffactory.com // операции void add (int); int size (); int includes (int) private: // область данных для значений List theData; }; Поскольку абстракция List хранится как часть области данных нашего множества, она должна быть инициализирована в конструкторе. Будучи аналогичными командам инициализации полей данных для классов (глава 4), команды инициализатора в начале конструктора задают аргументы для инициализации полей данных. В данном случае конструктор, который мы вызываем для класса List, — безаргументный: // список инициализации Set::Set() : theData() { // никакой дальнейшей инициализации } Операции в новой структуре данных реализованы с использованием уже существующих действий, предоставляемых старым типом данных. Например, операция includes для множества просто вызывает функцию с аналогичным названием, уже определенную для списков: int Set::size () { return theData.length(); } int Set::includes (int newValue) { return theData.includes(newValue); } Только одна операция оказывается чуть более сложной. Это — добавление нового элемента, так как нужно сначала убедиться, что данная величина не содержится в множестве (величины не могут появляться в множестве более одного раза): void Set::add (int newValue) { // если не в множестве if (! Includes (newValue)) { // тогда добавить theData.addToFront(newValue); }; // иначе ничего не делать } Важным является тот факт, что такая композиция помогает повторному использования кода в новых приложениях. За счет существующего класса List большая часть трудной работы по управлению значениями данных для нашей новой компоненты была уже проделана. Однако композиция ничего не говорит о соблюдении принципа подстановки. При создании нового типа указанным способом абстракции List и Set будут абсолютно различны, и ни одна их них не может быть подставлена вместо другой. PDF created with pdfFactory Pro trial version www.pdffactory.com Композиция в других языках Композиция может быть применена в любом объектно-ориентированном языке программирования, рассматриваемом в этой книге. Но она встречается и в языках, не являющихся объектно-ориентированными. Единственная существенная разница — в способе инициализации инкапсулированных данных. В языке Smalltalk в общем случае это выполняется через класс-методы, в языке Objective-C — с помощью методов-фабрик, в языках Java и Object Pascal — с использованием конструкторов. 9.2.2. Применение наследования Абсолютно другим механизмом многократного использования кода в ООП является наследование. С его помощью новый класс может быть объявлен как подкласс, или дочерний класс, существующего класса. В этом случае все области данных и функции, связанные с исходным классом, автоматически переносятся на новую абстракцию данных. Новый класс может определять дополнительные значения или функции. Он переопределяет некоторые функции исходного класса, просто объявив новые с такими же именами, как и в исходном классе. Все это проиллюстрировано ниже в классе, который реализует другую версию абстракции Set. Упоминая класс List в заголовке класса, мы показываем, что наша абстракция Set является расширением или уточнением существующего класса List. Таким образом, операции, связанные со списками, применимы и к множествам: Class Set : public List { public: // конструктор Set(); // операции void add (int); int size (); }; Заметьте, что новый класс не определяет никаких новых полей данных. Вместо этого поля данных класса List будут использоваться для хранения элементов множества. Эти поля должны быть по-прежнему проинициализированы. Данная операция выполняется вызовом конструктора надкласса в конструкторе нового класса: Set::Set() : List() { // никакой дальнейшей инициализации } Аналогично функции, определенные в родительском классе, могут быть использованы без каких-либо дальнейших усилий, и, следовательно, нам не нужно беспокоиться по поводу метода includes, так как наследованный метод из List имеет такое же имя и служит тем же целям. Добавление в множество нового элемента требует немного больше работы, чем в классе List: void Set::add (int newValue) { // добавить, если нет в множестве if (! Includes(newValue)) addToFront (newValue); } PDF created with pdfFactory Pro trial version www.pdffactory.com Сравните эту функцию с предыдущей версией. Обе техники — мощные механизмы для многократного использования кода, но в отличие от композиции наследование поддерживает неявное предположение, что подклассы на самом деле являются подтипами. Это значит, что экземпляры новой абстракции должны вести себя так же, как и экземпляры родительского класса. Наследование в других языках В главе 7 мы вкратце описали синтаксис, используемый для наследования в каждом из рассматриваемых нами языков программирования. Как и в случае композиции, главное — гарантировать, что абстракция надкласса проинициализирована должным образом. 9.2.3. Закрытое наследование в языке C++ C++ предоставляет интересный компромисс между композицией и наследованием как механизмами многократного использования кода. Это происходит путем использования ключевого слова private вместо ключевого слова public в заголовке определения класса. В этом случае программист сигнализирует, что наследование следует использовать при конструировании новой абстракцииданных, но такая абстракция не должна рассматриваться как уточненная форма родительского класса: Class Set : private List { public: // конструктор Set () : List () { } // операторы void add (int); int includes (int x); { return List::includes(x); } int size () { return List::length(); } }; Применяя термины, которые будут определены более строго в главе 10, можно сказать, что закрытое наследование создает подкласс, который не является подтипом. Тем самым закрытое наследование использует механизм наследования, но в явном виде нарушает принцип подстановки. Операции и области данных, наследуемые из родительского класса, задействуются в методах новой абстракции, но они не «просматриваются насквозь» и недоступны ее пользователям. По этой причине любой метод, который программист хочет экспортировать (такой, как includes в абстракции множества), должен быть переопределен заново для нового класса, даже если все, что он делает, — это вызов метода класса-предка. (Как было уже проиллюстрировано, чтобы избежать накладных расходов при вызовах процедур в подобных простых случаях, часто используются встраиваемые методы.) Закрытое наследование является интересной идеей и наиболее полезно, когда (как в данном случае) объект в основном составляется из абстракции данных другого типа и работа при создании нового объекта выполняется в основном инкапсулированной абстракцией, однако новое понятие не удовлетворяет отношению быть экземпляром, необходимому для открытого наследования. PDF created with pdfFactory Pro trial version www.pdffactory.com 9.3. Противопоставление композиции и наследования Проиллюстрировав два механизма многократного использования программного обеспечения и увидев, что они оба применимы для реализации множеств, мы можем прокомментировать некоторые недостатки и преимущества двух подходов: • Композиция более проста. Ее преимущество заключается в том, что она ясно показывает, какие точно операции будут выполняться над конкретной структурой данных. При взгляде на описание абстракции данных Set становится очевидно, что для типа данных предусмотрены только операции добавления элемента, проверки на наличие элемента и определение числа элементов в наборе. Это справедливо независимо от того, какие операции определены для списков. • При наследовании операции новой абстракции данных являются надмножеством операций исходной структуры данных. Таким образом, чтобы точно знать, какие операции разрешены для новой структуры, программист должен рассмотреть объявление исходной структуры. Например, изучение описания класса Set не показывает сразу же, что проверка на наличие элемента (метод includes) разрешена для множеств. Только из рассмотрения описанной ранее абстракции данных List видно, что имеется еще целый набор допустимых операций. Трудность состоит в следующем: чтобы понять класс, сконструированный с помощью наследования, программист должен постоянно переключаться «взад-вперед» между двумя (или более) описаниями классов. Она известна как проблема «вверх-вниз» («йо-йо») [Taenzer 1989]. • С другой стороны, лаконичность абстракции данных, созданной с помощью наследования, является преимуществом. Используя наследование, не обязательно писать весь код для доступа к функциям базового класса. По этой причине реализации с использованием наследования (как это было в нашем случае) значительно меньше по объему, если сравнить их с композицией. Наследование также часто обеспечивает большую функциональность. Например, применение наследования в нашем случае делает доступным для множеств не только проверку include, но и функцию remove. • Наследование не запрещает пользователям манипулировать новыми структурами через вызовы методов родительского класса, даже если эти методы не вполне подходят под идеологию потомка. Например, когда мы использовали наследование для получения множеств Set из списков List, то ничто не мешало пользователям добавлять новые элементы к множеству, вызывая унаследованный от класса List метод addToFront. • При композиции тот факт, что класс List используется для хранения наших множеств, — просто деталь реализации. С этой техникой было бы легко заново реализовать класс, чтобы извлечь пользу из применения других методов (например, таких, как хэш-таблицы) с минимальным воздействием на пользователей абстракции данных Set. Если пользователь рассчитывает на тот факт, что абстракция Set — это просто уточненная форма абстракции List, то такие изменения было бы трудно реализовать. • Наследование позволяет нам использовать новую абстракцию как аргумент существующей полиморфной функции. Мы будем исследовать эту возможность более детально в главе 14. Так как композиция не подразумевает соблюдение принципа подстановки, она обычно устраняет полиморфизм. • О степени понятности кода судить трудно. Наследование имеет преимущество в краткости кода, но не протокола. При композиции код класса, хотя он и оказывается длиннее, — это все, что должен понять другой программист, чтобы использовать абстракцию. Человек, столкнувшийся с необходимостью разобраться PDF created with pdfFactory Pro trial version www.pdffactory.com с версией кода с наследованием, вынужден проверять, является ли поведение, наследуемое из родительского класса, необходимым для должного использования нового класса, и таким образом он анализирует оба класса. • Структуры данных, реализованные с помощью наследования, имеют (незначительное) превосходство в смысле скорости выполнения над структурами с композицией. Причина состоит в том, что в первом случае исключается один дополнительный вызов функции (хотя техника inline в языке С++ может ликвидировать практически все накладные расходы по вызову функций). Имея два различных механизма реализации, можем ли мы сказать, который из них лучше в нашем конкретном случае? Обратимся к принципу подстановки. Спросите себя, корректно ли в приложении, которое предполагает использование абстракции данных List, подставлять вместо нее множество Set? Хотя чисто техническим ответом может быть «да» (абстракция Set действительно реализует все операции List), здравый смысл говорит, скорее, «нет». Поэтому в данном случае композиция подходит лучше. Последний штрих: обе техники очень полезны, и объектно-ориентированный программист должен быть знаком с обеими. 9.4. Повторное использование кода: реальность? На заре объектно-ориентированного программирования утверждалось, что композиция и наследование обеспечили возможность создания программного обеспечения из взаимозаменяемых компонент общего назначения. Но вопреки очевидному прогрессу (сейчас работает огромное количество коммерческих поставщиков, предлагающих объектно-ориентированные библиотеки общего назначения для различных приложений — пользовательские интерфейсы, контейнеры данных и т. д.), общий процесс не оправдал всех ожиданий. На то имеется ряд причин: • Наследование и композиция предоставляют средства для создания многократно используемых компонент, но сами собой они не дают указаний, как такая цель может быть достигнута. Оказывается, что создание хороших и полезных программных компонент почти всегда труднее разработки конкретно требуемой программы. • Так как создание многократно используемых компонент затруднено, то окупаемость не достигается в пределах одного проекта. На самом деле стремление к модульности может даже снизить скорость реализации проекта. Немедленная выгода не достигается, так что приходится рассчитывать на погашение затрат за счет последующих программных проектов. Но так как последние зачастую имеют свой собственный бюджет и планирование, то административные механизмы подобной амортизации отсутствуют. • Так как применение многократно используемых компонент непосредственно не улучшает проект, программисты не имеют стимула бороться за многократное использование. • Так как каждая новая задача обычно требует специфической функциональности, часто трудно с самого начала спроектировать действительно полезные программные компоненты общего назначения. Скорее всего, подходящие компоненты будут постепенно эволюционировать во многих проектах, пока наконец не достигнут стабильного состояния. • Многие программисты и администраторы подозрительно относятся к программам, которые не были разработаны в родных стенах. Такая осторожность называется синдромом «сделано не здесь». Так как сами администраторы гордятся своими PDF created with pdfFactory Pro trial version www.pdffactory.com командами, они, естественно, верят, что их программисты могут все сделать лучше, чем кто-либо. • Так как многие программисты недостаточно формально обучены или не держат руку на пульсе последних программных нововведений (таких, как ООП), они могут не знать о механизмах для разработки многократно используемого кода. Короче говоря, развитие программных механизмов для многократного использования само по себе не гарантирует технологическую и управленческую культуру, которая бы поддерживала и поощряла повторное использование программных компонент. Человеческие организации прогрессируют медленнее, чем технологии, поэтому, возможно, пройдет еще много лет до того, как мы увидим действительные выгоды, обещанные объектно-ориентированным подходом. Тем не менее многократное использование объектов применяется, возможно, не везде и далеко не так часто, как было заявлено, но все же применяется, и при правильном обращении оно тысячу раз доказало свою полезность и способность сокращать затраты. По этой причине многократное использование неизбежно станет нормой разработки программного обеспечения. Вот список недавно изданных книг, посвященных разработке многократно используемых компонент [Carroll 1995, McGregor 1992, Meyer 1994, Goldberg 1995]. Упражнения • Есть различные способы реализации структуры данных стек — например, с помощью списков или в виде массива. Предположим, мы имеем и класс списков List, и класс массивов Array. Для каждого из них проиллюстрируйте, как можно построить стек, используя и наследование, и композицию. Вам разрешается ввести какие угодно методы для базовых классов. Какая из техник реализации кажется вам более подходящей в данном случае? • Снова предположим, что мы имеем готовую структуру данных List и хотим построить абстракцию, представляющую собой упорядоченный список, в котором элементы вставляются в нужной последовательности, а не просто в начало списка. Наследование или композицию вы будете использовать в данном случае? Обоснуйте ваш ответ. |