Основы объектно-ориентированного проектирования

         

Брак по расчету


При обсуждении множественного наследования мы видели пример брака по расчету, комбинирующего отложенный класс с механизмом его реализации. Примером был стек, основанный на массиве ARRAYED_STACK:

class ARRAYED_STACK [G] inherit STACK [G] redefine change_top end ARRAY [G] rename count as capacity, put as array_put export {NONE} all end feature ... Реализация отложенных программ STACK, таких как put, count, full... ... и переопределение change_top в терминах операций ARRAY ... end

Интересно сравнить представленную схему класса ARRAYED_STACK с классом STACK2 из предыдущих обсуждений (см. лекцию 11 курса "Основы объектно-ориентированного программирования") - реализацию стека массивом, но без использования наследования. Заметьте, устранение необходимости быть клиентом ARRAY упрощает нотацию (предыдущая версия должна была использовать вызов в форме implementation.put, теперь можно писать просто put).

При наследовании все компоненты ARRAY были сделаны закрытыми. Это типично при браках по расчету: все компоненты родителя, обеспечивающего спецификацию, здесь STACK, экспортируются; все компоненты родителя, обеспечивающего реализацию, здесь ARRAY, скрываются. Это вынуждает клиентов класса ARRAYED_STACK использовать соответствующие экземпляры только через компоненты стека.



Формы льготного наследования


Два примера, ASCII и LINEAR_ITERATOR, демонстрируют два главных варианта льготного наследования:

наследование констант, в котором принципиальным вкладом родителя являются константные атрибуты и разделяемые объекты;наследование операций, в котором вкладом являются подпрограммы.

Как отмечалось ранее, возможна комбинация этих вариантов в единой наследственной связи. Вот почему льготное наследование задается одной категорией, а не двумя.



Иметь и быть (To have and to be)


Причина в том, что иметь не всегда означает быть, но во многих случаях быть означает иметь.

Нет, это не дешевая попытка экзистенциалистской философии - это отражение трудностей системного моделирования. Иллюстрацией первой половины высказывания опять-таки может служить наш пример: владелец автомобиля имеет машину, но нет никаких причин утверждать, что он является машиной.

Что можно сказать об обратной ситуации? Рассмотрим простое предложение о двух объектах из обычной программистской жизни:

Каждый инженер-программист является инженером. [A]

Очевидно, это хороший пример отношения является. Кажется, трудно думать по-другому - здесь ясно видно, что мы имеем дело со случаем быть, а не иметь. Но перефразируем утверждение:

В каждом инженере-программисте заключена частица инженера. [B]

Представим его теперь так:

Каждый инженер-программист имеет инженерную составляющую. [C]

Трюкачество - да, но все же [C] в основе не отличается от исходного высказывания [A]! Что отсюда следует: слегка изменив точку зрения, можно представить свойство является как имеет.

Рассмотрим структуру нашего объекта, как это делалось в предыдущих лекциях:


Рис. 6.3.  Объект "инженер-программист" как агрегат

Экземпляр SOFTWARE_ENGINEER показывает различные аспекты деятельности инженера-программиста. Вместо представления типа этого объекта как развернутого, можно рассматривать представление в терминах ссылок:


Рис. 6.4.  Другое возможное представление

Рассматривайте оба представления как способы визуализации ситуации, ничего более. Оба они исходят, однако, из отношения клиента имеет, интерпретации, в которой каждый инженер-программист несет в себе инженера как одну из своих ипостасей, что полностью согласуется с названием профессии. Одновременно в нем может быть сидит частица поэта и (или) сантехника. Подобные наблюдения могут быть сделаны для любого похожего отношения "является".

Вот почему проблема выбора между клиентом и наследованием не тривиальна - когда отношение "является" законно, то справедлив переход к отношению "иметь".

Обратное неверно. Это наблюдение предохраняет от простых ошибок, очевидно для всякого, кто понимает базисные концепции и, вероятно, объяснимо даже для авторов учебника. Но когда применимо отношение "является", то у него сразу же появляется соперник. Так что два компетентных специалиста могут не придти к одному решению: один выберет наследование, другой предпочтет клиентское отношение.

К счастью, существуют два критерия, помогающих в таких спорах. Иногда они могут не приводить к единственному решению. Но в большинстве практических случаев они без всяких колебаний указывают, какое из отношений является правильным.

Один из этих критериев предпочитает наследование, другой - клиента.



Индукция и дедукция


При проектировании семейства классов программной системы подходящим процессом является комбинация дедукции и индукции, специализации и обобщения. Иногда вы начинаете с абстракции, а затем выводите частные случаи, иногда вы обнаруживает полезный класс, а затем осознаете существование более общей абстрактной концепции.

Если вы обнаруживаете абстракцию только после распознавания конкретного, возможно, с вами все в порядке. Вы просто используете нормальный подход к классификации. С приобретением опыта и появлением интуиции возрастет доля априорных решений. Но апостериорная составляющая всегда останется.



Использование кодов символов


Библиотека Base Libraries включает класс ASCII:

indexing description: "Множество символов ASCII. % %Этот класс - предок всех классов, нуждающихся в его свойствах." class ASCII feature -- Access Character_set_size: INTEGER is 128; Last_ascii: INTEGER is 127 First_printable: INTEGER is 32; Last_printable: INTEGER is 126 Letter_layout: INTEGER is 70 Case_diff: INTEGER is 32 -- Lower_a - Upper_a ... Ctrl_a: INTEGER is 1; Soh: INTEGER is 1 Ctrl_b: INTEGER is 2; Stx: INTEGER is 2 ... Blank: INTEGER is 32; Sp: INTEGER is 32 Exclamation: INTEGER is 33; Doublequote: INTEGER is 34 ... ... Upper_a: INTEGER is 65; Upper_b: INTEGER is 66 ... Lower_a: INTEGER is 97; Lower_b: INTEGER is 98 ... и т.д. ... end

Этот класс является хранилищем множества константных атрибутов (всего 142 компонента), описывающих свойства множества ASCII.

Рассмотрим, например, лексический анализатор, ответственный за идентификацию лексем входного текста. Лексемами текста, написанного на некотором языке программирования, являются целые, идентификаторы, символы и так далее. Одному из классов системы, скажем, TOKENIZER, необходим доступ к кодам символов для их классификации на цифры, буквы и т. д. Такой класс воспользуется льготами и наследует эти коды от ASCII:

class TOKENIZER inherit ASCII feature ... Программы класса могут использовать компоненты Blank, Case_diff и другие... end

К классам, подобным ASCII, относятся иногда неодобрительно, но прежде чем перейти к методологической дискуссии, взглянем на еще один пример льготного наследования.



Использование наследования с отложенными и эффективными классами


В следующей таблице обобщены правила, определяющие для каждой категории, должны ли родитель и его потомок быть эффективными или отложенными классами. "Вариация" покрывает случаи вариации типа и функциональной вариации. Элементы, помеченные символом *, появляются более чем в одном входе.

Таблица 6.1. Отложенные и эффективные наследники и их родителиПотомокРодительОтложенныйЭффективный
Отложенный

Константы*

Ограничение*

Структура*

Тип*

Потеря эффективизации*

Вариация*

Вид

Расширение*

Потеря эффективизации*

Эффективный

Константы*

Конкретизация

Структура*

Тип*

Константы*

Расширение*

Реализация

Ограничение*

Вариация*



Использование наследования: таксономия таксономии


Мощь наследования - это следствие его универсальности. Правда и то, что временами оно наносит вред, заставляя многих авторов вводить ограничения на механизм. Понимая эти опасения, а иногда и разделяя их, отбросим случайные сомнения и страхи и научимся радоваться наследованию во всех его законных вариантах, к исследованию которых мы теперь и переходим.

Дадим обзор правильного использования наследования:

наследование подтипов (subtype inheritance);наследование вида (view inheritance);наследование с ограничением (restriction inheritance);наследование с расширением (extension inheritance);наследование с функциональной вариацией (functional variation inheritance);наследование с вариацией типа (type variation inheritance);наследование с конкретизацией (reification inheritance);структурное наследование (structure inheritance);наследование реализации (implementation inheritance);льготное наследование (facility inheritance) с двумя специальными вариантами: наследование констант и абстрактной машины (черного ящика) (constant inheritance и machine inheritance).

Некоторые из этих категорий (подтипы, вид, реализация, конкретизация, льготы) приводят к специфическим проблемам, обсуждаемым в отдельных разделах.



Использование скрытия потомком


Все наши усилия (по классификации) кажутся беспомощными на фоне множественности отношений живых существ, окружающих нас. Эта битва Человека и Природы во всей ее бесконечности описана величайшим ботаником Гете. Одно можно сказать с уверенностью, Человек в ней всегда будет побежден. Анри Бэйлон

Практика разработки ПО и аналогии природного мира свидетельствуют, что даже при самом тщательном проектировании остаются исключения таксономии. Скрытие redeem класса NEW_MORTGAGE или fly из OSTRICH не является свидетельством небрежного проектирования или недостаточного предвидения, оно свидетельствует о реальной сложности иерархии наследования.

Такие исключения таксономии имеют прецеденты, насчитывающие столетние усилия интеллектуальных гигантов (включая Аристотеля, Линнея, Бюффона и Дарвина). Они сигнализируют о внутренних ограничениях человеческой способности познания мира. Связаны ли они с результатами, шокирующими научную мысль в двадцатом столетии - принципом неопределенности в физике, неразрешимыми проблемами в математике?

