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

Мейер Бертран

Лекция 15. Множественное наследование

 

 

Примеры множественного наследования

 

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

[x]. потребность в смене имен компонентов, которая может оказаться полезной и при единичном наследовании;

[x]. дублируемое (repeated) наследование, при котором два класса связаны отношением предок-потомок более чем одним способом.

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

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

 

Пример, неподходящий для введения

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

В стандартной формулировке примера речь заходит о классах TEACHER и STUDENT, и вам тут же предлагают отметить тот факт, что отдельные студенты тоже преподают, и советуют ввести класс TEACHING_ASSISTANT, порожденный от TEACHER и STUDENT.

Рис. 15.1.  Пример множественного наследования

Выходит, в этой схеме что-то не так? Не обязательно. Но как начальный пример он весьма неудачен. Все дело в том, что STUDENT и TEACHER - не отдельные абстрактные понятия, а вариации на одну тему UNIVERSITY_PERSON. Поэтому, увидев картину в целом, мы обнаружим пример не просто множественного, но дублируемого (repeated) наследования - схемы, изучаемой позже в этой лекции, в которой класс является правильным наследником другого класса двумя или более различными путями:

Рис. 15.2.  А это пример дублируемого наследования

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

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

 

Может ли самолет быть имуществом?

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

Пусть класс AIRPLANE описывает самолет. Среди запросов к нему могут быть число пассажиров (passenger_count), высота (altitude), положение (position), скорость (speed); среди команд - взлететь (take_off), приземлиться (land), набрать скорость (set_speed).

Независимо от него может иметься класс ASSET, описывающий понятие имущества. К его компонентам можно отнести такие атрибуты и методы, как цена покупки (purchase_price), цена продажи (resale_value), уменьшить в цене (depreciate), перепродать (resell), внести очередной платеж (pay_installment).

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

Для моделирования понятия "самолет компании" прибегнем к множественному наследованию:

Рис. 15.3.  Самолет компании

class COMPANY_PLANE inherit

PLANE

ASSET

feature

... Любой компонент, характерный для самолетов компании,

(отличающийся от наследуемых компонентов родителей) ...

end

Родителей класса достаточно перечислить в предложении inherit. (Как обычно, можно разделять их имена точкой с запятой, хотя это не обязательно.) Порядок перечисления классов не играет никакой роли.

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

[x]. Наручные часы-калькулятор моделируются с применением множественного наследования. Один родитель позволяет устанавливать время и отвечать на такие запросы, как текущее время и текущая дата. Другой - электронный калькулятор - поддерживает арифметические операции.

[x]. Наследником классов судно и грузовик является амфибия (AMPHIBIOUS_VEHICLE). Наследник классов: судно, самолет - гидросамолет (HYDROPLANE). (Как и с TEACHING_ASSISTANT, здесь также возможно дублируемое наследование, поскольку каждый из классов-родителей является потомком средства передвижения VEHICLE.)

[x]. Ужин в ресторане; поездка в вагоне поезда - вагон-ресторан (EATING_CAR). Вариант: спальный вагон (SLEEPING_CAR).

[x]. Диван-кровать (SOFA_BED), на котором можно не только читать, но и спать.

[x]. "Дом на колесах" (MOBILE_HOME) - вид транспорта (VEHICLE) и жилище (HOUSE) одновременно; и так далее.

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

 

Числовые и сравнимые значения

Следующий пример напрямую относится к повседневной практике ОО-разработки и неразрывно связан с построением библиотеки Kernel.

Ряд классов Kernel, потенциально необходимых всем приложениям, требуют поддержки таких операций арифметики, как infix "+", infix "-", infix "*", prefix "-", а также специальных значений zero (единичный элемент группы с операцией "+") и one (единичный элемент группы с операцией "*"). Эти компоненты используют отдельные классы библиотеки Kernel: INTEGER, REAL и DOUBLE. Впрочем, они нужны и другим, заранее не определенным классам, например, классу MATRIX, который описывает матрицы определенного вида. Приведенные абстракции уместно объединить в отложенном классе NUMERIC, являющемся частью библиотеки Kernel:

deferred class NUMERIC feature

... infix "+", infix "-", infix "*", prefix "-", zero, one...

end

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

Многим классам необходимо отношение порядка с операциями сравнения элементов. Такая возможность полезна для классов Kernel, таких как STRING, и для многих других классов. Поэтому в состав библиотеки входит отложенный класс COMPARABLE:

deferred class COMPARABLE feature

... infix "<", infix "<=", infix ">", infix ">="...

end

Математически его экземпляры - это полностью упорядоченные множества с заданным отношением порядком.

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

Объекты некоторых типов, однако, имеют числовую природу и одновременно допускают сравнение. (Такие классы моделируют вполне упорядоченные кольца.) Примеры таких классов - REAL и INTEGER. Целые и действительные числа сравнивают, складывают и умножают. Их описание можно построить на множественном наследовании:

expanded class REAL inherit

NUMERIC

COMPARABLE

feature

...

end

Рис. 15.4.  Структура множественного и единичного наследования

 

Окна - это деревья и прямоугольники

Рассмотрим оконную систему с произвольной глубиной вложения окон:

Рис. 15.5.  Окна и подокна

В соответствующем классе WINDOW мы найдем компоненты двух основных видов:

[x]. те, что рассматривают окно как иерархическую структуру (список подокон, родительское окно, число подокон, добавить, удалить подокно);

[x]. те, что рассматривают окно как графический объект (высота, ширина, отобразить, спрятать, переместить окно).

Этот класс можно написать как единое целое, смешав все компоненты. Однако такой проект будет не самым удачным. Класс WINDOW следует рассматривать как сочетание двух абстракций:

[x]. иерархической структуры, представленной классом TREE;

[x]. прямоугольного экранного объекта, представленного классом RECTANGLE.

На практике класс будет описан так:

class WINDOW inherit

TREE [WINDOW]

RECTANGLE

feature

... Характерные компоненты окна ...

end

Обратите внимание, класс TREE является родовым (generic) классом, а потому требует указания фактического родового параметра, здесь - самого класса WINDOW. Рекурсивная природа определения отражает рекурсию, присущую моделируемой ситуации, - окно является одновременно деревом окон.

Далее, можно подметить, что отдельные окна не содержат ничего, кроме текста. Эту особенность окон можно реализовать вложением, представив класс TEXT_WINDOW как клиента класса STRING, введя атрибут

text: STRING

Предпочтем, однако, вариант, в котором текстовое окно является одновременно строкой. В этом случае используем множественное наследование с родителями WINDOW и STRING. (Если же все наши окна содержат лишь текст, их можно сделать прямыми потомками TREE, RECTANGLE и STRING, однако и здесь решение "в два хода" возможно будет более предпочтительным.)

 

Деревья - это списки и их элементы

Класс дерева TREE - еще один яркий пример множественного наследования.

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

