Лекции (1129116), страница 28
Текст из файла (страница 28)
}
class Z {
int j;
}
class Y: public X {
int k;
}
class W: public X, public Y, public Z {
int l;
}
С
интаксис множественного наследования очень похож на единичное наследование. Спецификатор public (и другие спецификаторы) имеет ту же семантику, что и при единичном наследовании. Объект класса W обладает полями классов X, Y, Z. Кроме того, содержит свои поля.
К
аким образом будет распределяться память под объект? Мы уже говорили, что есть два подхода распределения памяти: цепной и линейный. Для множественного наследования в С++ более эффективен линейный подход. При цепном подходе, объект класса-наследника должен содержать не просто ссылку на базовый класс, а целую таблицу ссылок. Хотя и линейная схема распределения памяти в С++ дает некоторые накладные расходы. Большинство компиляторов разместит объекты друг за другом.
В объекте класса W будет находиться два экземпляра класса Х. При этом возникает конфликт имен между классами Х и XY. Явным образом указать класс (Х или XY), к которому мы хотим обратиться, в данном случае нельзя, потому что запись X::i не определяет, к какому из двух классов X необходимо обратиться. Компилятор, в данном случае, выбирает правило доминирования имен: имя A::i доминирует над именем B::i в том случае, если A содержит класс B в качестве одного из своих базовых классов. Это правило доминирования будет действовать и при выборе виртуальных функций.
В данном случае, X::i будет означать обращение к верхнему в схеме объекту Х (т.е. к прямому предку). К полю объекта XY можно обратиться и через класс Y (Y::i), но только если это поле в классе Y не переопределено. Иначе к полю объекта XY обратиться невозможно.
Невозможно реализовать следующий вариант наследования:
class X: public Y, public Y{…}
В данном случае было бы невозможно из класса Х обратиться к какому-либо полю Y, потому что оба предка равноправны. Можно было бы специально для этого ввести механизм различения таких классов, но Страуструп избегал такого рода подходов.
Как реализовать ромбовидную схему наследования? Каким образом сделать так, чтобы классы Y и Z были наследниками одного экземпляра объекта Х? Определение класса W выглядит следующим образом:
c
lass W: public Y, public Z{…}; //определение класса W ничем не отличается
//классы Y и Z должны быть определены иначе.
class Y: virtual public X{…};
class Z: virtual public X{…};
Классы Y и Z должны наследовать класс Х виртуальным образом. Если один из этих классов наследует не виртуальным образом, то ромбовидная структура не получиться, и будет два экземпляра Х.
Если затем, например, описать класс А следующим образом:
class A: public Y, public Z, public X {…};
то объект этого класса будет содержать два тела Х, одно из которых совместно используется классами Y и Z. Если же мы хотим, чтобы было только одно тело Х, то нужно писать иначе:
class A: public Y, public Z, virtual public X {…};
В каких случаях удобно использовать множественное наследование? Для первой схемы рисунка (левая часть неравенства) можно привести следующий пример. Представим себе, что Х – это обобщенный контейнерный класс, например, линейный список. У контейнерного класса есть набор функций для работы с ним. Для того, чтобы хранить что-то в соответствующем списке, мы должны были добавлять к этим функциям конкретные данные, наследуя контейнерный класс. В данном случае, наличие двух объектов Х означает, что мы хотим, чтобы класс W находился сразу в двух линейных списках (например, список графических объектов на экране и список объектов, которые в данный момент не должны рисоваться).
З
ачем нужна ромбовидная форма наследования? Эта форма имеет несколько иную карту распределения памяти. Классы Y и Z совместно используют одно тело X. В стандартной библиотеке С++ есть пример такой схемы наследования, потому что, когда Страуструп вводил некоторые абстракции, он должен был их оправдать на примере стандартной библиотеки.
В данном случае, имеется в виду библиотека надежного ввода-вывода iostream. Есть некоторый базовый тип stream (аналог Х), из которого наследуются типы istream и ostream (аналогично классам Y и Z), которые являются базовыми классами для типа iostream (соответственно классу W). Получается ромбовидная схема наследования. Класс stream содержит в себе некий буфер, файловый дескриптор и др. Классы istream и ostream содержат интерфейс для ввода и вывода соответственно. Класс iostream применяется и для ввода и для вывода, причем через один и тот же буфер и один файловый дескриптор. Если бы для чтения и для записи использовались бы разные буфера, то ввод-вывод был бы некорректен.
На первый взгляд, множественное наследование добавляет некоторые преимущества, потому что что-то можно делать, чего раньше делать было нельзя. Хотя все это можно моделировать и с помощью единичного наследования, используя отношение включения. На первый взгляд, эффективная реализация множественного наследования сложности не представляет, но на самом деле существует одна большая проблема, связанная с виртуальными функциями. Множественное наследование было добавлено в С++ в районе 85-86го года, и тогда перед Страуструпом стояла проблема: нужно было срочно довести язык до квазипромышленной реализации, компилятор которой, можно было уже лицензировать. И на него оказывалось менеджерское давление. Перед Страуструпом стояло два вопроса: работать ли над множественным наследованием или работать над шаблонами. Страуструп говорит, что самая большая ошибка, которую он допустил при работе над языком С++, это то, что он занялся реализацией множественного наследования в ущерб статической параметризации.
Проблема здесь не столько в спорном характере множественного наследования, сколько в том, что было упущено время, следующая версия вышла только через четыре года. Если бы шаблоны были бы внедрены в язык в 86-ом году, то к 90-му году уже появилась бы работоспособная и стандартная библиотека шаблонов. Заметьте, что сейчас ситуация такова, что каждая фирма-производитель выпускает свою шаблонную библиотеку. У MFC один набор шаблонов, у Borland другой набор шаблонов. Правда в середине 90-х годов появилась таки библиотека STL (Standard Template Library), однако она появилась, когда поезд уже ушел, и программисты уже не хотят переучиваться.
Язык Java использует множественное наследование только для специальных видов классов, а именно, для классов, которые состоят только из функций. На первый взгляд, кажется, что такие классы смысла не имеют. Возникает вопрос: если нет данных, то с чем же будут работать функции этого класса? Но если это статические функции, то такой класс приобретает смысл модуля. Наследование такого модуля полезно, когда соответствующие функции становятся динамически связываемыми (см. пример на Обероне с рисованием графических объектов).
На этом мы пока закончим тему множественного наследования, чтобы потом к ней вернуться, рассматривая абстрактные классы языка С++ и интерфейсы языка Java. Именно для этих целей и требуется множественное наследование.
Глава 2. Динамическое связывание методов.
Мы начнем с языка Оберон, потому что это самый простой язык из нами рассматриваемых. Вспомним пример, в котором мы определяли указатель Shape на некоторую структуру данных Node. Из этой структуры наследовались типы PointObj, LineObj и т.д., для которых существовали соответствующие указатели Point, Line и т.д. То есть наследование базовых типов переносилось на указатели. В качестве примера работы с этими объектами, мы написали процедуру Draw, которая рисовала все объекты из списка типа Shape. Гибкой реализации этой процедуры мы добились только тогда, когда определили интерфейс для Shape, в котором были обработчики Draw, Move и т.д. Для каждого объекта был определен соответствующий обработчик (т.е. для каждого объекта значение указателя на процедуру было разным), и при инициализации соответствующего объекта инициализировался соответствующий обработчик. Т.е. мы в каждом объекте переопределяли данный обработчик. В этом и заключается суть динамического связывания методов. Главный обработчик выглядел так: this.Draw(this). И мы не могли сказать, глядя на эту запись, какой же именно объект будет рисоваться.
Какие недостатки у такого метода? Во-первых, это потенциальная ненадежность, потому что мы можем испортить соответствующую структуру данных Shape. Мы можем присвоить не тот метод Draw для данного объекта. При этом весь контроль ложиться на программиста. К тому же в программе не должно существовать объектов типа Shape, потому что для них нет смысла определять обработчик. В языке должна быть исключена возможность создания объектов типа Shape.
Поэтому появилось некоторое расширение языка Оберон, а именно Оберон-2. Оберон-2 – это уже настоящий объектно-ориентированный язык, по мощности сравнимый с С++. К этому языку были добавлены процедуры, динамически привязанные к типу.
Суть динамического привязывания состоит в следующем. Мы для некоторого базового класса определяется интерфейс, который может выглядеть так:
TYPE NODE = RECORD
X, Y : INTEGER;
NEXT : SHAPE;
END;
Существует еще некоторый процедурный интерфейс который, например, может содержать процедуру включения в список фигуры:
PROCEDURE ADD (S : SHAPE; VAR L : SHAPE);
Эта процедура не меняется в зависимости от фигуры, т.е. в терминологии языка С++, она не является виртуальной. Есть еще другой функциональный интерфейс, а именно, процедуры, динамически привязанные к типу:
PROCEDURE (P:SHAPE) Draw();
Эта процедура по синтаксису выглядит иначе. В данном случае, она не имеет параметров, а параметр Р играет роль указателя this. Чем отличаются такие процедуры по своей семантике от обычных? В языке Оберон (и Оберон-2) никакого статического перекрытия нет, т.е. отсутствует статический полиморфизм. Имя процедуры в пределах модуля должно быть уникально. Для процедур привязанных к типу все наоборот: они должны быть переопределены. Допустим мы выводим новый тип данных из SHAPE:
TYPE PointObj = RECORD(NODE)
END;
Point = POINTER TO PointObj;
PROCEDURE (P:Point) Draw()
BEGIN
…
END Draw;
Этот тип данных ничего, с точки зрения данных не дает, но зато он наследует всю функциональность типа NODE. Мы должны переопределить процедуру Draw, для того, чтобы она вызывалась для объектов типа Point. Тоже самое делается для типов данных Line, Circle и др. Как работать с этой процедурой?
VAR L:SHAPE;
….
L.Draw();
Этот вызов вызывает процедуру, динамически привязанную к типу SHAPE. Т.е. в зависимости от динамического типа L вызывается соответствующий вариант этой процедуры. Заметьте, что в данном случае, уже никаких процедурных переменных не используется, отсутствует инициализация. Все делается автоматически соответствующей Run-Time системой. При этом компилятор гарантирует надежность, т.е. программисту в теле соответствующего обработчика не требуется писать проверок.
С точки зрения межмодульного интерфейса в языке Оберон есть соответствующие инструменты, которые позволяют сгенерировать интерфейсное описание модуля DEFENITION. Интересно, что DEFENITION для типа NODE будет выглядеть следующим образом:
TYPE NODE = RECORD
PROCEDURE Draw;
END;
PROCEDURE ADD();
Обычные процедуры будут выписаны отдельно. А динамически привязанные методы являются как бы частью класса NODE.
Когда мы пишем L.Draw(), то как раз L и выступает как соответствующий аргумент. А в реализации Draw мы используем Р как указатель this. Естественно, что процедура DrawAll() будет выглядеть почти точно также:
WHILE this # NIL DO
this.Draw();
this := this.NEXT;
END;
Теперь не требуется передавать обработчику соответствующего параметра. Т.е. в гибкости мы ничего не потеряли, а приобрели в надежности. И это за счет того, что механизм динамической привязки был реализован как часть языка.
Давайте рассмотрим язык С++. Аналогично, в С++ все функции-члены делятся на два класса: виртуальные и невиртуальные. Виртуальные функции и являются аналогом процедур, динамически привязанных к типу. Рассмотрим пример, который поясняет принцип работы виртуальных функций.
class X{
virtual int f(){cout << "X::f";}
int g(){cout << "X::g";}
…
}
Пусть есть еще один класс:
class Y: public X{
virtual int f(){cout << "Y::f";} // слово virtual необязательно, функция уже виртуальна
int g(){cout << "Y::g";}
…
}
X* px = new X;
Y* py = new Y;
px->f(); px->g(); // X::f X::g
py->f(); py->g(); // Y::f Y::g
px = py; //Меняем динамический тип px
px->f(); px->g(); // Y::f X::g
Заметим, что в языке Turbo Pascal, и у его наследника Delphi тоже есть понятие виртуальных методов. Но используется несколько иная терминология. В Turbo Pascal виртуальные методы назывались виртуальными, а невиртуальные – назывались статическими. В С++ статические методы – это совсем другое. В Turbo Pascal статических методов в смысле С++ нет. Но есть не только синтаксические различия.
type MyObj = class
procedure F; virtual;