Все это предполагает, что скрытие потомком остается, хотя, как отмечалось, не должно часто использоваться. Для тех немногих случаев при разработке ПО, когда есть принципиальные препятствия в разработке совершенной иерархии типов, скрытие потомков является более чем удобным и спасительным средством.



Итераторы


Второй пример демонстрирует наследование программ общего вида, а не константных атрибутов.

Предположим, мы хотим обеспечить общий механизм, позволяющий просмотр всех элементов (итерирование) некоторой структуры данных, например линейных структур, таких как списки. "Итерирование" означает выполнение некоторой процедуры, скажем, action, на элементах этой структуры, просматриваемых в последовательном порядке. Нам хочется обеспечить несколько различных механизмов итерирования, включающих применение action ко всем элементам, удовлетворяющим условию, заданному булевой функцией test, ко всем элементам до появления первого, удовлетворяющего test, или до первого, не удовлетворяющего этой функции. Ну и так далее, вариантов можно придумать много. Система, использующая этот механизм, должна быть способна применять его к произвольным компонентам action и test.

С первого взгляда может показаться, что итерирующие компоненты должны принадлежать классам, описывающих соответствующие структуры данных, таким как LIST или SEQUENCE.

В упражнении У6.7 предлагается показать, что это неправильное решение.

Предпочтительнее ввести независимую иерархию итераторов, показанную на рис. 6.11.


Рис. 6.11.  Иерархия итераторов

Класс LINEAR_ITERATOR, один из наиболее интересных классов в этом обсуждении, выглядит так:

indexing description: "Объекты, допускающие итерирование на линейных структурах" names: iterators, iteration, linear_iterators, linear_iteration deferred class LINEAR_ITERATOR [G] inherit ITERATOR [G] redefine target end feature -- Access invariant_value: BOOLEAN is -- Свойство, сопровождающее итерацию (по умолчанию: true) do Result:= True end target: LINEAR [G] -- Структура, к которой будут применяться компоненты итерации test: BOOLEAN is -- Булево условие выбора применимых элементов deferred end feature - Basic operations action is -- Действие на выбранных элементах deferred end do_if is -- Применить action в последовательности к каждому элементу --target, удовлетворяющему test.
do from start invariant invariant_value until exhausted loop if test then action end forth end ensure then exhausted end ... И так далее: do_all, do_while, do_until и другие процедуры ... endРассмотрим теперь класс, нуждающийся в выполнении некоторой операции над выбранными элементами списка специального типа. Например, это может быть командный класс в системе обработки текстов, нуждающийся в проверке всех абзацев документа, за исключением специально отформатированных (подобных абзацам с текстом программ). Тогда:

class JUSTIFIER inherit LINEAR_ITERATOR [PARAGRAPH] rename action as justify, test as justifiable, do_all as justify_all end feature justify is do ... end justifiable is -- Подлежит ли абзац проверке? do Result := not preformated end ... endПереименование облегчает понимание. Заметьте, нет необходимости в объявлении или повторном объявлении процедуры justify_all (бывшей do_all): будучи наследуемой, ожидаемая работа будет проделана эффективными версиями action и test.

Процедура justify, вместо того чтобы быть описанной в классе, может наследоваться от другого родителя. В этом случае множественного наследования будет выполняться операция объединения ("join"), эффективизирующая отложенную action, наследуемую от одного родителя под именем justify (здесь переименование существенно), с эффективной justify, наследуемой от другого родителя. Реально, это и есть брак по расчету.

Класс LINEAR_ITERATOR является замечательным примером класса поведения (behavior class), рассматривая общее поведение и оставляя открытыми специфические компоненты, так чтобы его потомки могли подключить специальные варианты.


Итоговый обзор: используйте наследование правильно


Наследование никогда не перестанет удивлять нас своей мощью и универсальностью. В этой лекции мы пытались как можно лучше описать, что реально означает наследование и как можно его использовать для получения всех преимуществ. Перечислю несколько центральных выводов.

Прежде всего не следует бояться разнообразия способов использования наследования. Запрет множественного или льготного наследования не достигает никакой другой цели, кроме нанесения вреда самому себе. Механизмы должны помогать нам, используйте их правильно, но используйте их!

Наследование является, как правило, техникой поставщика. Это мощное оружие нашего арсенала в сражениях с противниками (в частности сложностью - безжалостным врагом разработчика).

Конечно, все ПО проектируется для клиентов, клиентские потребности управляют процессом. Множество классов хорошо тогда, когда оно предоставляет замечательные службы клиентам: интерфейсы и ассоциированные с ними реализации, являющиеся полными, свободными от неприятных сюрпризов (таких как неожиданные потери производительности), просты в использовании, легки в обучении, запоминании, допускают расширяемость. Для достижения этих целей проектировщик свободен в использовании наследования и других ОО-методов любым способом, который ему нравится. Победителей не судят.

Напомню также, при проектировании структуры наследования целью является конструирование ПО, а не философия. Редко существует единственное, или лучшее решение. Для нас "лучшее" означает лучшее для целей некоторого класса клиентского приложения. Это особенно верно, когда мы уходим от таких областей как математика или компьютерные науки, где существуют широко применимые теории, и попадаем в мир деловых приложений.

В известной степени в этом есть некоторый комфорт. Натуралист, классифицирующий растения и животных, должен мыслить в абсолютных категориях. В программном мире эквивалентом является создание библиотек общецелевого назначения (фундаментальные структуры данных, графика, базы данных). Большей же частью ваши цели скромны, вам необходимо спроектировать хорошую иерархию, ту, которая удовлетворяет потребностям клиентов.

Заключительный урок этой лекции обобщает комментарий, сделанный при обсуждении льготного наследования: принципиальная трудность построения структуры классов не в наследовании, она в поиске абстракций. Если правильные абстракции идентифицированы, то структура наследования следует из них. Для поиска абстракций следует руководствоваться тем, чем мы руководствуемся на протяжении всей книги, - абстрактными типами данных.



Это выглядит привлекательно, но правильно ли это?


Наследование реализации подвергается критике. Скрытие компонентов кажется нарушением отношения "is-a".

Это не так. У этого отношения есть разные формы. По своему поведению стек, основанный на массиве, ведет себя как стек. По внутреннему представлению он массив, и экземпляры ARRAYED_STACK отличаются от экземпляров ARRAY лишь обогащением за счет атрибута (count). Экземпляры, создаваемые по единому образцу, представляют достаточно строгую форму отношения "is-a". И дело не только в представлении: все компоненты ARRAY, такие как put (переименованный в array_put), infix "@" и count (переименованный capacity), доступны для ARRAYED_STACK, хотя и не экспортируются его клиентам. Классу они необходимы для реализации компонентов STACK.

Так что концептуально ничего ошибочного нет в наследовании в интересах реализации. Показательно сравнение с контрпримером, изучаемым в начале этой лекции, класс CAR_OWNER свидетельство непонимания концепции наследования, класс ARRAYED_STACK задает хорошо определенную форму отношения "is-a".

Один недостаток здесь есть: механизм наследования, позволяющий ограничить доступность экспорта наследуемых компонентов (другими словами, разрешение предложения export), делает более трудной статическую проверку типов. Но это трудности разработчиков компиляторов, а не разработчиков прикладного ПО.



Как избежать скрытия потомком


Прежде чем понять, когда и почему необходимо скрытие потомком, следует заметить, что чаще всего этого делать не стоит. Эта техника должна находиться в резерве главного командования. Когда есть полный контроль над структурой наследования на ранних этапах разработки системы, то предусловия дают лучший способ справиться с таксономией исключений.

Рассмотрим класс ELLIPSE. Эллипс имеет два фокуса, через которые можно провести прямую:


Рис. 6.9.  Эллипс и фокусная линия

Класс ELLIPSE может соответственно иметь компонент focus_line.

Естественно, определить класс CIRCLE как наследника ELLIPSE: каждая окружность является эллипсом. Но для окружности два фокуса сливаются в одну точку - центр окружности, так что фокусная линия исчезает. (Вероятно, более корректно говорить о бесконечном множестве фокусных линий, любая прямая, проходящая через центр, может рассматриваться как фокусная линия, но на практике эффект будет тот же.)


Рис. 6.10.  Круг и его центр

Хороший ли это пример для скрытия потомком? Должен ли класс CIRCLE сделать компонент focus_line закрытым, как здесь:

class CIRCLE inherit ELLIPSE export {NONE} focus_line end ...

Вероятно, нет. В данном случае у разработчика родительского класса была вся необходимая информация для понимания того, что не все эллипсы имеют фокусную линию. Для компонентов, представляющих подпрограмму, следует ввести предусловие:

focus_line is -- Линия, проходящая через два фокуса require not equal (focus_1, focus_2) do ... end

(Предусловие может быть абстрактным, использующим функцию distinct_focuses; с тем преимуществом, что класс CIRCLE может сам переопределить ее.)

Необходимость поддержки эллипсов без фокусной линии вытекает из анализа проблемы. Создать соответствующий класс с функцией focus_line, не имеющей предусловия, является ошибкой проекта. Переложить решение на скрытие потомком является попыткой скрытия ошибки. Как указывалось в конце обсуждения принципа Открыт-Закрыт, ошибочные решения должны фиксироваться, потомки не должны латать прорехи проекта.