Мы же заметим, что между понятиями дерева и узла нет серьезных различий. Узел можно определить как поддерево, корнем которого он является. В итоге приходим к классу TREE [G], который описывает как узлы, так и деревья. Формальный родовой параметр G отражает тип данных в каждом узле. Следующее дерево, является, например, экземпляром TREE [INTEGER]:

Рис. 15.6.  Дерево целых чисел

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

Рис. 15.7.  Представление списка

Эти понятия позволяют прийти к простому определению дерева: дерево (или его узел) есть список, - список его потомков, но является также потенциальным элементом списка, поскольку может представлять поддерево другого дерева.

Определение: дерево

Дерево - это список и элемент списка одновременно.

Это определение еще потребует доработки, однако, уже сейчас позволяет описать класс:

deferred class TREE [G] inherit

LIST [G]

CELL [G]

feature

...

end

От класса LIST наследуются такие компоненты как количество узлов (count), добавление, удаление узлов и т. д.

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

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

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

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

 

Составные фигуры

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

Рассмотрим структуру, введенную в предыдущей лекции для изучения наследования и содержащую классы графических фигур: FIGURE, OPEN_FIGURE, POLYGON, RECTANGLE, ELLIPSE и т.д. До сих пор в этой структуре использовалось лишь единичное наследование.

Рис. 15.8.  Элементарные фигуры

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

Рис. 15.9.  Составная фигура

Колесо, в свою очередь, может пригодиться при рисовании велосипеда, и т. д.

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

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

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

class COMPOSITE_FIGURE inherit

FIGURE

LINKED_LIST [FIGURE]

feature

...

end

Рис. 15.10.  Составная фигура - это фигура и список фигур одновременно

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

display is

-- Отображает фигуру, последовательно отображая все ее компоненты.

do

from

start

until

after

loop

item.display

forth

end

end

Как и в предыдущих рассмотрениях, мы предполагаем, что класс список предлагает механизм обхода элементов, основанный на понятии курсора. Команда start устанавливает курсор на первый элемент, если он есть (иначе after сразу же равно True ), after указывает, обошел ли курсор все элементы, item дает значение элемента, на который указывает курсор, forth передвигает курсор к следующему элементу.

Я нахожу эту схему прекрасной и, надеюсь, вы тоже пленитесь ее красотой. В ней вы найдете почти весь арсенал средств: классы, множественное наследование, полиморфные структуры данных (LINKED_LIST [FIGURE]), динамическое связывание (вызов item.display применяет метод display того класса, которому принадлежит текущий элемент списка), рекурсию (каждый элемент item сам может быть составной фигурой без ограничения глубины вложенности). Подумать только: есть люди, которые могут прожить всю жизнь и не увидеть этого великолепия!

Но можно пойти еще дальше. Обратимся к другим компонентам COMPOSITE_FIGURE - методам вращения (rotate) и переноса (translate). Они также должны выполнять надлежащие операции над каждым элементом фигуры, и каждый из них может во многом напоминать display. Для ОО-проектировщика это может стать причиной тревоги: хотелось бы избежать повторения; потому выполним преобразование - от инкапсуляции к повторному использованию. (Это могло бы стать девизом.) Техника, рассматриваемая здесь, состоит в использовании отложенного класса "итератор", чьи экземпляры способны выполнять цикл по COMPOSITE_FIGURE. Его эффективным потомком может стать DISPLAY_ ITERATOR, а также ряд других классов. Реализацию этой схемы мы оставляем читателю (см. упражнение 15.4).

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

 

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

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

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

Рис. 15.11.  Брак по расчету

Рассмотрим реализацию стека, заданную массивом. У нас уже есть классы для поддержки стеков и массивов в отдельности (абстрактный STACK и эффективный ARRAY, см. предыдущие лекции). Лучший способ реализации класса ARRAYED_STACK (стек, заданный массивом) - описать его как наследника классов STACK и ARRAY. Это концептуально верно: стек-массив одновременно является стеком (с точки зрения клиента) и массивом (с позиций поставщика). Вот описание класса:

indexing

description: "Стек, реализованный массивом"

class ARRAYED_STACK [G] inherit

STACK [G]

ARRAY [G]

... Здесь будут добавлены предложения переименования ...

feature

...Реализация отложенных подпрограмм класса STACK

в терминах операций класса ARRAY (см. ниже)...

end

ARRAYED_STACK предлагает ту же функциональность, что и STACK, делая эффективными отложенные компоненты: full, put, count ..., реализуя их как операции над массивом.

Вот схема некоторых типичных компонентов: full, count и put. Так, условие, при котором стек полон, имеет вид:

full: BOOLEAN is

-- Является ли стек (его представление) заполненным?

do

Result := (count = capacity)

end

Компонент capacity унаследован от класса ARRAY и задает емкость стека, равную числу элементов массива. Для count потребуется ввести атрибут:

count: INTEGER

Это пример эффективной реализации отложенного компонента как атрибута. Наконец,

put (x: G) is

-- Втолкнуть x на вершину.

require

not full

do

count := count + 1

array_put (x, count)

end

Процедура array_put унаследована от класса ARRAY. Ее цель - записать новое значение в указанный элемент массива.

Компоненты capacity и array_put имели в классе ARRAY имена count и put . Смену прежних имен мы поясним позднее.

Класс ARRAYED_STACK типичен как вариант наследования, образно именуемый "брак по расчету". Оба класса, - абстрактный и эффективный, - дополняя друг друга, создают достойную пару.

Помимо эффективной реализации методов, отложенных (deferred) в классе STACK, класс ARRAYED_STACK способен переопределять реализованные. Компонент change_top, реализованный в STACK в виде последовательности вызовов remove и put, можно переписать более эффективно:

array_put (x, count)

Указание на переопределение компонента следует ввести в предложение наследования:

class ARRAYED_STACK [G] inherit

STACK [G]

redefine change_top end

... Остальное, как прежде ...

Инвариант этого класса может иметь вид

invariant

non_negative_count: count >= 0

bounded: count <= capacity

Первое утверждение выражает свойство АТД. Фактически оно присутствует в родительском классе STACK и потому является избыточным. Здесь оно приводится в педагогических целях. Из окончательной версии класса его нужно изъять. Второе утверждение включает емкость массива - capacity. Это - инвариант реализации.

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

 

Структурное наследование

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

Рассмотрим механизм создания объектов с постоянной структурой (способных сохраняться на долговременных носителях). Поскольку объект является "сохраняемым", то у него должны быть свойства, позволяющие его чтение и запись. В библиотеке Kernel за эти свойства отвечает класс STORABLE, который может быть родителем любого класса. Очевидно, такой класс, помимо STORABLE, должен иметь и других родителей, а значит, схема не сможет работать, не будь множественного наследования. Примером может служить изученное выше наследование с родителями COMPARABLE и NUMERIC. Форма наследования, при которой родитель задает общее структурное свойство, и, чаще всего, имеет имя, заканчивающееся на - ABLE, называется схемой наследования структурного вида.

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

 

