Выше во время наших рассуждений мы предполагали, что класс наследует только от одного родительского класса. Хотя эта ситуация является, безусловно, типичной, тем не менее бывают случаи, когда некоторая абстракция логически вытекает из двух (или более) независимых источников. Если вы представляете себе классы как аналоги категорий, как мы делали в главе═1, и пытаетесь описать себя в терминах групп, к которым принадлежите, то весьма вероятно, что вы построите много непересекающихся классификаций. Например, я═≈ отец ребенка, профессор, гражданин США. Ни одна из этих категорий не является собственным подмножеством другой.
Еще пример. Бет занимается художественной лепкой. Ее мы относим к классу Potter. Ее соседка Маргарет рисует портреты, она═≈ PortraitPainter. Тот тип живописи, которым она занимается, отличен от ремесла Пола: он═≈ маляр (HousePainter). Обычно мы рассматриваем однонаправленное наследование как способ специализации (в данном примере Potter═≈ это частный случай художника Artist). Однако множественное наследование следует рассматривать как комбинирование (портретист PortraitPainter═≈ это творческий человек Artist и художник Painter).
Мы проиллюстрируем трудности, возникающие при одиночном наследовании, на более конкретном примере. В языке Smalltalk класс Magnitude определяет некий протокол для объектов с определенной мерой: они могут сравниваться друг с другом по величине 12 . Например, отдельные символы (экземпляры класса Char) сравниваются по своей внутренней кодировке (скажем, ASCII). Более традиционный класс сравнимых объектов═≈ числа, то есть экземпляры класса Number в терминах Smalltalk. Помимо сравнения, экземпляры класса Number поддерживают выполнение арифметических операций (сложение, умножение и═т.═д.). Эти операции не имеют смысла для объектов класса Char. В Smalltalk имеется несколько типов чисел: целые (Integer), дробные (Fraction), вещественные (Float).
13.1. Комплексные числа
|
Предположим теперь, что мы добавляем класс Complex, который представляет собой абстракцию комплексного числа. Арифметические операции, несомненно, определены для комплексных чисел. Разумно сделать Complex подклассом класса Number, так что арифметика наследуется и переопределяется. Проблема состоит в том, что сравнение двух комплексных чисел═≈ это нечто двусмысленное. Комплексные числа просто не сравнимы между собой.
Итак, мы имеем следующие ограничения:
Невозможно удовлетворить всем этим условиям с помощью иерархии одиночного наследования. Имеется несколько альтернативных решений данной проблемы:
Рис.═13.3. Иерархия множественного наследования для комплексных чисел
Важный момент в альтернативах═2 и═3═≈ это то, что они в гораздо большей степени привлекательны для языков программирования с динамическими типами данных (Objective-C, Smalltalk). В языках C++ или Object Pascal определение того, какие именно типы являются ╚измеримыми╩ или ╚сравнимыми╩, выражается в терминах классов. А именно объект ╚измерим╩, если он может быть присвоен переменной, объявленной с классом Magnitude. С другой стороны, в языках Smalltalk и Objective-C объект является ╚измеримым╩, если он понимает сообщения, относящиеся к сравнению объектов, независимо от того, в каком месте иерархии классов он находится. Тем самым, чтобы заставить комплексные числа взаимодействовать с другими объектами, даже если они не имеют общих классов-предков, может использоваться техника двойной диспетчеризации (см. работу [Ingalls═1986] или раздел═18.2.3).
О классе, который наследует от двух или более родительских классов, говорят, что он порожден множественным наследованием. Множественное наследование═≈ это мощное и полезное свойство языка программирования, но оно создает много изощренных и сложных проблем при реализации. Из рассматриваемых нами языков только C++ поддерживает множественное наследование, хотя некоторые исследовательские версии Smalltalk также обладают этим свойством. В данной главе мы будем изучать некоторые из преимуществ и проблем, связанных с множественным наследованием.
Второй пример проиллюстрирует многие моменты, которые необходимо иметь в виду, когда вы рассматриваете множественное наследование. Он вдохновлен библиотекой для создания графических пользовательских интерфейсов, которая связана с объектно-ориентированным языком Eiffel [Meyer═1988a, Meyer═1988b]. В этой системе меню описываются как класс Menu. Экземпляры класса Menu поддерживают такие свойства, как число пунктов меню, список команд и т.═д. Функционирование, связанное с меню, подразумевает способность отображать меню (то есть себя) на графическом экране и выбирать один из его пунктов (рис.═13.4).
Каждый элемент (пункт) меню представляет собой экземпляр класса MenuItem. Экземпляры содержат текст элемента, ссылку на родительское меню и описание команды, которую надо выполнить при выборе этого пункта меню (рис.═13.5).
Типичным средством пользовательского графического интерфейса являются многоуровневые меню (в некоторых системах они называются каскадными меню), которые требуются, когда элемент меню содержит несколько альтернативных команд. Например, пункт главного меню для эмулятора терминала может иметь имя ╚задать опции╩. Когда этот пункт (подменю) выбран пользователем, высвечивается второе меню, которое позволяет выбирать из набора имеющихся опций (темный/светлый фон и т.═д.).
Многоуровневое меню определенно принадлежит классу Menu. Оно содержит ту же информацию, что и класс Menu, и должно вести себя подобным образом. С═другой стороны, оно так же, несомненно, является элементом MenuItem, так как содержит имя и способно выполнить команду (вывести свой образ на экран), когда соответствующий пункт выбран в родительском меню. Требуемое поведение может быть достигнуто с минимальными усилиями, если мы разрешим классу WalkingMenu наследовать от обоих родителей. Например, когда всплывающему меню требуется выполнить действие по щелчку мыши (унаследованное от класса MenuItem), оно выводит на экран свое содержимое (вызывая графический метод, унаследованный от класса Menu).
Как и в случае с одиночным наследованием, при использовании множественного наследования важно иметь в виду условие ╚быть экземпляром╩. В нашем примере множественное наследование оправдано, поскольку имеет смысл каждое из утверждений: ╚подменю есть меню╩ и ╚подменю есть пункт меню╩. Когда отношение ╚быть экземпляром╩ не выполнено, множественное наследование может использоваться неправильно. Например, неверно описывать ╚автомобиль╩ как подкласс двух классов: ╚мотор╩ и ╚корпус╩. Точно так же класс ╚яблочный пирог╩ не следует выводить из классов ╚пирог╩ и ╚яблоко╩. Очевидно, что яблочный пирог есть пирог, но он не есть яблоко.
Когда множественное наследование используется правильным образом, происходит тонкое, но тем не менее важное изменение во взгляде на наследование. Интерпретация условия ╚быть экземпляром╩ при одиночном наследовании рассматривает подкласс как специализированную форму другой категории (родительского класса). При множественном наследовании класс является комбинацией нескольких различных характеристик со своими интерфейсами, состояниями и базовым поведением, специализированным для рассматриваемого случая. Операции записи и поиска объектов в неком хранилище (скажем, на жестком диске) представляют типичный пример. Часто эти действия реализованы как часть поведения, связанного с определенным классом типа Persistence или Storable. Чтобы придать такую способность произвольному классу, мы просто добавляем Storable к списку предков класса.
Конечно же, важно различать между наследованием от независимых источников и построением из независимых компонент, что иллюстрируется на примере ╚автомобиля╩ и ╚мотора╩.
Часто возникающее затруднение при множественном наследовании состоит в том, что имена могут использоваться для обозначения более чем одной операции. Чтобы проиллюстрировать это, мы рассмотрим еще раз модель карточной игры. Предположим, что уже имеется абстракция карточной колоды CardDeck, которая обеспечивает надлежащую функциональность: тасование колоды (метод shuffle), выбор отдельной карты (метод draw 13 ) и т.═д., но графика при этом не реализована. Предположим далее, что другое множество классов обеспечивает поддержку обобщенных графических объектов. Они содержат поле данных (точку на плоскости) и, кроме того, виртуальный метод с именем draw для графического отображения самих себя.
13.2. Всплывающие меню
Рис.═13.4. CRC-карточка для класса Menu
Рис.═13.5. CRC-карточка для класса MenuItem
13.3. Двусмысленность имен
|
Программист решает создать класс новой абстракции GraphicalDeck, которая наследует как от класса CardDeck, так и от GraphicalObject. Ясно, что концептуально класс GraphicalDeck является колодой карт CardDeck и тем самым логически выводится из нее. GraphicalDeck является также графическим объектом GraphicalObject. Единственная неприятность═≈ это двойное значение команды draw.
Как отмечает Мейер [Meyer═1988a], проблема однозначно кроется в дочернем классе, а не в родителях. Команда draw имеет недвусмысленное значение для каждого из родительских классов, когда они рассматриваются изолированно. Сложность состоит в их комбинировании. Поскольку загвоздка возникает на уровне дочернего класса, то и решение должно быть найдено здесь же. В данном случае дочерний класс обязан принять решение, как устранить двусмысленность перегруженного имени.
Решение проблемы обычно включает комбинацию переименования и переопределения. Под переопределением мы понимаем изменение в выполняемой операции или команде, как это происходит при модификации в подклассе виртуального метода. Под переименованием мы просто подразумеваем смену имени метода без изменения его функционирования. В случае колоды карт GraphicalDeck программист может приписать методу draw задачу рисования графического образа, а процесс вытаскивания карты из колоды переименовать в drawCard.
Более сложная проблема возникает, если программист хочет использовать два класса, имеющие общего родителя. Предположим, что программист разрабатывает набор классов для потоков ввода/вывода. Поток данных═≈ это обобщение понятия файла. Элементы первого могут быть более структурированы. Например, часто используются потоки целых чисел или потоки чисел с плавающей точкой. Класс InStream обеспечивает протокол для входных потоков. Пользователь может открыть входной поток путем присоединения его к файлу данных, выбрать очередной элемент из потока и т.═д. Класс OutStream обеспечивает похожую функциональность для выходных потоков. Оба класса наследуют от общего родителя с именем Stream. Информация, которая указывает на собственно файл данных, прячущийся под маской потока, содержится в родительском классе.
Теперь предположим, что пользователь хочет создать комбинированный поток ввода/вывода InOutStream (рис. 13.6). Имеет смысл объявить его потомком и потока ввода, и потока вывода. Переименование (см. предыдущий раздел) позволяет принять решение по поводу любой функции, определенной одновременно в классах InStream и OutStream. Но что делать со свойствами, наследуемыми от общего прародителя Stream? Трудность состоит в том, что дерево наследования═≈ это направленный граф, а не просто дерево (см. рис.═13.6). Если методы═≈ это единственное, что наследуется от общего родительского класса, то может быть использована описанная выше техника разрешения противоречий. Но если родительский класс определяет также и поля данных (например, указатель на файл), то имеются два варианта. Хотим ли мы иметь две копии полей данных или только одну? Аналогичная проблема возникает, если прародительский класс использует конструкторы или подобные им средства инициализации, которые должны вызываться только однажды. В следующем разделе мы опишем, как с этой проблемой справляется язык C++.
Мы проиллюстрируем использование множественного наследования в C++, работая над небольшим примером. Предположим, что для прежнего проекта программист разработал набор классов для манипуляций со связными списками (листинг═13.1). Абстракция списка была разбита на две части: класс Link поддерживает указатели на элементы списка, а класс LinkedList запоминает начало списка. Основной смысл связного списка═≈ добавление новых элементов. Связные списки предоставляют также возможность выполнить некоторую функцию над каждым своим элементом. Функция передается в качестве аргумента. Оба эти действия поддерживаются процедурами в классе Link.
Листинг═13.1.13.3.1. Наследование через общих предков
Рис.═13.6. Граф множественного наследования
13.4. Множественное наследование в C++
Классы реализации связных списков
|
Мы образуем специализированные списки через определение подклассов Link. Например, класс IntegerLink в листинге═13.2 служит для поддержки списков целых чисел. Листинг 13.2 содержит также короткую программу, которая показывает, как используется эта абстракция данных.
Теперь предположим, что для нового проекта тот же самый программист должен разработать класс Tree (древовидная структура). После некоторого размышления он обнаруживает, что дерево можно представить себе как совокупность связных списков. На каждом уровне дерева поля связи указывают на подветви (деревья, принадлежащие к одному уровню). Однако каждый узел указывает также на связный список, который представляет собой его потомков. Рисунок═13.7 иллюстрирует эту структуру. Здесь наклонные стрелки обозначают указатели на потомков, а горизонтальные═≈ соединения подветвей.
Тем самым узел дерева относится и к классу LinkedList (поскольку он содержит указатель на список своих потомков), и к классу Link (поскольку он содержит указатель на свою подветвь). В языке C++ мы обозначаем множественное наследование, просто перечисляя имена надклассов, разделяя их запятыми (перечисление следует после двоеточия сразу за именем класса при его описании). Как и в случае одиночного наследования, каждому классу должно предшествовать ключевое слово (public или private), которое определяет правило видимости. В листинге═13.3 показан класс Tree, который наследует от классов
Листинг═13.2.
Уточнение класса Link
|
Рис.═13.7. Дерево как совокупность связных списков
Листинг═13.3.
Пример множественного наследования
|
Link и LinkedList с ключевым словом public. Узлы дерева Tree содержат указатели на потомков, а также целочисленные значения.
Теперь необходимо справиться с проблемой неоднозначности. Прежде всего имеется двусмысленность в имени add (добавить), которая отражает двойственность приписываемого ему значения. Для дерева есть два смысла операции ╚добавить╩: присоединить узел-потомок и породить узел-подветвь. Первое обеспечивается функцией add класса LinkedList, второе═≈ функцией add класса Link. После некоторого размышления программист решает оставить функцию add в смысле ╚добавить узел-потомок╩, но одновременно вводит две новые функции, имена которых отражают цель операции (добавить подветвь и добавить потомка).
Заметьте, что все три функции по существу═≈ просто переименованные старые. Они не добавляют нового функционирования, а просто передают управление ранее определенным функциям. Некоторые объектно-ориентированные языки (например, Eiffel) позволяют пользователю вводить подобное переименование без создания новой функции.
Двусмысленность в методе onEachDo является более сложной. Здесь правильное действие состоит в выполнении сквозного прохода по всем узлам дерева. Процесс начинается с просмотра узлов-потомков, затем возвращается в исходный узел, а затем переходит к подветвям (которые, естественно, осуществляют рекурсивный проход уже по своим потомкам). То есть действие является комбинацией методов базовых классов Link и LinkedList, как это показано в листинге═13.4.
Переименование время от времени оказывается необходимым из-за пересечения понятий наследования и параметрической перегрузки. Когда в C++ используется перегруженное имя, то сперва вызывается механизм наследования для поиска контекста, в котором определена функция. Затем типы параметров анализируются для снятия двусмысленности в пределах данного контекста. Предположим, что есть два класса A и B, для каждого из которых определен метод display, но у методов разные аргументы (листинг═13.4). Пользователь считает, что так как эти два метода различаются по списку параметров, дочерний класс может наследовать от двух родителей и иметь доступ к обоим методам. К сожалению, здесь наследования недостаточно. Когда пользователь вызывает метод display с целочисленным аргументом, компилятор не может принять решение, использовать ли функцию из класса A (которая соответствует типу аргумента) или же из класса B (которая встречается первой при заложенном в C++ алгоритме поиска; для ее вызова аргумент будет приведен от типа integer к типу double). К счастью, компилятор всегда предупреждает о подобных случаях. Однако предупреждение выдается в точке вызова метода, а не при описании класса.
Выход в том, чтобы переопределить оба метода для дочернего класса C, как это показано в листинге═13.4. Мы избежим конкуренции между наследованием и перегрузкой═≈ в обоих случаях поиск кончается в классе C, где для компилятора уже ясно, что параметрическая перегрузка используется намеренно.
Листинг═13.4.
Взаимодействие наследования и перегрузки
|
В предыдущем разделе мы описали трудность, которая возникает, когда класс наследует от двух родителей, имеющих общего предка. Эта проблема была проиллюстрирована на примере классов InStream и OutStream, каждый из которых наследовал от общего класса Stream. Если мы хотим, чтобы порождаемый класс наследовал только одну копию полей данных, определенных в классе Stream, то промежуточные классы InStream и OutStream должны определять, что их наследование от общего родительского класса является виртуальным. Ключевое слово virtual показывает, что надкласс может появляться более одного раза в подклассах, порождаемых из определяемого класса, но при этом нужно оставлять только одну его копию. Листинг═13.5 показывает такой вариант описания для этих четырех классов.
Листинг═13.5.
Пример виртуального наследования
|
Такой подход, использованный в языке C++, нельзя признать совершенным (как на это указывает Мейер), поскольку конфликт имен возникает на уровне дочерних классов, а решение (ключевое слово virtual для общего предка) затрагивает родительские классы. То есть ╚виртуальное предназначение╩ общей части закладывается в родителях, а не в комбинированном классе.
В редких случаях желательно создавать две копии наследуемых полей данных. Например, и графические объекты, и колоды игральных карт могут быть основаны на связных списках, поэтому оба класса выводятся из класса Link. Поскольку эти два списка являются независимыми, они оба должны содержаться в комбинированном классе GraphicalDeck. В такой ситуации ключевые слова virtual опускаются═≈ желаемый результат будет достигнут. Однако важно гарантировать, что возникший конфликт имен не вызовет ошибочной интерпретации.
Ключевые слова, определяющие видимость, имеют право отличаться в разных родительских классах. Следовательно, виртуальный наследник может быть порожден с различными атрибутами, например public и protected. В таком случае наиболее жесткий уровень защиты (в нашем примере protected) игнорируется и используется менее ограничительная категория.
Когда конструкторы определены в нескольких надклассах, важен порядок выполнения родительских конструкторов и, следовательно, очередность инициализации полей данных. Пользователь может управлять этим, вызывая непосредственно конструкторы родительских классов внутри конструктора потомков. Например, в листинге═13.6 пользователь явно указывает, что при инициализации класса C конструктор класса B вызывается первым, то есть до вызова конструктора класса A. Порядок вызова конструкторов влияет на инициализацию.
Исключение из этого правила составляют виртуальные базовые классы. Они всегда инициализируются лишь один раз вызовом безаргументного конструктора (если обращение к нему не осуществляется пользователем, такой конструктор вызывается системой). Это происходит до какой бы то ни было другой инициализации. В листинге═13.6 инициализация при создании нового элемента класса C будет производиться в таком порядке: инициализируется класс D с помощью безаргументного конструктора, затем═≈ класс B и наконец═≈ класс A. Два кажущихся вызова конструктора класса D внутри конструкторов классов A и B не имеют никакого эффекта, поскольку указано, что родительский класс виртуален.
Если требуется, чтобы для конструктора базового класса задавались аргументы, класс C может законным образом задать нужные значения даже тогда, когда D не
Листинг═13.6.
Конструкторы при множественном наследовании
|
является непосредственным предком класса C. Это единственная ситуация, когда внутри класса разрешено использовать конструктор другого класса, который не является непосредственно предшествующим родителем. То есть конструктор класса C может быть записан следующим образом:
C() : D(12), B(), A() { ... }
Конструкторы для виртуальных базовых классов должны вызываться первыми, то есть до конструкторов невиртуальных предков.
Виртуальные методы, определенные в виртуальных базовых классов, также могут быть источником проблем. Предположим, что каждый из четырех классов в листинге═13.5 обладает методом с именем initialize(). Он определен как виртуальный в классе Stream и переопределяется в каждом из последующих трех классов. Методы initialize() в классах InStream и OutStream вызывают Stream::initialize() и, кроме того, выполняют некоторую специфическую для каждого из классов инициализацию.
Теперь рассмотрим метод initialize() для класса InOutStream. Он не может вызвать оба унаследованных метода InStream::initialize() и OutStream::initialize() без того, чтобы не вызвать дважды метод Stream::initialize(). Повторное обращение к методу Stream::initialize(), вероятно, будет иметь побочные эффекты. Способ из-бежать этой проблемы: переписать Stream::initialize() так, чтобы он определял, была ли уже осуществлена инициализация. Другой вариант: переопределить методы классов InStream и OutStream, чтобы они не вызывали метод класса Stream. В последнем случае класс InOutStream должен в явном виде обращаться к процедуре инициализации каждого из трех классов.
Язык Java не поддерживает множественное наследование классов, но реализует множественное наследование интерфейсов. Класс может указать, что он поддерживает несколько различных интерфейсов. Например, один интерфейс может требовать запоминания данных на диске, а другой═≈ определять протокол самоотображения объектов. Запоминаемый графический объект будет поддерживать оба эти интерфейса:
13.5. Множественное наследование в Java
|
В то время как классы не могут наследовать от двух и более классов (расширяя их), интерфейсам это разрешено. Мы имеем право определить интерфейс для запоминаемых графических объектов следующим образом:
|
Критика множественного наследования встречается у Саккинена [Sakkinen 1988a]. Упомянутая работа является сокращенной адаптированной версией его Ph. D. диссертации [Sakkinen═1992]. Объяснение множественного наследования в языке C++ дается Эллис [Ellis═1990].
Упражнения