Как это делается без наследования


Давайте проверим, как можно выполнить эту работу без использования наследования. Для нашего примера это было уже сделано в классе STACK2 из предыдущих лекций. Он имеет атрибут representation типа ARRAY [G] и процедуры стека, реализованные в следующем виде (утверждения опущены):

put (x: G) is -- Добавляет x на вершину require ... do count := count + 1 representation.put (count, x) ensure ... end

Каждая манипуляция с представлением требует вызова компонента ARRAY с representation как цели. Платой являются потери производительности: минимальные по памяти (атрибут representation), более серьезные по времени (связанные с representation, накладные расходы, добавляемые при вызове каждой операции).

Предположим, что проблемы эффективности можно игнорировать. Остается еще одна утомительная необходимость выписывать перед каждой операцией префикс "representation". Это придется делать для всех классов, реализующих различные структуры данных - стеки, списки, очереди, все, что реализуется массивами.

ОО-разработчики ненавидят утомительные, повторяющиеся операции. "Встроенное повторение" - вот наш девиз. Если некий образец повторно встречается в множестве классов, естественной и здоровой реакцией является попытка понять общую абстракцию и встроить ее в класс. Абстракция здесь нечто подобное "структуре данных, имеющей доступ к массиву и его операциям".

indexing description: "Объекты, имеющие доступ к массиву и его операциям" class ARRAYED [G] feature -- Access item (i: INTEGER): G is -- Элемент представления с индексом i require ... do Result := representation.item (i) ensure ... end feature -- Element change put (x: G; i: INTEGER) is -- Замена на x элемента с индексом i require ... do representation.put (x, i) ensure ... end feature {NONE} -- Implementation representation: ARRAY [G] end

Компоненты item и put экспортированы. Так как ARRAYED описывает только внутренние свойства структуры данных, нет реальной необходимости в экспортируемых компонентах. Так что тот, кто не согласен с самой идей разрешения потомкам скрывать некоторые из экспортируемых компонентов, может предпочесть сделать закрытыми все компоненты ARRAYED. По умолчанию они тогда будут скрытыми и у потомков.

При таком определении класса не вызывает споров, что классы, такие как ARRAYED_STACK или ARRAYED_LIST, становятся наследниками ARRAYED: они действительно описывают структуры на массивах. Эти классы могут теперь использовать item вместо representation.item и так далее; мы избавились от утомительного повторения.

Но минуточку! Если наследовать от ARRAYED представляется правильным, почему же нельзя непосредственно наследовать от ARRAY? Никакой выгоды от введения еще одного слоя, надстроенного над ARRAY. Введение ARRAYED позволило убедить себя, что наследование реализации не используется, но по соображениям практики мы пошли на это, сделав систему более сложной и менее эффективной.

На самом деле нет никаких причин для введения класса ARRAYED. Прямое наследование реализации от классов, подобных ARRAY, проще и легитимнее.



Как не следует использовать наследование


Для выработки методологического принципа часто полезно - как показано во многих обсуждениях этой книги - вначале понять, как не следует делать вещи. Понимание того, "что такое плохо", позволяет осознать, "что такое хорошо". Если постоянно тепло, то грушевое дерево не зацветет, ему необходима встряска зимним морозом - тогда оно расцветет весной.

Вот и встряска для нас, любезно предоставленная широко известным во всем мире вузовским учебником, выдержавшим 4 издания, по которому программной инженерии учатся многие студенты. Вот начало текста по поводу множественного наследования:

Множественное наследование позволяет нескольким объектам выступать в роли базовых и поддерживается во многих языках (ссылка на первое издание этой книги [M1988]).

Помимо неудачного использования "объектов", вместо классов начало кажется весьма подозрительным. Цитата продолжается:

Характеристики нескольких различных классов объектов

(классы, уже хорошо!)

могут комбинироваться, создавая новый объект.

(Нет, опять неудача.) Далее следует пример множественного наследования:

например, пусть мы имеем класс объектов CAR, инкапсулирующий информацию об автомобиле, и класс PERSON, инкапсулирующий информацию о человеке. Мы можем использовать их для определения

(неужели оправдаются наши наихудшие подозрения?)

нового класса CAR-OWNER, комбинирующего атрибуты CAR и PERSON.

(Они оправдались.) Нас приглашают рассматривать каждый объект CAR-OWNER не только как персону, но и как автомобиль. Для каждого, кто изучал наследование даже на элементарном уровне, это станет сюрпризом.

Несомненно, вы понимаете, что второе отношение является клиентским, а не наследованием, владелец автомобиля является (is) персоной, но имеет (has) автомобиль.


Рис. 6.1.  Походящая модель

В формальной записи:

class CAR_OWNER inherit PERSON feature my_car: CAR ... end

В цитируемом тексте обе связи используют отношение наследования. Наиболее интересный пассаж в этом обсуждении следует далее, когда автор советует читателям рассматривать наследование с осторожностью:


Адаптация через наследование имеет тенденцию приводить к избыточной функциональности, что может делать компоненты неэффективными и громоздкими.

И в самом деле, громоздкими - подумаем о владельце машины с крышей, мотором, карбюратором, не говоря уже о четырех колесах и запаске. Эта картина возникла, возможно, под влиянием образчика австралийского юмора, где владелец машины выглядит так, как если бы он являлся своим автомобилем.


Рис. 6.2.  Рисунок Джеффа Хокинга (голова его похожа на его же авто с открытыми дверцами)

Наследование не является тривиальной концепцией, так что мы можем забыть и простить автора процитированного отрывка, но сам пример имеет важную практическую пользу - он помог нам стать немного умнее и напомнил базисное правило наследования:

Правило: Наследование "Is-a" (является)

Не делайте класс B наследником класса A, если нельзя привести аргументы в защиту того, что каждый экземпляр B является также экземпляром A.

Другими словами, мы должны быть способными убеждать, что каждый B is an A (отсюда имя: "is-a").

Вопреки первому впечатлению, это слабое, а не строгое правило, и вот почему:

Обратите внимание на фразу "привести аргументы". Мы не требуем доказательства того, что каждый B всегда является A. В большинстве случаев мы оставляем пространство для дискуссии. Верно ли, что каждый "сберегательный счет" (savings account) является "текущим счетом" (checking account)? Здесь нет абсолютного ответа - все зависит от политики банка и вашего анализа свойств различных видов счетов. Возможно, вы решите сделать класс SAVINGS_ ACCOUNT наследником BANK_ACCOUNT или поместить его где-либо еще в структуре наследования. Разумные люди могут все же не согласиться с результатом. Это нестрашно, важно лишь, чтобы был случай, для которого ваши аргументы способны устоять. В нашем контрпримере: нет ситуации, при которой аргументы в пользу того, что CAR_OWNER является CAR, могли бы устоять.Наш взгляд на то, что означает отношение "является", будет довольно либеральным.Он не будет, например, препятствовать наследованию реализации - форме наследования, многими считающейся подозрительной.Эти наблюдения показывают как полезность, так и ограниченность правила "Is-a". Оно полезно как отрицательное правило, позволяя обнаружить и отвергнуть неподходящее использование наследования. Но как положительное правило оно недостаточно - не все, что проходит тест, заданный правилом, является подходящим случаем наследования.


Как разрабатываются структуры наследования


При чтении книги или учебной статьи по ОО-методу или при обнаружении библиотеки классов с уже спроектированной иерархией наследования авторы не всегда говорят о том, как они пришли к конечному результату. Что же следует делать при проектировании собственных структур?



Классификация при множественных критериях


Традиционная классификация в естественных науках использует единственный критерий (возможно, объединяющий несколько качеств) на каждом уровне: позвоночные или беспозвоночные, ветви, обновляемые один или несколько раз в год, и тому подобное. Результатом будет то, что называется иерархией единственного наследования, чье главное преимущество - простота классификации. Конечно, возникают проблемы, поскольку природа определенно не пользуется единственным критерием. Это очевидно для всякого, кто когда-либо пытался провести классификацию, вооружившись книгой по ботанике с традиционной классификацией Линнея.

При разработке ПО, где единый критерий кажется ограничительным, мы можем использовать все приемы множественного и особенно дублирующего наследования, которыми мы овладели при изучении предыдущих лекций. Рассмотрим, например, класс EMPLOYEE в системе управления персоналом. Предположим также, что у нас есть два различных критерия классификации служащих:

по типу контракта: временные или постоянные работники;по типу исполняемой работы: инженерная, административная, управленческая.

Оба эти критерия приводят к правильным классам-потомкам. При этом мы не впадаем в таксоманию, так как идентифицируемые классы, такие как TEMPORARY_EMPLOYEE по первому критерию и MANAGER по второму, действительно характеризуются специальными компонентами, не применимыми к другим категориям. Как же следует поступать?

В первой попытке введем все варианты на одном и том же уровне (рис. 6.12).

Для простоты на этой схеме имена классов сокращены. В реальной системе мы действуем более аккуратно и используем, как положено, полные имена, такие как PERMANENT_EMPLOYEE, ENGINEERING_EMPLOYEE и так далее.

Получившуюся иерархию наследования нельзя признать удовлетворительной, так как различные концепции представлены классами одного уровня.


Рис. 6.12.  Беспорядочная классификация



Критерии для наследования видов