Наследование функциональных возможностей

Вот еще одна типичная ситуация. Многие программные инструменты должны сохранять "историю", что позволяет пользователям:

[x]. просмотреть список последних команд;

[x]. вторично выполнить последнюю команду;

[x]. выполнить новую команду, отредактировав для этого предыдущую;

[x]. аннулировать действие последней команды, которая не сумела закончить свою работу.

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

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

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

 

Лунка и кнопка

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

Среда разработки ISE, описанная в курса "Основы объектно-ориентированного проектирования", подобно другим графическим приложениям, содержит "кнопки" для выполнения определенных действий. В среду встроен механизм "выбрать и перетащить" (pick and throw), аналог традиционного механизма буксировки drag-and-drop. С его помощью можно выбрать объект на экране; при этом курсор мыши превращается в "камешек", форма которого указывает тип выбранного объекта. Камешек можно перетащить и опустить в лунку, форма которой соответствует камешку, инициируя тем самым определенное действие. Например, инструментарий Class Tool, позволяющий исследовать свойства класса, имеет "классную лунку", опустив в которую камешек нового класса, вы перенастроите инструмент на показ его свойств.

Рис. 15.12.  Pick and throw (Выбрать и перетащить)

Обратите внимание на нижнюю строку с кнопками форматирования. Нажатие каждой из них позволяет получить разнообразную информацию о классе ARRAY, например краткую форму класса. Как показано на рисунке, пользователь, работая в окне Feature Tool, выбрал щелчком правой кнопки класс INTEGER. Он передвигает его в направлении "лунки" класса в окне Class Tool, настроенного сейчас на ARRAY. Перетаскивание завершается щелчком правой кнопки на "лунке" класса, форма которой соответствует форме камешка. Тем самым Class Tool будет перенастроен на работу с выбранным классом INTEGER.

Иногда удобнее, чтобы "лунка" была одновременно и кнопкой, что позволяет не только "загонять" в нее объект, но независимо от этого щелкать по ней левой кнопкой. Таковой является наша "лунка" класса, точка внутри которой указывает на присутствие в ней объекта (сначала ARRAY, а затем INTEGER). Щелчок по ней левой кнопкой перенастроит инструмент на работу с текущим объектом, что полезно, когда дисплей отражает другую информацию. Такая лунка с кнопкой реализуется специальным классом BUTTONHOLE.

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

 

Оценка

Приведенные примеры наглядно проиллюстрировали мощь и силу механизма множественного наследования. Необходимость его применения подтверждена опытом построения универсальных библиотек [M 1994a].

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

 

Переименование компонентов

 

Иногда при множественном наследовании возникает проблема конфликта имен (name clash). Ее решение - переименование компонентов (feature renaming) - не только снимает саму проблему, но и способствует лучшему пониманию природы классов.

 

Конфликт имен

Каждый класс обладает доступом ко всем компонентам своих родителей. Он может использовать их, не указывая тот класс, в котором они были описаны. После обработки inherit в классе class C inherit A ... метод f класса C становится известен как f. То же справедливо и для клиентов: при объявлении сущности x типа C вызов компонента записывается как x.f без каких-либо ссылок на A. Все метафоры "хромают", иначе можно было бы говорить, что наследование - форма усыновления: C усыновляет все компоненты A.

Усыновление не меняет присвоенных имен, и набор имен компонентов данного класса содержит наборы имен компонентов каждого его родителя.

А если родители класса разные компоненты назвали одним именем? Возникает противоречие, поскольку согласно установленному ранее правилу запрещена перегрузка имен: в классе имя компонента обозначает только один компонент. Это правило не должно нарушаться при наличии родителей класса. Рассмотрим пример:

class SANTA_BARBARA inherit

LONDON

NEW_YORK

feature

...

end-- class SANTA_BARBARA

Что предпринять, если LONDON и NEW_YORK имеют в своем составе компонент с именем, например, foo (нечто)?

Ни при каких обстоятельствах нельзя нарушить запрет перегрузки имен компонентов. Как следствие, класс SANTA_ BARBARA окажется некорректным, что обнаружится при трансляции.

Вспомним класс TREE , порожденный от классов CELL и LIST , каждый из которых имеет компонент с именем item . Кроме того, оба класса имеют метод, названный put . Выбор каждого имени не случаен, и мы не хотим менять их в исходных классах лишь потому, что кому-то пришла идея объединить эти классы в дерево.

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

Всегда ошибочно обвинять в грехах своих родителей. Проблема конфликта имен возникла в самом классе. В нем должно найтись и решение.

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

class SANTA_BARBARA inherit

LONDON

rename foo as fog end

NEW_YORK

feature

...

end

Как внутри SANTA_BARBARA, так и во всех клиентах этого класса компонент LONDON с именем foo будет именоваться fog, а одноименный компонент NEW_YORK - просто foo. Клиенты LONDON, как и прежде, будут знать этот компонент под именем foo.

Этого достаточно для устранения конфликта (если других совпадений нет, а класс LONDON и класс NEW_YORK не содержат компонента с именем fog). В противном случае можно переименовать компонент класса NEW_YORK:

class SANTA_BARBARA inherit

LONDON

rename foo as fog end

NEW_YORK

rename foo as zoo end

feature

...

end

Предложение rename следует за указанием имени родителя и предшествует любым выражениям redefine, если таковые имеются. Можно переименовать и несколько компонентов, как в случае:

class TREE [G] inherit

CELL [G]

rename item as node_item, put as put_right end

где устраняется конфликт между одноименными компонентами CELL и LIST. Компоненту CELL с именем item дается идентификатор node_item, аналогично и put переименовывается в put_right.

 

Результат переименования

Убедимся, что нам понятен результат этого действия. Пусть класс SANTA_BARBARA имеет вид (оба унаследованных компонента foo в нем переименованы):

Рис. 15.13.  Устранение конфликта имен

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

l: LONDON; n: NEW_YORK; s: SANTA_BARBARA

Вызовы l.foo и s.fog будут являться корректными. После полиморфного присваивания l := s все останется корректным, поскольку имена обозначают один и тот же компонент. Аналогично, корректны вызовы n.foo, s.zoo, которые после n := s также будут давать одинаковый результат.

В то же время, следующие вызовы некорректны:

[x]. l.zoo, l.fog, n.zoo, n.fog, так как ни LONDON, ни NEW_YORK не содержат компонентов с именем fog или zoo;

[x]. s.foo, поскольку после смены имен класс SANTA_BARBARA уже не имеет компонента с именем foo.

