лекции (1998) (Буров) (1161123), страница 28
Текст из файла (страница 28)
FMyProp2: integer;
procedure SetMyProp2(...); //определяется процедура для работы с Property
property MyProp2: read FMyProp2;
write SetMyProp2;
end;
C:MyComp;
C.MyProp:=3;
C.MyProp2:=10;
Очевидно, что это очень удобно, особенно при визуальной разработке приложений.
Интересно, что события в Delphi тоже реализованы, как Property, значения которых – процедурный тип, правда, немного необычный:
type TNotify.Event = procedure (Sender: TObject; Ev: TEvent) of object;
И теперь мы можем работать с событием, как со свойством, например:
OnResize:=MyResizeHandler;
а если мы не хотим ничего делать на событии Resize, то можно написать:
OnResize:=nil;
Спецификатор published относится как раз к свойствам и означает его публикуемость. В Delphi есть целая иерархия типов, например для объекта ListView:
TWinControl
...
TCustomListView
TListView
<здесь следует описать назначение published и ключевого слова final, запись стала невыносимо быстройи неразборчивой>
final
class T{
final int f();
}
final int i=0;
Лекция 23
Множественное наследование.
Множественное наследование до сих пор вызывает споры. Наиболее строгие апологеты объектно-ориентированного подхода считают, что множественное наследование – несколько искусственная форма, и что от нее нужно отказаться. Поклонники языка Smalltalk считают, что если чего-то нет в Smalltalk, то это не имеет никакого отношения к объектно-ориентированному (в Smalltalk нет множественного наследования). Наиболее максимальные языки, такие как CLOS (Command Lisp Object System) и С++, используют множественное наследование, хотя именно критика множественного наследования в С++ и послужила катализатором мнений по этому поводу. Сейчас ситуация с множественным наследованием прояснилась окончательно, и тот вариант множественного наследования, который реализован в языке Java в настоящий момент признан оптимальным.
Такие языки, как Delphi, Оберон, Smaltalk множественного наследования не поддерживают.
П
ри множественном наследовании появляется возможность моделировать более сложные отношения между классами, которые в математике называются решетками. Можно создавать структуры разных типов.
Подобного рода структуры реализуются следующим образом:
class X {
int i;
}
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 системой. При этом компилятор гарантирует надежность, т.е. программисту в теле соответствующего обработчика не требуется писать проверок.