Нет ничего необычного в рассмотрении наследования видов на ранних этапах анализа проблемной области, когда обсуждаются фундаментальные концепции и рассматриваются несколько равно привлекательных критериев классификации. В дальнейших исследованиях часто оказывается, что один из критериев начинает доминировать, выступая в качестве основы построения иерархической структуры. Тогда, как показывает наше обсуждение, следует отказаться от наследования типов в пользу построенной нами схемы.

Все же я нахожу наследование видов полезным при выполнении следующих трех условий:

Различные критерии классификации одинаково важны, так что выбор одного в качестве основного представляется спорным.Многие возможные комбинации (такие как в примере: permanent supervisor, temporary engineer, permanent engineer и так далее) являются необходимыми.Рассматриваемые классы настолько важны, что стоит потратить время на разработку лучшей из возможных структур наследования. Чаще всего речь идет в таких случаях о библиотечных классах повторного использования.

Примером приложения, удовлетворяющего этим критериям, является библиотека Base с ее структурой иерархии на верхних уровнях, описанная в последней лекции этой книги. Классы, полученные в результате этих усилий, в деталях описаны в [M 1994а]. Они построены в традиции естественных наук с применением таксономических принципов систематической классификации основных программистских структур. Верхняя часть этой иерархии выглядит так:


Рис. 6.15.  Классификация, основанная на видах фундаментальных программистских структур

Классификация на первом уровне (BOX, COLLECTION, TRAVERSABLE) основана на типах; уровень ниже (и многие другие, не показанные на рисунке) задают классификацию подтипов. Структура контейнера характеризуется тремя различными критериями:

COLLECTION определяет доступ к элементам. Класс SET позволяет определить сам факт присутствия элемента, в то время как BAG позволяет также посчитать число вхождений данного элемента.
Дальнейшие уточнения включают такие абстракции доступа, как SEQUENCE (элементы доступны последовательно), STACK (элементы доступны в порядке, обратном их включению) и так далее.BOX определяет представление элементов. Варианты включают конечные и бесконечные структуры. Конечные структуры могут быть ограниченными и не ограниченными. Ограниченные структуры могут быть фиксированными или изменяемого размера.TRAVERSABLE определяет способы обхода структур.Интересно отметить, что эта иерархия не начиналась, как иерархия видов. Начальная идея состояла в том, чтобы определить BOX, COLLECTION и TRAVERSABLE как несвязанные классы, каждый, задающий вершину своей независимой иерархии. Затем при описании реализации любой специальной структуры данных использовать множественное наследование с родителями из каждой иерархии. Например, связный список является конечным и неограниченным с последовательным доступом и линейным способом обхода.


Рис. 6.16.  Построение структуры данных комбинированием абстракций путем множественного наследования

Но затем мы осознали, что независимые семейства классов BOX, COLLECTION и TRAVERSABLE не лучший способ: им всем потребовались некоторые общие компоненты, в частности has (тест на проверку членства) и empty (тест на отсутствие элементов). Все это указывало на необходимость иметь общего родителя - CONTAINER, где эти общие свойства теперь и появляются. Следовательно, структура, изначально спроектированная как чистое множественное наследование с тремя непересекающимися иерархиями, превратилась в структуру с наследованием типов, приводящую к дублируемому наследованию.

Изначально трудно было сделать все сразу правильным, но со временем структура стала гибкой, стабильной и полезной. Она подтверждает заключение нашего обсуждения: наследование видов не для слабонервных. Когда оно применимо, то играет ключевую роль в сложных проблемных областях, где взаимодействуют многие критерии. Если усилия по ее созданию оправданы, как при создании фундаментальных библиотек повторно используемых компонентов, то их необходимо совершить.


Льготное наследование


Льготное наследование является схемой, в которой родитель представляет коллекцию полезных компонентов, предназначенных для использования его потомками.

Определение: Льготное наследование

Льготное наследование применяется, если A существует единственно в целях обеспечения множества логически связанных компонентов, дающих преимущества его потомкам, таким как B. Двумя общими вариантами являются:

Наследование констант, при котором компоненты A все являются константами или однократными функциями, описывающими разделяемые объекты.Наследование абстрактной машины, в котором компоненты A являются подпрограммами, рассматриваемыми как операции абстрактной машины.

Примером льготного наследования может служить класс EXCEPTIONS, класс, предоставляющий множество утилит, обеспечивающих доступ к механизму обработки исключений.

Иногда, как в примерах, которые появятся чуть позже, при льготном наследовании используется только один вариант - константы или абстрактная машина, но в других случаях, как для класса EXCEPTIONS, родительский класс предоставляет как константы (такие как коды исключений Incorrect_inspect_value), так и подпрограммы (такие как trigger для возбуждения исключения разработчика). Так как при нашем обсуждении категории наследования рассматриваются как непересекающиеся, то льготное наследование с двумя пересекающимися вариантами рассматривается как одна категория.

При наследовании констант как A, так и B являются эффективными. При наследовании абстрактной машины ситуация более гибкая, но B должно быть, по меньшей мере, столь же эффективно как и A.

В деталях льготное наследование еще будет обсуждаться в данной лекции.


При льготном наследовании мы еще менее щепетильны, чем при наследовании реализации. Чистый расчет руководит нами при вступлении в брак. Мы видим класс с полезными свойствами и хотим использовать его. Здесь нет ничего предосудительного, поскольку таково назначение класса.



Множественные критерии и наследование видов


Вероятно, наиболее трудная проблема наследования возникает при наличии альтернативных критериев классификации.



Наследование c ограничением


Определение: Наследование c Ограничением

Наследование c ограничением применимо, если экземпляры B являются экземплярами A, удовлетворяющими некоторому ограничению, выраженному, если это возможно, как часть инварианта B, не включенного в инвариант A. Любой компонент, введенный в B, должен быть логическим следствием добавленного ограничения. A и B должны быть оба отложенными или оба эффективными.

<
p>Типичным примером является: Прямоугольник
Квадрат.

Ограничением является утверждение: сторона1 = сторона2 (включается в инвариант класса Квадрат).

Многие математические примеры подпадают под эту категорию.

Последняя часть определения позволяет избежать смешения этой формы наследования с другими, такими как наследование с расширением, допускающим появление полностью новых компонентов у наследника. Здесь в интересах простоты предпочтительно ограничивать новые компоненты теми, что непосредственно следуют из добавленного ограничения. Например, у класса CIRCLE может появиться новый компонент radius, отсутствующий у родительского класса ELLIPSE.

Поскольку допускаются только концептуальные изменения класса A, добавляющие некоторые ограничения в класс B, то оба класса должны быть либо отложенными, либо эффективными.

Наследование с ограничением концептуально близко к наследованию подтипов; последующее обсуждение создания подтипов (subtyping) будет относиться к обеим категориям.


Наследование подтипов


Начнем с наиболее очевидной формы модельного наследования. При моделировании внешней системы часто возникает ситуация, при которой категория внешних объектов естественно разделяется на непересекающиеся подкатегории. Например, замкнутые фигуры можно разделить на многоугольники и эллипсы. Вот формальное определение:

Определение: наследование подтипов

Наследование подтипов применимо, если A и B представляют некоторые множества A' и B' внешних объектов, так что B' является подмножеством A', и множество, моделируемое любым другим подтипом, наследуемым от A, не пересекается с B'. Класс A должен быть отложенным.

A' может быть множеством замкнутых фигур, B' - множеством многоугольников, A и B - соответствующие классы. В большинстве практических случаев "внешняя система" не принадлежит миру программ, например, определяет некоторые аспекты деятельности компании (внешними объектами являются специальные и депозитные счета) или часть внешнего мира (с планетами и звездами).

Наследование подтипов является формой наследования ближайшей к иерархической таксономии в ботанике, зоологии и других естественных науках.

(ПОЗВОНОЧНЫЕ

МЛЕКОПИТАЮЩИЕ и подобные примеры).

Мы настаиваем, что родитель A должен быть отложенным, поскольку он описывает не полностью специфицированное множество объектов, в то время как наследник B может быть как эффективным, так и отложенным. Следующие две категории рассматривают ситуации, где A может быть эффективным классом.

В одном из следующих разделов эта категория наследования будет рассмотрена детальнее, поскольку она не столь уж проста, как может показаться с первого взгляда.



Наследование подтипов и скрытие потомков


Первая категория наследования из нашего списка, вероятно, единственная, с которой согласится каждый, по меньшей мере, тот, кто принимает наследование: то, что мы можем назвать чистым наследованием подтипов (типов).

Большая часть данного обсуждения применима и к наследованию с ограничением, чье принципиальное отличие состоит в том, что от родителя не требуется быть отложенным классом.



Наследование реализации


Определение: Наследование Реализации

Наследование реализации применяется, если B получает от A множество компонентов (отличных от константных атрибутов и однократных функций), необходимых для реализации абстракции, связанной с B. Как A, так и B должны быть эффективными.

Наследование реализации в деталях обсуждается позднее в этой лекции. Общей ситуацией является "брак по расчету", основанный на множественном наследовании, где один из родителей обеспечивает спецификацию (наследование с конкретизацией), а другой - предоставляет реализацию (наследование реализации).

Случай наследования константных атрибутов и однократных функций покрывается следующим вариантом.


Часто критикуемой, но полезной и концептуально правильной формой является наследственная связь между классом, описывающим реализацию абстрактной структуры данных, и классом, обеспечивающим реализацию.