При всей искусственности имен пример хорошо иллюстрирует природу конфликта имен. Хотите верьте, хотите нет, но приходилось слышать, что конфликт порождает "глубокую семантическую проблему". Это неправда. Конфликт имен - простая синтаксическая проблема. Если бы автор первого класса сменил имя компонента на fog, или автор второго - на zoo, конфликта бы не было, и в каждом случае - это всего лишь замена буквы. Конфликт имен - это обычная неудача, он не вскрывает никаких глубоких проблем, связанных с классами, и не свидетельствует об их неспособности работать совместно. Возвращаясь к метафоре брака, можно сказать, что конфликт имен - это не драма (обнаруженная несовместимость групп крови), а забавный факт (матери обоих супругов носят имя Татьяна, и это вызовет трудности для будущих внуков, которые можно преодолеть, договорившись, как называть обеих бабушек).

 

Смена имен и переопределение

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

[x]. Переопределение меняет компонент, но сохраняет его имя.

[x]. Переименование меняет имя, но сохраняет компонент.

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

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

Иногда то и другое можно совмещать:

class SANTA_BARBARA inherit

LONDON

rename

foo as fog

redefine

fog

end

...

Если, как и раньше, l: LONDON; s: SANTA_BARBARA, и выполнено присваивание l := s, то оба вызова l.foo, s.fog включают переопределенную версию компонента fog, объявление которого должно появиться в предложении feature класса.

Заметьте: redefine содержит уже новое имя компонента. Это нормально, поскольку под этим именем компонент известен классу. Именно поэтому rename должно находиться выше всех остальных предложений наследования (таких, как redefine и пока неизвестные читателю export, undefine, select). После выполнения rename компонент теряет свой прежний идентификатор и становится известным под новым именем классу, его потомкам и его клиентам.

 

Подбор локальных имен

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

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

Хорошим примером является класс WINDOW, порожденный от класса TREE. Последний описывает иерархическую структуру, единую для всех деревьев, в том числе и для окон, но имена, понятные в исходном контексте, могут не подходить для интерфейса между WINDOW и его клиентами. Смена имен дает возможность привести их в соответствие с местными обычаями:

class WINDOW inherit

TREE [WINDOW]

rename

child as subwindow, is_leaf as is_terminal, root as screen,

arity as child_count, ...

end

RECTANGLE

feature

... Характерные компоненты window ...

end

Аналогично, класс TREE, который сам порожден от CELL, может сменить имя right на right_sibling и т.д. Путем смены имен класс может создать удобный набор наименований своих "служб" вне зависимости от истории их создания.

 

Играем в имена

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

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

 

Использование родительской процедуры создания

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

make (minb, maxb: INTEGER) is

-- создать массив с границами minb и maxb

-- (пустой если minb > maxb)

do ... end

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

class ARRAYED_STACK [G] inherit

STACK [G]

redefine change_top end

ARRAY [G]

rename

count as capacity, put as array_put, make as array_make

end

creation

make

feature -- Initialization

make (n: INTEGER) is

-- Создать стек, допускающий размещение n элементов.

require

non_negative_size: n >= 0

do

array_make (1, n)

ensure

capacity_set: capacity = n

empty: count = 0

end

... Другие компоненты ...

invariant

count >= 0; count <= capacity

end

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

 

Плоские структуры

 

Смена имен - лишь одно из средств, используемых мастером наследования для построения полноценных классов, удовлетворяющих потребностям своих клиентов. Другим таким средством является переопределение. В этой и следующей лекции мы увидим еще несколько таких механизмов: отмену определений (undefinition), соединение (join), выделение (select), скрытие потомков (descendant hiding). Мощь этих комбинируемых механизмов делает наследование излишне заметным, поэтому иногда возникает необходимость в существовании версии класса, свободной от наследования, - плоской форме (flat form).

 

Плоская форма класса

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

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

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

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

[x]. устранение предложения inherit, если оно есть;

[x]. сохранение в неизменном виде всех определений и переопределений из C;

[x]. введение в класс объявлений всех унаследованных компонентов, скопированных из соответствующих классов-родителей, с учетом всех указанных в inherit преобразований: переименования, переопределения, отмены определений, выделения (select), объединения компонентов;

[x]. добавление к каждому унаследованному компоненту строки комментария вида: from ANCESTOR, где указано имя ближайшего предка, (пере)определившего компонент (а в случае объединения компонентов - победившая сторона);

[x]. восстановление полной формы предусловий и постусловий унаследованных методов (по правилам наследования утверждений, изложенным в следующей лекции);

[x]. восстановление полного инварианта класса как конъюнкции (and) всех родительских инвариантов с последующим преобразованием в случае применения переименованных или выделенных компонентов.

Полученный в результате класс содержит все компоненты оригинала, как введенные в самом классе, так и полученные им от предков (вторая категория компонентов от первой отличается лишь комментарием). В случае наличия меток в секциях объявления компонентов, например, feature - Access, подобные метки остаются. Секции с одинаковыми метками объединяются. В каждой секции компоненты выстраиваются по алфавиту.

На рисунке показана часть плоской формы класса LINKED_TREE из библиотеки Base. Результат получен с применением Class Tool в среде разработки ISE. Для повторения результата настройте Class Tool на LINKED_TREE и щелкните по кнопке формата Flat.

Рис. 15.14.  Отображение плоской формы

 

Применение плоской формы

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

Кроме того, она может оказаться полезной при построении автономной версии класса, не обремененной историей порождения. Потеря полиморфизма снижает ценность такого класса.

 

Краткая плоская форма

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

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

Объединение двух понятий дает новое понятие краткой плоской формы (flat-short form). Как и краткая форма класса, она содержит лишь общедоступную информацию, в ней не указаны скрытые компоненты, а для экспортируемых компонентов не приводится реализация, в частности, предложения do. Как и плоская форма, краткая плоская форма задает все компоненты класса - и унаследованные, и описанные в нем самом.

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

 

Дублируемое наследование

 

Дядюшка Жак: С кем желаете Вы говорить, сударь, с конюхом или с поваром? Ибо я у Вас и то, и другое.

Мольер, "Скупой"

Дублируемое наследование (repeated inheritance) возникает, когда класс является потомком другого класса более чем на одном пути наследования. При этом возникает потенциальная неоднозначность, которую и следует разрешить.

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

 

Общие предки

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

Рис. 15.15.  Дублируемое наследование

Если B и C наследники потомков A, (случай 1), то такое наследование именуется косвенным. Если A, B и C - это один класс (случай 2), - наследование именуется прямым, что может быть записано в виде:

class D inherit

A

A

...

feature

...

end

 

По обе стороны океана

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

age: INTEGER

address: STRING

violation_count: INTEGER -- Число записанных нарушений

и методы:

pass_birthday is do age := age + 1 end

pay_fee is

-- Оплата ежегодной лицензии.

do ... end

Класс наследник, US_DRIVER учитывает налоговое законодательство США, другой, FRENCH_DRIVER, - налоговое законодательство Франции.

Рассмотрим категорию людей, которым в течение года приходится водить машину в обеих странах. Нужного класса у нас еще нет, и простым решением этой проблемы кажется множественное наследование. Опишем класс FRENCH_US_DRIVER как порожденный от US_DRIVER и FRENCH_DRIVER. Налицо дублируемое наследование.

Рис. 15.16.  Типы водителей

 

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

Из приведенного примера вытекает основная проблема дублируемого наследования: каков смысл компонентов дублируемого потомка (FRENCH_US_DRIVER), полученных от дублируемого предка (DRIVER)?

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

Внимательно перечитайте правило о конфликте имен:

Класс, наследующий от разных родителей различные компоненты с идентичным именем, некорректен.

Компоненты age (также как и pass_birthday), наследованные классом FRENCH_US_DRIVER от обоих родителей, не являются "различными", поэтому реального конфликта не возникает. Заметьте, неоднозначность могла бы возникнуть лишь в случае переопределения компонента в одном из классов. Чуть позже мы покажем, как справиться с этой проблемой, а пока предположим, что переопределений не происходит.

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

Всегда ли применяется совместное использование? Нет. Рассмотрим компоненты address, pay_fee, violation_count. Обращаясь в службу регистрации автотранспорта в разных странах, водители скорее всего будут указывать разные адреса и по-разному платить ежегодные сборы. Впрочем, и нарушения правил тоже будут различны. Каждый из таких компонентов, следует представить в дублируемом потомке двумя разными компонентами. Данный случай будем называть репликацией (replication).

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

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

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

Правило дублируемого наследования

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

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

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

Правило придает желаемую гибкость процессу объединения классов. Вот как может выглядеть класс FRENCH_US_DRIVER:

class FRENCH_US_DRIVER inherit

FRENCH_DRIVER

rename

address as french_address,

violation_count as french_violation_count,

pay_fee as pay_french_fee

end

US_DRIVER

rename

address as us_address,

violation_count as us_violation_count,

pay_fee as pay_us_fee

end

feature

...

end

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

Компоненты age и pass_birthday переименованы не были, а потому, как мы и хотели, они используются совместно.

Реплицируемый атрибут, скажем, address, в каждом экземпляре класса FRENCH_US_ DRIVER будет представлен несколькими полями данных. Тогда при условии, что эти классы содержат только указанные нами компоненты, их экземпляры будут выглядеть как на .

Рис. 15.17.  Совместное использование и репликация

Рис. 15.18.  Репликация атрибутов

(Организация FRENCH_DRIVER и US_DRIVER аналогична организации DRIVER, см. рисунок.)

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

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

[x]. нулевые затраты на поддержку универсальности;

[x]. низкие, ограниченные константой, затраты на динамическое связывание (не зависящие от наличия в системе дублируемого наследования классов).

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

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

 

Ненавязчивое дублирующее наследование

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

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

Рис. 15.19.  Избыточное наследование

На рисунке показана типичная ошибка начинающих (или рассеянных разработчиков): класс D объявлен наследником B, ему нужны также свойства класса A, но B сам является потомком A. Забыв о транзитивности наследования, разработчик пишет:

class D ... inherit

B

A

...

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

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

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

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

 

Правило переименования

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

Начнем с запрета возникновения конфликта имен:

Определение: финальное имя

Финальным именем компонента класса является:

[x]. Для непосредственного компонента (объявленного в самом классе) - имя, под которым он объявлен.

[x]. Для наследуемого компонента без переименования - финальное имя компонента (рекурсивно) в том родительском классе, от которого оно унаследовано.

[x]. Для переименованного компонента - имя, полученное при переименовании.

Правило одного имени

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

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

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

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

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

Рис. 15.20.  Два варианта наследования

class A feature

this_one_OK: INTEGER

end

class B inherit A feature

portends_trouble: REAL

end

class C inherit A feature

portends_trouble: CHARACTER

end

class D inherit

-- Это неправильный вариант!

B

C

end

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

Два компонента portend_trouble ("предвещающие беду") заслуженно получили такое имя. Они различны, потому их появление в D ведет к конфликту имен, делая класс некорректным. (У них разные типы, но и одинаковые типы никак не повлияли бы на ход нашего обсуждения.)

Переименовав один из компонентов, мы с легкостью сделаем D корректным:

class D inherit

-- Этот вариант класса теперь полностью корректен.

B

rename portends_trouble as does_not_portend_trouble_any_more end

C

end

 

Конфликт переопределений

Пока в ходе наследования мы меняли лишь имена. А что, если промежуточный предок, такой, как B или C (см. последний рисунок), переопределит дублируемо наследуемый компонент? При динамическом связывании это может привести к неоднозначности в D.

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

Пусть дублируемо наследуемый компонент переопределяется в одной из ветвей:

Рис. 15.21.  Переопределение - причина потенциальной неоднозначности

Класс B переопределяет f. Поэтому в D этот компонент представлен в двух вариантах: результат переопределения в B и исходный вариант из A, полученный через класс C. (Можно предполагать, что и C переопределяет f, но это не внесет в наше рассуждение ничего нового.) Такое положение дел отличается от предыдущих случаев, в которых мы имели лишь один вариант компонента, возможно, наследуемый под разными именами.

Что произойдет в результате? Ответ зависит от того, под одним или разными именами класс D наследует варианты компонентов. Подразумевает ли дублируемое наследование репликацию или совместное использование? Рассмотрим эти случаи по порядку.

 

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

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

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

2 Каждая версия эффективна, однако обе они переопределяются в D в предложении redefine. Проблемы снова не возникает, поскольку обе версии сливаются в одну, переопределяемую в тексте класса.

3 Обе версии эффективны, но обе не переопределяются, тогда действительно возникает конфликт имен. Класс D будет отвергнут, как нарушающий правило одного имени.

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

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

Правила переопределения дают возможность переопределить компонент f как отложенный, хотя для этого и потребуется ввести промежуточный класс, скажем C', - наследника C, единственная роль которого - в переопределении отложенного f . Затем класс D должен быть порожден не от C, а от C'. Сложно и некрасиво. Вместо этого нам нужен простой языковой механизм: undefine. В секции наследования класса он приводит к появлению нового предложения:

class D inherit

B

C

undefine f end

feature

...

end

Синтаксически предложение undefine следует за rename (всякая отмена определения должна действовать на окончательный вариант имени компонента), но до redefine (прежде, чем что-то переопределять, мы должны позаботиться об отмене ненужных определений).

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

Рис. 15.22.  Два родителя и слияние компонентов

Мы хотим, чтобы D трактовал f и g как один компонент. Очевидно, это возможно лишь при условии совместимости семантики и сигнатур обоих компонентов (числа и типов аргументов и результата, если он есть). Допустим, что имена компонентов различны, и мы хотели бы сохранить имя f. Добиться желаемого можно, объединив переименование с отменой определения:

class D inherit

B

C

rename

g as f

undefine

f

end

feature

...

end

B получил полное превосходство над C, передавая классу D как сам компонент, так и его имя. Возможны и другие сочетания: компонент можно получить от одного из родителей, имя - от другого; можно переименовать оба компонента, присвоив им новое имя в D.