Наследование с конкретизацией


Перейдем теперь к третьей и последней группе категорий - программному наследованию.

Определение: Наследование с конкретизацией

Наследование с конкретизацией применимо, если A задает структуру данных общего вида, а B представляет ее частичную или полную реализацию. A является отложенным; B может быть эффективным или все еще отложенным, оставляя пространство для дальнейшей конкретизации собственными потомками.

Примером, используемым многократно в предыдущих лекциях, является отложенный класс TABLE, описывающий таблицы самой общей природы. Конкретизация ведет к потомкам SEQUENTIAL_ TABLE и HASH_TABLE, все еще отложенным. Заключительная конкретизация SEQUENTIAL_TABLE приводит к эффективным классам ARRAYED_TABLE, LINKED_TABLE, FILE_TABLE.

Термин "конкретизация" (reification), введенный Георгом Лукасом, происходит от латинского слова, означающего "превращение в вещь". Он используется в спецификациях и методе разработки VDM.



Наследование с расширением


Определение: Наследование с Расширением

Наследование с расширением применимо, когда B вводит компоненты, не представленные в A и не применимые к прямым экземплярам A. Класс A должен быть эффективным.

Присутствие обоих вариантов - расширения и сужения (ограничения) - является одним из парадоксов наследования. Как отмечалось при обсуждении наследования, расширение применяется к компонентам, в то время как ограничение (понимаемое как специализация) применяется к экземплярам. Но это не устраняет парадокс.

Проблема в том, что добавляемые компоненты обычно включают атрибуты. Так что при наивной интерпретации типа (заданного классом) как множества его экземпляров отношение между классом и наследником (рассматриваемых как множества) "быть подмножеством" становится полностью ошибочным. Рассмотрим пример:

class A feature a1: INTEGER end class B inherit A feature b1: REAL end

Рассмотрим каждый экземпляр класса A как одноэлементное множество (которое можно записать как <n>, где n целое), а каждый экземпляр B - как пару, содержащую целое и вещественное (например, пару <1, -2.5>). Множество пар MB не является подмножеством одноэлементного множества MA. Верно обратное, отношение "быть подмножеством" имеет место в обратном направлении, поскольку существует отображение один-к-одному между MA и множеством всех пар, имеющих данный второй элемент.

Обнаружение того факта, что отношение "быть подмножеством" не выполняется, делает наследование расширением довольно подозрительным. Например, в ранней версии уважаемой ОО-библиотеки (не от ISE) класс RECTANGLE был наследником SQUARE, в отличие от изучаемого нами способа. Причина простая: класс SQUARE имеет атрибут side; класс RECTANGLE наследует его, добавляя новый компонент other_side. Этот проект был подвергнут критике, он был пересмотрен с обращением наследования.

Но не следует исключать наследование с расширением как общую категорию. У нее есть эквивалент в математике, где специализация некоторого понятия происходит путем добавления новых операций. Такое происходит довольно часто и считается необходимым. Типичным примером является понятие кольца, представляющее специализацию понятия группы. В группе задана некоторая операция, назовем ее +, обладающая рядом свойств. Кольцо является группой, потому имеет ту же операцию + с теми же свойствами. Но в кольцо добавляется новая операция, скажем, *, со своими собственными свойствами. По сути это не отличается от введения нового атрибута классом наследником.

Соответствующая схема используется и при разработке ОО-ПО. Конечно, класс SQUARE должен быть наследником RECTANGLE, а не наоборот, но можно предложить легитимные примеры. Класс MOVING_POINT (в приложениях кинематики) может наследовать от чисто графического класса POINT и добавлять компонент speed, описывающую величину и направление скорости. Другой пример, в текстовом процессоре класс CHAPTER может наследовать от DOCUMENT, добавляя специфические свойства - текущую позицию лекции в книге и процедуру ее сохранения.



Наследование вариаций


(Читатели - не математики, добро пожаловать!) Перейдем теперь ко второму семейству категорий - наследованию вариаций.

Определение: Наследование вариаций типа и функций

Наследование вариаций применяется, если B переопределяет некоторые компоненты A; A и B являются оба либо отложенными, либо эффективными. Класс B не должен вводить никаких новых компонентов за исключением тех, что непосредственно необходимы переопределяемым компонентам. Здесь рассматриваются два случая:

Наследование вариаций функций: переопределения действуют на тела компонентов, но не на их сигнатуры.Наследование вариаций типа: все переопределения являются переопределениями сигнатур.

Наследование вариаций применимо, когда существующий класс A, задающий некоторую абстракцию, полезен сам по себе, но обнаруживается необходимость представления подобной, хотя и не идентичной абстракции, имеющей те же компоненты, но с отличиями в сигнатуре и реализации.

Определение требует, чтобы оба класса были эффективными (общий случай) или оба отложенными. Оно не рассматривает эффективизацию компонентов, когда речь идет о переходе от абстрактной формы к конкретной. Тесно связанной является рассматриваемая далее категория "отмена эффективизации", в которой некоторые эффективные компоненты становятся отложенными.

Из определения следует, что наследник не должен вводить новых компонентов за исключением непосредственно необходимых для переопределения. Этим проводится граница между наследованием расширением и наследованием вариаций.

При вариациях типа можно изменять только сигнатуры некоторых компонентов (число и типы аргументов и результата). Эта форма наследования подозрительна и часто является признаком таксомании. В законных случаях, однако, это может быть подготовкой для наследования расширением или реализацией. Примером наследования вариации типа могут быть наследники MALE_EMPLOYEE и FEMALE_EMPLOYEE.

Наследование вариации типа не является необходимым, когда начальная сигнатура использует закрепленные (like...) объявления.
Например, в классе SEGMENT интерактивного пакета для рисования можно ввести функцию:

perpendicular: SEGMENT is -- Сегмент, повернутый на 90 градусов ...Затем определим наследника DOTTED_SEGMENT, дающего графическое представление пунктирными, а не непрерывными линиями. В этом классе perpendicular должен возвращать результат типа DOTTED_SEGMENT, так что необходимо переопределить тип. Этого бы не требовалось, если бы изначально результат объявлялся как like Current. Так что, будь у вас доступ к источнику и его автору, можно было бы предложить модифицировать оригинал, не нанося ущерба существующим клиентам. Но если нет возможности модифицировать оригинал или по ряду причин закрепленное объявление не подходит оригиналу (вероятно, из-за потребностей других потомков), то возможность переопределить тип может стать палочкой-выручалочкой.

При наследовании функциональной вариации изменяются тела некоторых компонентов. Если, как обычно в таком случае, компонент уже эффективен, то это означает изменение реализации. Спецификация компонента, заданная утверждением, также может измениться. Также возможно, хотя и реже встречается, иметь функциональную вариацию между двумя отложенными классами, в этом случае будут меняться только утверждения. Это может повлечь изменения в некоторых функциях, отложенных или эффективных, связанных с утверждениями, или даже добавление новых компонентов.

Наследование функциональной вариации является прямым приложением принципа Открыт-Закрыт: мы хотим адаптировать существующий класс, не затрагивая оригинал (к коду которого мы можем и не иметь доступа) и его клиентов. Это может стать предметом злоупотреблений, некоей формой хакерства, перекручивая существующий класс, приспосабливая его для других целей. Во всяком случае это будет организованное хакерство, позволяющее избежать угроз модификации существующего ПО. Но если есть доступ к оригиналу, то предпочтительной может оказаться реорганизация иерархии наследования путем введения абстрактного класса, для которого как уже существующий класс A, так и новичок B будут его потомками или подходящими наследниками с равным статусом.


Наследование вида


Помня об идеях использования наследования для классификации, следует ввести промежуточный уровень, описывающий конкурирующие критерии классификации (рис. 6.13).

Появились два вида служащих. Заметьте, имя CONTRACT_EMPLOYEE не означает служащего, имеющего контракт, а служащего, характеризуемого контрактом (он может не иметь контракта!). Имя класса для другого вида означает "служащий, характеризуемый своей специальностью".

То, что эти имена кажутся неестественными, отражает определенную сложность, характерную для наследования видов. При наследовании подтипов мы встречались с правилом, устанавливающим, что экземпляры наследников принадлежат непересекающимся подмножествам множества, заданного родителем. Здесь это правило неприменимо. Постоянный служащий имеет специальность и может быть инженером. Такая классификация подходит для дублирующего наследования: некоторые потомки классов, показанных на рисунке, будут иметь в качестве предков CONTRACT_EMPLOYEE и SPECIALTY_EMPLOYEE не напрямую, но через наследование от классов PERMANENT и ENGINEER. Такие классы будут дублируемыми потомками EMPLOYEE.

Эта форма наследования может быть названа наследованием видов: различные наследники некоторого класса представляют не непересекающиеся подмножества его экземпляров, но различные способы классификации экземпляров родителя. Заметьте, это имеет смысл только при условии, что родитель и наследники являются отложенными классами, говоря другими словами, классами, описывающими общие категории, а не полностью специфицированные объекты. Наша первая попытка классификации EMPLOYEE по видам (та, у которой все потомки на одном уровне) нарушает это правило, вторая ему удовлетворяет.


Рис. 6.13.  Классификация, использующая виды



Необходимость скрытия потомком