Еще один, более "симметричный" вариант соединения компонентов, заключается в замене обоих унаследованных вариантов на новый компонент. Достаточно указать оба компонента в предложении redefine, убедившись предварительно, что оба компонента имеют одно и то же финальное имя (добавив, если надо, выражение rename). В результате конфликта имен не возникнет (случай (2)), а объединение двух вариантов даст новый компонент.

 

Конфликты при репликации: выделение

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

Рис. 15.23.  Необходимость выделения

Представленный на рисунке класс B меняет имя f на bf и переопределяет сам компонент. При этом мы опять полагаем, что C никак не меняет f, иное предположение нисколько не повлияет на ход нашего рассуждения. Более того, результат остался бы прежним, если бы B переопределял компонент f без его переименования, которое мы могли отложить до описания D. Допустим также, что речь не идет о соединении компонентов (которое происходит при переопределении обоих или отмене определения одного).

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

В отличие от случая совместного использования не возникает конфликта имен. Однако возникают другие конфликты, относящиеся к динамическому связыванию. Пусть полиморфная сущность a1 типа A (общий предок) на этапе выполнения связывается с экземпляром типа D (общим потомком). Что тогда означает вызов a1.f?

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

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

Для устранения неоднозначности необходим простой языковой механизм - предложение select. Вот версия класса, в которой предпочтение при динамическом связывании сущности f типа A отдается версии класса C:

class D inherit

B

C

select f end

feature

...

end

В этом варианте предпочтение отдается версии класса B:

class D inherit

B

select bf end

C

feature

...

end

Синтаксически предложение select следует за предложениями rename, undefine и redefine, если таковые имеются (выбор осуществляется после переименования и переопределения). Применение этого механизма регламентирует следующее правило:

Правило выделения

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

Механизм select устраняет неоднозначность раз и навсегда. Потомкам класса нет необходимости (и они не должны) повторять выделение.

 

Выделение всех компонентов

Любой конфликт переопределений должен быть разрешен посредством select. Если, объединяя два класса, вы натолкнулись на ряд конфликтов, возможно, вы захотите, чтобы один из классов "одержал верх" (почти) в каждом из них. В частности, так происходит в ситуации, метафорично названной "брак по расчету" (вспомните, ARRAYED_STACK - потомок STACK и ARRAY), в которой классы-родители имеют общего предка. (В библиотеках Base оба класса действительно являются удаленными (distant) потомками общего класса CONTAINER.) В этом случае один из родителей (STACK) служит источником спецификаций, и вам, быть может, захочется, чтобы (почти) все конфликты были разрешены именно в его пользу.

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

SOME_PARENT

select all end

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

 

Сохранение исходной версии при переопределении

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

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

Вернемся к известному нам классу BUTTON - потомку WINDOW, переопределяющему display:

display is

-- Показ кнопки на экране.

do

window_display

special_button_actions

end

где window_display выводит кнопку как обычное окно, а special_button_actions добавляет элементы, специфические для кнопки, отображая, например, ее границы. Компонент window_display в точности совпадает с WINDOW-вариантом display.

Мы уже знаем, как написать window_display, используя механизм Precursor. Если метод display переопределен в нескольких родительских классах, то желаемый класс можно указать в фигурных скобках: Precursor {WINDOW}. Того же результата можно достичь, прибегнув к дублируемому наследованию, заставив класс Button быть потомком двух классов Window:

indexing

WARNING: "Это первая попытка - данная версия некорректна!"

class BUTTON inherit

WINDOW

redefine display end

WINDOW

rename display as window_display end

feature

...

end

Одна из ветвей наследования меняет имя display, а потому, по правилу дублируемого наследования BUTTON, будет иметь два варианта компонента. Один из них переопределен, но имеет прежнее имя; второй переопределен не был, но именуется теперь window_display.

Этот вариант кода почти корректен, однако в нем не хватает подвыражения select. Если, как это обычно бывает, мы хотим выбрать переопределенную версию, то запишем:

indexing

note: "Это (корректная!)схема дублируемого наследования,%

% использующая оригинальную версию переопределяемого компонента"

class BUTTON inherit

WINDOW

redefine

display

select

display

end

WINDOW

rename

display as window_display

export

{NONE} window_display

end

feature

...

end

Если такая схема должна применяться к целому ряду компонентов, их можно перечислить вместе. При этом нередко возникает необходимость разрешить все конфликты именно в пользу переопределенных компонентов. В этом случае можно воспользоваться select all.

Предложение export (см. лекцию 16 ) определяет статус экспорта наследуемых компонентов класса. Так, WINDOW может экспортировать компонент display , а BUTTON сделать window_display скрытым (поскольку его клиенты в нем не нуждаются). Экспорт исходной версии наследуемого компонента может сделать класс формально некорректным, если она не соответствует новому инварианту класса.

Для скрытия всех компонентов, полученных "в наследство" по одной из ветвей иерархии, служит запись export {NONE} all.

Такой вариант экспорта переопределенных компонентов и скрытия исходных компонентов под новыми именами весьма распространен, но отнюдь не универсален. Нередко классу наследнику необходимо скрывать или экспортировать оба варианта (если исходная версия не нарушает инвариант класса).

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

 

Пример повышенной сложности

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

Проблема, близкая по духу нашему примеру, возникла из интересного обсуждения в основной книге по C++ [Stroustrup 1991].

Рассмотрим класс WINDOW с процедурой display и двумя наследниками: WINDOW_WITH_BORDER и WINDOW_WITH_MENU. Эти классы описывают абстрактные окна, первое из них имеет рамку, а второе поддерживает меню. Переопределяя display, каждый класс выводит на экран стандартное окно, а затем добавляет к нему рамку (в первом случае) и меню (во втором).

Опишем окно с рамкой и с поддержкой меню. В результате мы породим класс WINDOW_WITH_BORDER_AND_MENU.

Рис. 15.24.  Варианты окна

Переопределим метод display в новом классе; новая версия вначале вызывает исходную, затем строит рамку, а потом строит меню. Исходный класс WINDOW имеет вид:

class WINDOW feature

display is

-- Отобразить окно (общий алгоритм)

do

...

end

... Другие компоненты ...

end

Наследник WINDOW_WITH_BORDER осуществляет вызов родительской версии display и затем отображает рамку. В дублируемом наследовании нет необходимости, достаточно воспользоваться механизмом Precursor:

class WINDOW_WITH_BORDER inherit

WINDOW

redefine display end

feature -- Output

display is

-- Рисует окно и его рамку.

do

Precursor

draw_border

end

feature {NONE} -- Implementation

draw_border is do ... end

...

end

Обратите внимание на процедуру draw_border, рисующую рамку окна. Она скрыта от клиентов класса WINDOW_WITH_BORDER (экспорт классу NONE), поскольку для них вызов draw_border не имеет смысла. Класс WINDOW_WITH_MENU аналогичен:

class WINDOW_WITH_MENU inherit

WINDOW

redefine display end

feature -- Output

display is

-- Рисует окно и его меню.

do

Precursor

draw_menu

end