В совершенном мире можно было бы ввести правило, не допускающее скрытия потомком, но это не подходит для реального мира ПО. Наследование должно быть полезно даже для классов, написанных людьми, не обладающими совершенным предвидением, некоторые из включенных в класс компонентов могут не иметь смысла для потомков, написанных позднее и в другом контексте. Такие случаи можно назвать таксономией исключений (В другом контексте достаточно было бы одного слова "исключение", но не хочется пересечения с программистским понятием исключения, изучаемым в предыдущих лекциях.)

Следует ли отказываться от наследования привлекательного и полезного класса из-за таксономии исключений, другими словами, из-за того, что у него есть пара компонентов, не подходящих для вызова нашими клиентами. Это было бы неразумно. Следует просто спрятать эти компоненты, сделать их невидимыми для наших клиентов и продолжать свою работу.

Альтернативы обсуждались при рассмотрении основополагающего принципа Открыт-Закрыт и они не кажутся привлекательными:

Можно было бы модифицировать оригинальный класс. Это повлекло бы к поломке уже работающих систем у всех клиентов класса - нет уж, увольте! И это не всегда возможно практически из-за недоступности кода.Можно было бы написать новую версию класса, если нам повезло, и мы располагаем исходным кодом. Этот подход противоположен всему ОО-подходу, он противоречит прежде всего повторному использованию.

Независимость клиента


Абстрагирование и факторизация могут во многих случаях выполняться без негативных последствий для существующих клиентов (приложение принципа Открыт-Закрыт). Это свойство является результатом использования скрытия информации. Рассмотрим снова предшествующие схематические случаи, но с типичным клиентским классом X, показанным на рисунке:


Рис. 6.19.  Абстракция, факторизация и клиенты

Когда B абстрагируется в A, или компоненты E факторизуются с компонентами F в D, класс X, представляющий клиента B или E (на рисунке он клиент обоих классов) в большинстве случаев не заметит никаких изменений. Включение класса в схему наследования не оказывает влияния на его клиентов, если они применяют компоненты класса на сущностях соответствующего типа. Другими словами, если X использует B и E как поставщиков по схеме:

b1: B; e1: E ... b1.some_feature_of_B ... e1.some_feature_of_E

то X не заметит, что B или E обрели родителей в результате абстрагирования или факторизации.



Область действия правил


Относительно широкое рассмотрение наследования, предпринятое в этой книге, не означает, что "подходит все". Мы принимаем и фактически поддерживаем только некоторые формы наследования, часть из которых одобряется не всеми авторами. Конечно, есть много способв неверного использования наследования, вроде CAR_OWNER. Так что случаи наследования строго ограничены:

Правило Наследования

Каждое использование наследования должно принадлежать одной из допустимых категорий.

Это правило утверждает, что все типы наследования известны и что, если встречается ситуация, не покрываемая этими типами, то не следует применять наследование.

Под допустимыми категориями понимаются категории, рассматриваемые в этом разделе. И я надеюсь, что все имеющие смысл ситуации полностью покрываются этим рассмотрением. Но таксономия (введение классификации) может нуждаться в дальнейшем обдумывании. Я нашел немногое в литературе по этой теме, наиболее полезная ссылка на неопубликованные тезисы диссертации [Girod 1991]. Так что вполне возможно, что в этой попытке классификации пропущены некоторые категории. Но правило говорит, что, если вы рассматриваете возможное применение наследования, не укладывающееся в предложенную схему, то следует серьезно подумать, скорее всего, применять его не следует. Если же по зрелому размышлению вы решите применить наследование, то это стоит рассматривать как новый вклад в классификацию.

Мы уже видели следствие правила Наследования - правило Таксомании, устанавливающее необходимость введения собственного вклада для класса наследника. Это непосредственно следует из того, что каждая легитимная форма наследования, детализируемая ниже, требует от наследника выполнения по крайней мере одной из ранее перечисленных операций.

Правило Наследования не запрещает наследственные связи, принадлежащие более чем к одной категории. Однако такая практика не рекомендуется.

Правило Упрощения Наследования

Следует предпочитать наследование, принадлежащее ровно одной допустимой категории.

<
p> Это не абсолютное правило; оно относится к рекомендательным положительным правилам. Оно вводится в интересах простоты и ясности: всякий раз, когда вводится наследственная связь межу двумя классами, неявно применяются методологические принципы, в особенности при решении вопроса выбора одного из применимых вариантов. Простота структуры уменьшает вероятность ошибки проектирования или создания хаоса, усложняющего использование и сопровождение.

Конечно, возможна ситуация, при которой одна наследственная связь служит двум различным целям в нашей классификации. Но такие случаи составляют меньшинство.

К сожалению, я не знаю простого критерия, недвусмысленно говорящего о корректности свертки нескольких категорий в одну наследственную связь. Отсюда и происходит рекомендательный характер правила. Автор разработки, основываясь на ясном понимании методологии наследования, должен принимать решение в каждом спорном случае.


Общая таксономия


С этого момента речь пойдет о правильном использовании наследования. Список включает двенадцать различных категорий, для удобства сгруппированных в три семейства:


Рис. 6.7.  Классификация допустимых категорий наследования

Классификация основана на том наблюдении, что любая программная система отражает как внешнюю модель, так и связь с реалиями в области программных приложений. В связи с этим будем различать:

наследование модели, отражающее отношения "is-a" между абстракциями, характерными для самой модели;программное наследование, выражающее отношения между объектами программной системы, не имеющих очевидных двойников во внешней модели;наследование вариаций - специальный случай, относящийся как к моделям, так и программному наследованию, служащий для описания вариаций семейства классов.

Эти три общие категории облегчают понимание, но наиболее важные свойства задаются терминальными категориями.

Так как классификация сама по себе является таксономией, можно из любопытства задаться вопросом, как применить к ней самой идентифицируемые категории. Это является темой упражнения У6.2.

Следующие далее определения используют имя A для родительского класса и B для наследника:


Рис. 6.8.  Соглашение именования при определении категорий наследования

Каждое из определений будет устанавливать, в каких случаях A и B могут быть отложенными, а когда - эффективными. Обсуждение завершается таблицей, содержащей сводку применимых категорий для каждой комбинации отложенных и эффективных классов.



Один механизм, или несколько?


Заметьте, это обсуждение предполагает в качестве основы раннюю презентацию, определяющую смысл наследования (см. лекцию 14 курса "Основы объектно-ориентированного программирования").

Разнообразие использования наследования, чему свидетельством является предшествующее рассмотрение, может создавать впечатление, что должны существовать разнообразные механизмы языка, покрывающие основополагающие понятия. В частности, ряд авторов предлагают разделение наследование модуля, в особенности как средства повторного использования существующих компонентов в новом модуле, и наследования типа, в частности механизма классификации типов.

Такое разделение нанесло бы больше вреда, чем принесло пользы. Вот несколько доводов.

Во-первых, сведение наследования к двум категориям не отражает всего разнообразия, вводимого нашей классификацией. Так как никто не будет отстаивать введение десяти различных языковых механизмов, то введение двух механизмов приводило бы к ограниченному результату.

Практическим следствием были бы бесполезные методологические обсуждения: предположим, вы хотите наследовать от класса итератора, такого как LINEAR_ITERATOR; следует ли использовать наследование модуля или наследование типа? Можно приводить аргументы в защиту одного и другого решения. Вклад этого предложения в критерий качества нашего ПО и скорость его создания будут фактически нулевыми.

В упражнении У6.8 требуется проанализировать наши категории, отнеся их либо к наследованию модуля, либо к наследованию типа.

Интересно задуматься и о тех последствиях в усложнении языка, к которым привело бы такое разделение. Наследование сопровождается несколькими вспомогательными механизмами, большинство из которых необходимо обоим видам:

Переопределение полезно как для подтипов (вспомните RECTANGLE, переопределяющий perimeter от POLYGON) и для расширения модуля (принцип Открыт-Закрыт требует при наследовании модуля сохранения гибкости изменений, без чего будет потеряно одно из главных преимуществ ОО-метода).Переименование полезно при наследовании модуля.
Полагать его неподходящим при наследовании типа (см. [Breu 1995]) представляется серьезным ограничением. При моделировании внешней системы варианты некоторого понятия могут вводить специальную терминологию, которую желательно сохранить в ПО. Класс STATE_INSTITUTIONS в географической или выборной информационной системе может иметь потомка LOUISIANA_INSTITUTIONS, отражающего особенности политической структуры штата Луизиана, поэтому вполне ожидаемо желание потомка переименовать компонент counties, задающий список округов штатов, в parishes - имя, используемое для округа в данном штате.Дублируемое наследование может встретиться для любой из форм. Так как можно ожидать, что только наследование модуля сохранит полиморфную подстановку, то при наследовании типов тут же возникнет необходимость разбора случаев и предложения select со всеми недостатками при появлении новых случаев. Появляются и другие вопросы - когда разделять компоненты, а когда их дублировать.При введении в язык новых механизмов они взаимодействуют друг с другом и с другими механизмами языка. Должны ли мы защитить класс от совместного наследования и модуля, и типа? Если да, то будут возмущены разработчики, использующие класс двумя возможными способами, если нет, мы откроем ящик Пандоры, грозящий появлением множества проблем - конфликтов имен, переопределений и так далее.Все это ради преимуществ пуристской точки зрения - ограниченной и спорной. Нет ничего плохого в защите спорной точки зрения, но следует быть крайне осторожным в нововведениях и учитывать их последствия для пользователей языка. И снова примером может служить Эдсгар Дейкстра в исследовании goto. Он не только в деталях объяснил все недостатки этой инструкции, основываясь на теории конструирования ПО и процесса его выполнения, но и показал, как можно без труда заменить этот механизм. В данном же случае убедительные аргументы не представлены, по крайней мере, я не увидел, почему "плохо" иметь единый механизм, покрывающий как наследование модулей, так и наследование типа.

Помимо общих возражений, основанных на предвзятых идеях о том, каково должно быть наследование, приводится лишь один серьезный аргумент против единого механизма наследования - сложность статической проверки типов, возникающая при этом подходе. Эта проблема полностью анализировалась в лекции 17 курса "Основы объектно-ориентированного программирования", бремя ее решения возлагается на компиляторы, которые признают это бремя разумным, если этим существенно облегчается жизнь разработчиков.

Как покажет в конечном итоге это обсуждение, возможность иметь единый механизм для наследования модуля и типа является результатом важнейшего решения ОО-конструирования ПО: унификации концепций модуля и типа в едином понятии класса. Если мы принимаем классы как модули и как типы, то следует принять и наследование, аккумулирующее модули и типы.


Определение подтипа


Как указывалось во введении в наследование, мощь этой идеи частично происходит от интеграции механизма модуля с механизмом типов: определения нового типа как специального случая существующего типа, определение нового модуля как расширения уже существующего модуля. Большинство из спорных вопросов о наследовании идут от осознания конфликтов между этими двумя точками зрения. При наследовании подтипов такие вопросы не возникают, хотя, как мы увидим, это не означает, что все становится простым.

Наследование подтипов по своему образцу соответствует принципам таксономии в естественных и математических науках. Каждое позвоночное является животным, каждое млекопитающее является позвоночным, каждый слон является млекопитающим. Каждая группа (в математике) является моноидом, каждое кольцо является группой, каждое поле является кольцом. Подобными примерами, многие из которых мы видели в предыдущих лекциях, изобилует ОО-ПО:

FIGURE
CLOSED_FIGURE
POLYGON
QUADRANGLE
RECTANGLE
SQUAREDEVICE
FILE
TEXT_FILESHIP
LEISURE_SHIP
SAILBOATACCOUNT
SAVINGS_ACCOUNT
FIXED_RATE_ACCOUNT

В любом из этих случаев четко идентифицируется множество объектов, описываемое родительским типом, и мы выделяем подмножество, характеризуемое некоторыми свойствами, которыми обладают не все объекты родителя. Например, текстовый файл является файлом со специальными свойствами, вытекающими из того, что элементы файла являются строками текста, что нехарактерно, например, для бинарных файлов.

Общее правило при наследовании подтипов состоит в том, что потомки задают непересекающиеся подмножества экземпляров. Ни одна из замкнутых фигур не является одновременно эллипсом и многоугольником.

Некоторые из примеров, такие как RECTANGLE

SQUARE, возможно, включают эффективного родителя и потому представляют случаи наследования с ограничением.


Ошибочное использование


Прежде чем рассмотреть правильные случаи, еще раз поговорим об ошибках. Ошибаться - в природе человека, нельзя надеяться на полноту классификации возможных ошибок, но несколько общих ошибок идентифицируются просто.

Первая типичная ошибка связана с путаницей отношений "has" и "is". Класс CAR_OWNER служит примером - экстремальным, но не уникальным. Мне доводилось слышать и видеть и другие подобные примеры, такие как APPLE_PIE, наследуемый от APPLE и от PIE, или (упоминаемый Adele Goldberg) ROSE_TREE, наследуемый от ROSE и от TREE.

Другим типичным примером является таксомания, в котором простое булево свойство, такое как пол персоны (или свойство с несколькими фиксированными значениями, такое как цвет светофора), используется как критерий наследования, хотя нет важных вариантов компонентов, зависящих от свойства.

Третьей типичной ошибкой является наследование по расчету (convenience inheritance), при котором разработчик видит некоторые полезные компоненты класса и создает наследника просто для того, чтобы использовать эти компоненты. Заметьте, использование "наследования реализации" или "наследование компонентов класса" являются допустимыми формами наследования, изучаемыми позже в этой лекции. Ошибка в том, что класс используется как родитель без подходящего отношения is-a между соответствующими абстракциями, а в некоторых случаях вообще без адекватной абстракции.



Отмена эффективизации


Определение: Наследование с Отменой эффективизации

Наследование с отменой эффективизации применимо, если B переопределяет некоторые из эффективных компонентов A, преобразуя их в отложенные компоненты.

<
p> Отмена эффективизации не является общим приемом и не должна им быть. Основная идея этого способа противоречит общему направлению, так как обычно ожидается конкретизация потомка B своего более абстрактного родителя A (как это имеет место в следующей рассматриваемой категории, для которой A является отложенным, а B эффективным или, по крайней мере, менее отложенным). По этой причине новичкам следует избегать отмены эффективизации. Но она может быть законной в двух случаях:

При множественном наследовании сливаются компоненты, наследуемые от двух различных родителей. Если один из них отложенный, а другой эффективный, то слияние произойдет автоматически при условии совпадения имен (возможно после переименования), эффективная версия будет определять реализацию. Но если обе версии эффективны, то следует провести потерю эффективизации одной версии, отдавая предпочтение другой версии.Хотя абстракция соответствует потребностям, но повторно используемый класс слишком конкретен для наших целей. Отмена эффективизации позволит удалит нежеланную реализацию. Перед использованием этого решения следует рассмотреть альтернативу - реорганизовать иерархию наследования, сделав более конкретный класс наследником нового отложенного класса. По понятным причинам это не всегда возможно, например из-за отсутствия доступа к исходному коду. Отмена эффективизации может в таких случаях обеспечить полезную форму обобщения.Для этой категории наследования класс B будет отложенным; A обычно эффективным, но может быть частично отложенным.


Подходит ли нам наследование видов?


Наследование видов не является общеприменимым и представляет собой объект для критики. Читатель может сам судить, стоит ли его использовать при решении возникающих у него проблем, но в любом случае необходимо разобрать все аргументы за и против.

Прежде всего должно быть ясно, что, подобно дублируемому наследованию, наследование видов не является механизмом для новичков. Правило осмотрительности, введенное для дублируемого наследования, справедливо и здесь: если ваш опыт разработки ОО-проектов измеряется несколькими месяцами, избегайте наследования типов.

Альтернативой наследованию типов служит выбор одного критерия в качестве первичного, он и будет руководить построением иерархии. Для учета других критериев следует использовать специальные компоненты класса. Стоит отметить, что современные зоологи и ботаники используют именно такой подход: их основной критерий классификации основан на реконструкции эволюционной истории, включающей деление на роды и виды. Значит ли это, что мы всегда имеем единый, бесспорный стандарт, руководящий нами при создании программистских таксономий?

Чтобы в нашем примере придерживаться единого критерия, мы могли бы принять решение, что тип работы служащего является более важным фактором, а статус контракта задать компонентом. Рассмотрим первую попытку введения в класс EMPLOYEE такого компонента:

is_permanent: BOOLEAN

Но такое решение накладывает серьезные ограничения. Расширяя возможности, приходим к варианту:

Permanent: INTEGER is unique Temporary: INTEGER is unique Contractor: INTEGER is unique ...

Но это означает, что мы сталкиваемся с явным перечислением, и лучшим подходом является введение класса WORK_CONTRACT, как правило, отложенного, имеющего потомков по числу видов контракта. Тогда мы сможем избежать явного разбора случаев в форме:

if is_permanent then ... else ... end

или

inspect contract_type when Permanent then ... when ... ... end

Как неоднократно говорилось, разбор случаев приводит к ряду проблем при появлении новых вариантов и нарушает важные принципы непрерывности, единого выбора, открытость-закрытость и так далее.
Вместо этого мы поставляем класс WORK_CONTRACT с отложенными компонентами, представляющими операции, зависящие от типа контракта, которые по-разному будут реализованы потомками. Большинству из этих компонентов будет необходим аргумент типа EMPLOYEE, представляющий служащего, к которому применяется операция, примерами операций могут быть hire (приглашение на работу) and terminate (увольнение).

Результирующая структура показана на рис. 6.14.

Эта схема, как вы заметили, почти идентична образцу проектирования с описателями, изучаемому ранее в этой лекции.

Такая техника может использоваться вместо наследования видов. Это усложняет структуру из-за введения независимой иерархии, нового атрибута (здесь contract) и соответствующего клиентского отношения. Преимущество ее в том, что по поводу иерархии не возникает вопросов. В то же время при наследовании видов абстракции требуют больших пояснений (служащий, рассматриваемый с позиций его специальности или контракта).


Рис. 6.14.  Многокритериальная классификация с независимой иерархией, построенной для клиента


Подходящая математическая модель


(Читатели - не математики могут пропустить этот раздел.)

Для успокоения совести следует разрешить видимый парадокс, отмеченный выше (обнаружение того, что MB не является подмножеством MA), так как мы хотим, чтобы некоторое отношение подмножества имело место между наследником и родителем. И это отношение реально существует, парадокс лишь показывает, что декартово произведение атрибутов не является подходящей моделью для моделирования класса. Рассмотрим класс:

class C feature c1: T1 c2: T2 c3: T3 end