feature {NONE} -- Implementation

draw_menu is do ... end

...

end

Осталось описать общего наследника WINDOW_WITH_BORDER_AND_MENU этих двух классов, дублируемого потомка WINDOW. Предпримем первую попытку:

indexing

WARNING: "Первая попытка - версия не будет работать корректно!"

class WINDOW_WITH_BORDER_AND_MENU inherit

WINDOW_WITH_BORDER

redefine display end

WINDOW_WITH_MENU

redefine display end

feature

display is

-- Рисует окно,его рамку и меню.

do

Precursor {WINDOW_WITH_BORDER}

Precursor {WINDOW_WITH_MENU}

end

...

end

Заметьте: при каждом обращении к Precursor мы вынуждены называть имя предка. Каждый предок имеет собственный компонент display, переопределенный под тем же именем.

Впрочем, как замечает Страуструп, это решение некорректно: версии родителей дважды вызывают исходную версию display класса WINDOW, что приведет к появлению "мусора" на экране. Для исправления ситуации добавим еще один класс, получив тройку наследников класса WINDOW:

indexing

note: "Это корректная версия"

class WINDOW_WITH_BORDER_AND_MENU inherit

WINDOW_WITH_BORDER

redefine

display

export {NONE}

draw_border

end

WINDOW_WITH_MENU

redefine

display

export {NONE}

draw_menu

end

WINDOW

redefine display end

feature

display is

-- Рисует окно,его рамку и меню.

do

Precursor {WINDOW}

draw_border

draw_menu

end

...

end

Заметьте, что компоненты draw_border и draw_menu в новом классе являются скрытыми, поскольку мы не видим причин, по которым клиенты WINDOW_WITH_BORDER_AND_MENU могли бы их вызывать непосредственно.

Несмотря на активное применение дублируемого наследования, класс переопределяет все унаследованные им варианты display, что делает выражения select ненужными. В этом состоит преимущество спецификатора Precursor в сравнении с репликацией компонентов.

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

В полученном варианте класса присутствует лишь совместное использование, но не репликация компонентов. Расширим пример Страуструпа: пусть WINDOW имеет запрос id (возможно, целого типа), направленный на идентификацию окон. Если идентифицировать любое окно только одним "номером", то id будет использоваться совместно, и нам не придется ничего менять. Если же мы хотим проследить историю окна, то экземпляр WINDOW_WITH_BORDER_AND_MENU будет иметь три id - независимых "номера". Новый текст класса комбинирует совместное использование и репликацию id (изменения в тексте класса помечены стрелками):

indexing

note: "Усложненная версия с независимыми id."

class WINDOW_WITH_BORDER_AND_MENU inherit

WINDOW_WITH_BORDER

rename

id as border_id

redefine

display

export {NONE}

draw_border

end

WINDOW_WITH_MENU

rename

id as menu_id

redefine

display

export {NONE}

draw_menu

end

WINDOW

rename

id as window_id

redefine

display

select

window_id

end

feature

.... Остальное, как ранее...

end

Обратите внимание на необходимость выбора (select) одного из вариантов id.

 

Дублируемое наследование и универсальность

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

class A [G] feature

f: G;...

end

class B inherit

A [INTEGER]

A [REAL]

end

В классе B по правилу дублируемого наследования компонент f должен использоваться совместно. Но из-за универсализации возникает неоднозначность, - какой результат должен возвращать компонент - real или integer? Та же проблема возникнет, если f имеет параметр типа G.

Подобная неоднозначность недопустима. Отсюда правило:

Универсальность в правиле дублируемого наследования

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

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

 

Правила об именах

(В этом разделе мы только формализуем сказанное выше, поэтому при первом чтении книги его можно пропустить.)

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

Конфликты имен: определение и правило

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

Конфликт имен делает класс некорректным за исключением следующих случаев:

1 Оба компонента унаследованы от общего предка, и ни один из них не получен повторным объявлением версии предка.

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

3 Оба компонента имеют совместимые сигнатуры и переопределяются в новом классе.

Ситуация (1) описывает совместное использование при дублируемом наследовании.

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

Ситуации (2) и (3) рассматриваются отдельно, однако, их можно представить как один вариант - вариант соединения (join). Переходя к n компонентам (n >= 2), можно сказать, что ситуации (2) и (3) возникают, когда от разных родителей класс принимает n одноименных компонентов с совместимыми сигнатурами. Конфликт имен не делает класс некорректным, если эти компоненты могут быть соединены, иными словами:

[x]. все n компонентов отложены, так что некому вызвать конфликт определений;

[x]. существует единственный эффективный компонент. Его реализация станет реализацией остальных компонентов;

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

И, наконец, точное правило употребления конструкции Precursor. Если в переопределении используется Precursor, то неоднозначность может возникнуть из-за того, что неясно, версию какого родителя следует вызывать. Чтобы решить эту проблему, следует использовать вызов вида Precursor {PARENT} (...), где PARENT - имя желаемого родителя. В остальных случаях указывать имя родителя не обязательно.

 

Обсуждение

 

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

 

Переименование

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

[x]. требовать от клиентов устранения всех неоднозначностей;

[x]. выбирать некую интерпретацию по умолчанию.

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

x: C

... x.f ...

Клиенту придется квалифицировать ссылку на f, используя нотацию, например, такую: x.f | A, либо x.f | B, чтобы указать подразумеваемый класс.

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

Согласно второй стратегии, запись x.f корректна. Выбор одного из вариантов делается средствами языка. Критерием выбора является, например, порядок, в котором C перечисляет своих родителей. Для обращения к другим вариантам может существовать особая форма записи.

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

Эти проблемы решает смена имен. Одним из ее преимуществ является возможность создания клиентского интерфейса с "понятными" именами компонентов.

 

ОО-разработка и перегрузка

Анализ роли имен, сделанный в этой лекции, позволяет вернуться к вопросу о внутриклассовой перегрузке (in-class name overloading).

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

infix "+" (a, b: VECTOR) is...

infix "+" (a, b: MATRIX) is...

Языки Java и C++ позволяют делать то же самое в пределах класса.

Ранее мы называли эту возможность синтаксической перегрузкой. Это - статический механизм. Для однозначного разрешения вызова, например, x + y, достаточно посмотреть на тип аргументов x и y, который очевиден из текста программы.

В объектной технологии применяется и более мощный механизм семантической (или динамической) перегрузки. Так, если классы VECTOR и MATRIX наследуют от общего предка NUMERIC компонент

infix "+" (a: T) is...

и каждый из них переопределяет его нужным образом, то понять, о какой операции + идет речь в выражении x + y, можно только динамически во время выполнения программы. Семантическая перегрузка - действительно интересный механизм, позволяющий использовать единое имя в тексте различных классов для представления разных вариантов по сути одной и той же операции, такой, как сложение в NUMERIC. Правила для утверждений, рассматриваемые в следующей лекции, уточнят эту ситуацию, требуя, чтобы переобъявления компонента сохраняли его фундаментальную семантику.

Сохраняется ли роль синтаксической перегрузки в объектной технологии? Трудно найти разумные аргументы в ее поддержку. Можно понять, почему язык Ada 83, не имеющий классов, ее использовал. Но в ОО-языке выбор одного имени для обозначения разных операций - это прямой путь к созданию беспорядка.

Проблема состоит еще и в том, что синтаксическая форма перегрузки вступает в конфликт с семантической, в активе которой - полиморфизм и динамическое связывание. Рассмотрим вызов x.f (a). Если он следует за полиморфными операторами присваивания x := y и a := b, то при сохранении имен его результат будет в точности тем же, что и для y.f (b), даже если типы b и y отличны от типов a и x. Но при перегрузке это свойство не сохраняется! Теперь f может быть перегруженным именем двух разных компонентов: одного - типа a, другого - типа b. Чему отдать предпочтение: синтаксической перегрузке или динамическому связыванию? Хуже того, базовый класс типа y может переопределять один или оба перегруженных компонента. И таким комбинациям, как и причинам ошибок, нет числа.

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

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

Типичный пример, иногда приводимый в подтверждение полезности перегрузки, связан с компонентами класса STRING. Чтобы к одной строке, при отсутствии перегрузки, добавить другую строку или отдельный символ, используются разные имена компонентов: s1.add_string (s2) и s1.add_character ('A'), или в инфиксной записи s := s1++ s2 и s := s1 + 'A'. При перегрузке обе операции можно назвать одинаково. Так ли это необходимо? Объекты типов CHARACTER и STRING наделены совершенно разными свойствами. Добавление символа всегда увеличивает длину строки на 1. Сцепление строк может оставить длину неизменной (если вторая строка пуста) или увеличить ее произвольным образом. Применение разных имен кажется не только разумным, но и желательным, особенно потому, что приведенные выше примеры ошибок действительно вполне возможны.

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

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

В итоге (внутриклассовая) синтаксическая перегрузка в ОО-среде создает немало проблем, не давая видимых преимуществ. (Тем же, кто использует Java, C++ или Ada 95, можно посоветовать полностью отказаться от перегрузки, прибегая к ней лишь при создании конструкторов, то есть тогда, когда язык не оставляет другого выбора.) Стараясь умело применять объектный подход, придерживайтесь простого правила: каждый компонент имеет имя, каждое имя означает только один компонент.

 

Ключевые концепции

[x]. Подход к конструированию ПО, подобный конструированию из кубиков, требует возможности объединения нескольких абстракций в одну. Это достигается благодаря множественному наследованию.

[x]. В самых простых и наиболее общих случаях множественного наследования два родителя представляют независимые абстракции.

[x]. Множественное наследование часто необходимо как для моделирования систем, так и для повседневной разработки ПО, в частности, создания повторно используемых библиотек.

[x]. Конфликты имен при множественном наследовании должны устраняться переименованием.

[x]. Переименование позволяет ввести в классе контекстно-адаптированную терминологию.

[x]. Компоненты следует отделять от их имен. Один и тот же компонент в разных классах может быть известен под разными именами. Класс определяет отображение имен в компоненты.

[x]. Дублируемое наследование - мощная техника - возникает как результат множественного наследования, при котором один класс становится потомком другого несколькими способами.

[x]. При дублируемом наследовании компонент общего предка становится одним компонентом, если он наследуется под одним именем, и несколькими независимыми компонентами в противном случае.

[x]. Конкурирующие версии общего предка при динамическом связывании должна устраняться предложением select.

[x]. Механизм репликации при дублируемом наследовании не должен дублировать компоненты, включающие родовые параметры.

[x]. В ОО-среде семантическая перегрузка, поддерживаемая динамическим связыванием, более полезна, чем синтаксическая перегрузка.

 

Библиографические замечания

Механизм переименования, а также правила дублируемого наследования были разработаны при написании этой книги. Механизм отмены определений предложен Михаэлем Швайцером (Michael Schweitzer), механизм выбора- Джоном Поттером (John Potter).

Пример с выпадающим меню взят из книги [M 1988c].

 

Упражнения

 

У15.1 Окна как деревья

Класс WINDOW порожден от TREE [WINDOW]. Поясните суть родового параметра. Покажите, какое новое утверждение появится в связи с этим в инварианте класса.

 

У15.2 Является ли окно строкой?

Окно содержит ассоциированный с ним текст, представленный атрибутом text типа STRING. Стоит ли отказаться от атрибута и объявить WINDOW наследником класса STRING?

 

У15.3 Завершение строительства

Завершите проектирование класса WINDOW, показав точно, что необходимо от лежащего в основе механизма управления выводом?

 

У15.4 Итераторы фигур

При обсуждении COMPOSITE_FIGURE мы говорили о применении итераторов для выполнения операций над составными фигурами. Разработайте соответтсвующие классы итераторов. (Подсказка: в [M 1994a] приведены классы библиотеки итераторов, которые послужат основой вашей работы.)

 

У15.5 Связанные стеки

Основываясь на классах STACK и LINKED_LIST, постройте класс LINKED_STACK, описывающий реализацию стека как связного списка.

 

У15.6 Кольцевые списки и цепи

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

 

У15.7 Деревья

Согласно одной из интерпретаций, дерево - это рекурсивная структура, представляющая собой список деревьев. Замените приведенное в этой лекции описание класса TREE как наследника LINKED_LIST и LINKABLE новым вариантом

class TREE [G] inherit

LIST [TREE [G]]

feature ...end

Расширьте это описание до полнофункционального класса. Сравните это расширение с тем, что было описано в тексте данной лекции.

 

У15.8 Каскадные или "шагающие" (walking) меню

Оконные системы вводят понятие меню, реализуемое классом MENU с запросом, возвращающим список элементов, и командами отображения, перехода к следующему элементу и т.д. Меню составлено из элементов, поэтому нам понадобится класс MENU_ENTRY с такими запросами, как parent_menu и operation (операция, выполняемая при выборе элемента) и такими командами, как execute (выполняет операцию operation).

Среди меню нередко встречаются каскадные, или шагающие меню (walking menu), где выбор элемента приводит к появлению подменю (submenu). На рисунке приведено шагающее меню среды Open Windows, созданной корпорацией Sun:

Рис. 15.25.  Выпадающее меню

Предложите описание класса SUBMENU. (Подсказка: подменю одновременно является меню и элементом меню, чья операция должна отображать подменю.) Можно ли это понятие с легкостью описать в языке без множественного наследования?

 

У15.9 Плоский precursor (предшественник)

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

 

У15.10 Дублируемое наследование и репликация

Напишите класс WINDOW_WITH_BORDER_AND_MENU без обращения к Precursor. Для доступа к родительскому варианту переопределенного компонента используйте репликацию при дублируемом наследовании. Убедитесь в том, что вы используете правильные предложения select и назначаете каждому компоненту правильный статус экспорта.