Мы не должны выбирать в качестве математической модели C' - множества экземпляров C - декартово произведение T'1 _ T'2 _ T'3, где знак штрих ' указывает на рекурсивное использование модели множеств, приводящее к парадоксу (наряду с другими недостатками).

Вместо этого, следует рассматривать любой экземпляр как частичную функцию, отображающую множество возможных имен атрибутов ATTRIBUTE в множество возможных значений VALUE, со следующими свойствами:

A1 Функция определена для c1, c2 и c3.A2 Множество VALUE (множество цели для функции) является супермножеством T'1
T'2
T'3.A3 Значения функции для c1 лежат в T'1 и так далее.

Тогда, если вспомнить, что функция является специальным случаем отношения и что отношение является множеством пар (например, в ранее упоминаемом случае экземпляр класса A может быть промоделирован функцией {<a1, 25>}, а экземпляр класса B - {<a1, 1>, <b1, -2.5>}), мы получаем ожидаемое свойство - B' является подмножеством A'. Заметьте, здесь уже элементы обоих множеств являются парами и первая функция задает все возможные отображения второго атрибута.

Заметьте также, что принципиально важно установить свойство A1 как "Функция определена для...", но не в виде "Областью определения функции является...", что ограничивало бы область множеством {c1, c2 c3}, не позволяя потомкам добавлять свои собственные атрибуты. Как результат такого подхода, каждый программный объект моделируется неограниченным числом математических объектов.

Это обсуждение дает только схему математической модели. С деталями использования частичных функций для моделирования кортежей и общими математическими основами можно ознакомиться в [M 1990].



Покупать или наследовать


Основное правило выбора между двумя возможными межмодульными отношениями - клиентом и наследованием - обманчиво просто: клиент имеет, наследование является. Почему же тогда выбор столь непрост?



Понимание льготного наследования


Некоторые рассматривают льготное наследование как злоупотребление механизмом наследования, как некоторую форму хакерства.

Главный вопрос, заслуживающий рассмотрения, связан не столько с наследованием, сколько с тем, как определены классы ASCII и LINEAR_ITERATOR. Как всегда, при рассмотрении проекта класса, следует спросить себя: "Действительно ли мы описали значимую абстракцию данных - множество объектов, характеризуемых абстрактными свойствами?"

Для этих примеров ответ менее очевиден, чем для классов RECTANGLE, BANK_ACCOUNT или LINKED_LIST, но, по сути, он тот же:

Класс ASCII представляет абстракцию: "любой объект, имеющий доступ к свойствам множества ASCII".Класс LINEAR_ITERATOR представляет абстракцию: "любой объект, способный выполнять последовательные итерации на линейной структуре". Такой объект имеет тенденцию быть "абстрактной машиной", описанной в лекции 5.

Как только эти абстракции принимаются, наследственные связи не вызывают никаких проблем: экземпляру TOKENIZER необходим "доступ к свойствам множества ASCII", а экземпляр JUSTIFIER способен "выполнять последовательные итерации на линейной структуре". Фактически можно было бы классифицировать такие примеры как наследование подтипов. Что отличает льготное наследование, так это природа родителей. Эти классы являются исходными, не использующими наследование. И класс приложения может предпочесть быть их клиентом, а не наследником. Это утяжеляет подход, особенно для класса ASCII:

charset: ASCII ... create charset

При каждом использовании кода символа потребуется задавать целевой объект charset.Lower_a. Присоединяемый объект charset не играет никакой полезной роли. Те же комментарии справедливы и для класса LINEAR_ITERATOR. Но если классу необходимы несколько видов итерации, то тогда создание объектов-итераторов с собственными версиями action и test становится разумным.

Коль скоро мы хотим иметь объекты-итераторы, то нам нужны итераторные классы, и нет никаких причин отказывать им в праве вступления в клуб наследования.



Правило изменений


Первое наблюдение состоит в том, что клиентское отношение обычно допускает изменения, а наследование - нет. Сейчас мы должны с осторожностью обходиться с глаголами "быть" и "иметь", помогающими нам до сих пор характеризовать природу двух отношений между программными модулями. Правила для программ, как всегда, более точные, чем их двойники из обычного мира.

Одним из определяющих свойств наследования является то, что это отношение между классами, а не между объектами. Мы интерпретировали свойство "Класс B наследует от класса A" как "каждый объект B является объектом A". Следует помнить, что это свойство не в силах изменить никакой объект - только класс может достичь такого результата. Свойство характеризует ПО, но не его отдельное выполнение.

Для отношения клиента ограничения слабее. Если объект типа B имеет компонент типа A (либо подобъект, либо ссылку) вполне возможно изменить этот компонент - ограничением служит лишь система типов.

Заданное отношение между объектами может быть результатом как отношения наследования, так и клиентского отношения между классами. Важно различать, допускаются изменения или нет. Например, наша воображаемая структура объекта могла быть результатом отношения наследования между соответствующими классами:


Рис. 6.5.  Объект и подобъект

class SOFTWARE_ENGINEER_1 inherit ENGINEER feature ... end


Она могла быть точно так же получена через отношение клиента:

class SOFTWARE_ENGINEER_2 feature the_engineer_in_me: ENGINEER ... end

Фактически оно могло быть и таким:

class SOFTWARE_ENGINEER_3 feature the_truly_important_part_of_me: VOCATION ... end


Для удовлетворения ограничений системы типов класс ENGINEER должен быть потомком класса VOCATION.

Строго говоря, последние два варианта представляют слегка отличную ситуацию. Если предположить, что ни один из заданных классов не является развернутым, то вместо подобъектов в последних двух случаях объекты "software engineer" будут содержать ссылки на объекты "engineer", как показано на рис.6.4. Введение ссылок, однако, не сказывается на сути нашего обсуждения.

<
p> Поскольку отношение наследования задается между классами, то, приняв первое определение класса, динамически будет невозможно изменить отношение между объектами: инженер всегда останется инженером.

Но для других двух определений модификация возможна: процедура класса "software engineer" может присвоить новое значение полю соответствующего объекта (полю the_engineer_in_me или the_truly_important_part_of_me). В случае класса SOFTWARE_ENGINEER_2 новое значение должно быть типа ENGINEER или совместимого с ним; для класса SOFTWARE_ENGINEER_3 оно может быть любого типа, совместимого с VOCATION (Профессия). Такая программа способна моделировать инженера-программиста, который после многих лет притязаний стать настоящим инженером, наконец, покончил с этой составляющей своей личности и решил стать поэтом или сантехником. ("Не надо оваций. Графа Монте-Кристо из меня не вышло. Придется переквалифицироваться в управдомы".)

Это приводит к нашему первому критерию:

Правило изменений

Не используйте наследование для описания отношения, воспринимаемого как "является", если компоненты соответствующего объекта могут изменять тип в период выполнения.
Используйте наследование только при условии, что отношение между объектами постоянно. В других случаях используйте отношение клиента.

По настоящему интересный случай имеет место для SOFTWARE_ENGINEER_3. Для SOFTWARE_ENGINEER_2 можно заменить инженерный компонент на другой, но того же инженерного типа. Но для SOFTWARE_ENGINEER_3 класс VOCATION может быть более высокого уровня, вероятнее всего, отложенным, так что атрибут может (благодаря полиморфизму) представлять объекты многих возможных типов, согласованных с VOCATION.

Это также означает, что, хотя решение использует клиента как первичное отношение, но на практике в своей окончательной форме оно часто использует дополнительно отношение наследования. Это станет особенно ясно, когда мы придем к понятию описателя (handle).


Правило полиморфизма


Займемся теперь критерием, требующим наследования и исключающим клиента. Этот критерий прост: он основан на полиморфизме. При изучении наследования мы видели, что для объявления в форме:

x: C

x обозначает в период выполнения (предполагая, что класс C не является развернутым) полиморфную ссылку. Другими словами, x может быть присоединен как к прямому экземпляру C, так и к экземпляру потомков C. Это свойство представляет ключевой вклад в мощность и гибкость ОО-метода, особенно из-за следствий - возможности определения полиморфных структур данных, подобных LIST [C], которые могут содержать экземпляры любого из потомков C.

В нашем примере это означает, что, выбрав решение SOFTWARE_ENGINEER_1 - форму, в которой класс является наследником ENGINEER, клиент может объявить сущность:

eng: ENGINEER

Эта сущность в период выполнения может быть присоединена к объекту типа SOFTWARE_ENGINEER_1. Можно иметь список инженеров, базу данных, включающую инженеров-механиков, инженеров-химиков наряду с программистами.

Методологическое напоминание: использование слов, не относящихся к программе, облегчает понимание концепций, но это нужно делать с осторожностью, особенно для антропологических примеров. Объекты нашего интереса являются программными объектами, поэтому, когда мы говорим "a software engineer", то это фактически означает экземпляр класса SOFTWARE_ENGINEER_1.

Такие полиморфные эффекты требуют наследования: в случае SOFTWARE_ENGINEER_2 или SOFTWARE_ENGINEER_3 сущности или структуры данных типа ENGINEER не могут непосредственно означать объекты "software engineer".

Обобщая это наблюдение, характерное не только для этого примера, приходим к правилу, дополняющему правило изменений:

Правило полиморфизма

Наследование подходит для описания отношения, воспринимаемого как "является", если для сущностей может возникнуть потребность присоединения к объектам различных типов.