Лекции (1129116), страница 30
Текст из файла (страница 30)
px->g( ) // ошибка, так как в X функция g даже не определена.
Ведь в случае когда вызывается функция от класса Y, ей должен быть передан указатель класса py:
X | px, pz |
Y | py |
Z |
а у нас реально указатель другой – на начало.
В случае же
pz->g( )
какая функция будет вызвана? Очевидно, Y::g( ), в качестве указателя this ей будет передан pz (на начало вышеприведенной таблицы). Что же получается? А именно то, что в случае множественного наследования в ТВМ должно быть еще смещение. Фактически, мы должны иметь два варианта ТВМ. Если вызываются виртуальные методы от X (в нашем случае это f( ) ), то смещение равно нулю, если же вызываются методы для второго класса (а также третьего, четвертого и т.д.), то нужно добавлять некий сдвиг внутри таблицы, он равен –(размер X).
Z | X | ссылка на ТВМ XZ |
X | ||
Y | ссылка на ТВМ YZ | |
Y | ||
Z |
TВМ XZ:
Z::f |
Z::i |
ТВМ YZ:
Y::g |
Z::f |
Z::i |
Заметим, что ТВМ Z получается «спрятанным» в совместных ТВМ с другими классами.
Таким образом, для кажого класса, от которого происходит наследование, мы должны иметь совместную ТВМ этого класса и Z (потомка). Разумеется, это не есть хорошо.
Smalltalk
В SmallTalk не линейное, а цепное представление класса:
Начинается все со ссылки на объект базового класса. Плюс каждый объект имеет ссылку на таблицу методов (ТМ), можно не писать слово «виртуальных», так как в SmallTalk все методы виртуальны. Более того, SmallTalk настолько динамический язык, что там можно динамически изменять ТМ – среди методов класса (аналог статических методов в C++) есть методы, которые позволяют добавлять или убирать методы из ТМ данного класса. Получается, что можно динамически менять поведение объекта. В C++ это невозможно.
В SmallTalk в принципе нельзя во время компиляции определить – применим ли данный метод для данного объекта, поэтому вызов метода осуществляется следующим образом: пусть у нас вызывается некий метод MSG, он ищется в ближайшей к нему ТМ, если он там есть, то вызывается, если нет – происходит поиск в следующей ТМ по иерархии вверх (если метод не будет найден, возникнет сообщение об ошибке). Следует отметить, что вызов MSG может оказаться некорректным, если есть несоответствие в параметрах (ведь ТМ может изменяться динамически, и компилятор это несоответсвие отловить просто не может), в этом случае возникнет сообщение об ошибке, но уже во время выполнения.
Понятно, почему в SmallTalk нет множественного наследования. И так накладные расходы велики (хотя бы динамический поиск – это не подставление смещения в C++). Если же мы добавим множественное наследование, то поиск метода будет осуществляться не по линейному списку, а по дереву, что на порядок сложнее и дольше:
Таким образом множественное наследование с точки зрения данных придается критике. Из-за того, что у нас есть некоторые данные, приходится объединять соответствующие ТВМ - если есть два базовых класса, то фактически мы имеем две ТВМ, это неприятно.
Если бы никаких данных не было, то проблема с модификацией указателя отпадала бы и можно было бы реализовать соответствующую вещь эффективнее.
Снятие механизма виртуального вызова
Как мы уже отметили вызовы виртуального и невиртуального методов существенно различаются. Невиртуальный метод вызывается одним косвенным вызовом, тогда как виртуальный вызывается несколько сложнее через несколько указателей. Но вызовы виртуальных методов можно снимать, разумеется, только в методах. Пусть у нас есть:
class X {
virtual void f( );
...
};
class Y public X {
void h( ) { f ( ); ...} // нельзя сказать, из какого класса
//будет вызван метод
// f( ), он может быть вызван из X, Y или какого-то
// наследного класса Z в зависимости от динамического типа
...
};
Если мы знаем, какой метод какого класса хотим получить, то можно снять вызов виртуального метода, написав вместо f ( ), например, X::f(), таким образом мы дадим точно понять, какую реально функцию хотим, и компилятор подставит сюда вызов f( ), как невиртуальной функции. То есть виртуальность и невиртуальность – это механизм вызова, а не самой функции, ибо сама функция реализуется одинаково в обоих случаях.
Заметим, что снять механизм виртуального вызова извне класса невозможно.
Абстрактные методы. Абстрактные классы.
Когда мы начинали обсуждать пример связанный с графическими примитивами:
class Shape {
int x,y;
virtual void Draw(bool);
void Move(...);
...
};
Класс Shape – вершина иерархии. Заметим, что в методе Move у нас вызывается Draw. Это напрямую означает, что мы должны написать реализацию Draw, иначе компилятор выдаст ошибку. Но смысла в этой реализации нет никакого, так как она сведется в результате к вызову пустого метода, да и назначение Shape не предусматривает смысла в реализации Draw.
Классы аналоги Shape, из которых потом выводятся другие классы, призваны для определения интерфейсов виртуальных методов.
Из этих соображений были введены абстрактные классы и абстрактные виртуальные методы (не путать с абстрактными типами данных).
class X {
virtural void f( )=0;
};
“=0” и означает то, что f( ) – абстрактный виртуальный метод. Страуструп дико боялся введения новых ключевых слов по вполне понятным причинам, хотя объявление могло бы выглядеть и, например, так:
abstract virtual void f( );
Что такое абстрактный виртуальный метод? Это метод, который указывает на то, что в любом классе, унаследованном от данного функция f( ) должна быть переопределена. Но она не должна быть определена здесь. Классы, в которых есть абстрактные методы называют абстрактными классами. Они отличаются от обычных тем, что объекты данных классов нельзя заводить в программе. Если в классе X есть хотя бы один абстрактный метод, то при попытке создать экземпляр данного класса компилятор выдаст ошибку.
Очевидно, что метод Draw() должен быть абстрактным.
Если мы не переопределим в классе-наследнике абстрактный метод, то порожденный таким образом класс будет также абстрактным. Заметим, что в стандартных библиотеках очень часто можно встретить целые слои абстрактных классов. Это допустимо.
Можно заводить указатели на абстрактные классы и им присваивать адреса производных, уже неабстрактных.
Возникает вопрос, связаны ли между собой абстрактные классы (АК) и абстрактные типы данных (АТД)? На первый взгляд нет. Но представим себе такую вещь. Пусть есть класс, в котором данные отсутствуют вообще, а все методы объявлены, как виртуальные и абстрактные:
class Abstract {
public:
virtual void f()=0;
virtual void g()=0;
};
Раньше имело смысл заводить класс без данных в случае, если все функции в нем статические. Здесь речь идет о функциях-членах и в классе нет никаких данных. Утверждается, что это в самом чистом виде абстрактный тип данных. Реализация данного типа данных – выведение неабстрактного типа данных. Приведем стандартный пример, пример множества:
class Set {
public:
virtual void Include (Set & S)=0;
virtual void Include (Elem & S)=0;
virtual void Exclude (Elem &S)=0;
...
};
Конечно, хочется еще сделать этот класс и шаблоном, параметризовав его типом Elem. Вобщем, мы получили АК без данных, что очень похоже на АТД. Ведь АТД это тип, где данные и код полностью инкапсулированы и интерфейс представлен лишь набором операций. Что является реализацией? В объектно-ориентированных языка в отличие от остальных появляется еще одна степень свободы:
class Slist {...}; // операции работы с линейным списком
class ListSet: public Set, public Slist {
...// благодаря множественному наследованию переопределяются
//мы наследуем и интерфейс и реализацию.
// следует, конечно, переписать все операции Set через операции
// SList
};
Теперь всем клиентским модулям, использующим Set, можно подставлять класс ListSet, и они будут прекрасно работать. Но клиентские функции ничего не знают реализации. Отсюда следует еще и минимизация перекомпиляции – ведь реализацию класса ListSet мы можем поместить куда угодно.
Информация о классе Set представляет из себя указатель на ТВМ.
Здесь мы получаем наиболее гибкое отделение реализации от определения.
Интересно, что объекты ListSet будут представлять из себя одну ссылку на ТВМ и набор данных:
ссылка на ТВМ |
DATA |
причем не произойдет никаких «размножений» ТВМ, так как один из классов-предков имел ТВМ, а другой только данные. Мы даже можем написать универсальный контейнер, который с помощью линейного списка реализует множество, стек, очередь Q – где все они будут являться АК.
Поскольку данные наследуются только по одному пути (или вообще не наследуются, а указываются в наследнике), то мы лишаемся всех тех проблем, которые были связаны с наследованием данных из разных источников, они решены в C++, но как-то неестественно.
Java
В Java наследование только единичное:
class Y extends X {
...
};
Но еще в Java введен тип данных – interface, строго говоря, это совокупность типов данных.
interface имя { описание членов интерфейса};
Член интерфейса – это либо описание метода (без тела), либо константы (static final int i=0).
В Java есть понятие чисто виртуальной функции (вспомним, что в Java все функции виртуальны). У таких функций нет реализации. Интерфейс – это интерфейс, он сам по себе ничего реализовывать не должен. Но тем не менее реализован он все-таки должен быть. Поэтому общая форма наследования в Java выглядит так (следует заметить, что в Java все классы являются потомками класса Object, поэтому, если опустить “extends X” в нижеследующем примере, то Y будет непосредственным потомком Object):
class Y extends X implements <список_интерфейсов> { ...}
Если класс реализует все функции из списка интерфейсов, то это – неабстрактный класс. Если не все – абстрактный (чтобы из него можно было вывести что-то еще).
Также как и в C++ в Java нельзя создавать экземпляры АК. Но ссылки и указатели задавать можем.
Очень много понятий реализуется через интерфейсы. Например, проблема копирования. В C++ эта проблема решена через перекрытие операции присваивания и конструктора копирования. В Java же вместо этого делается так: есть
объект Object
и
interface Cloneable;
Object может реализовывать Cloneable, чтобы сделать свой объект клонируемым следует переопределить методы из Cloneable, поскольку
Object implements Cloneable.
Таким же образом можно запретить копирование или реализовать его нестандартно (стандартно – это побитово).
Понятие интерфейса – это и есть понятие АТД. Причем добавляется гибкость и, как бы побочно, минимизация времени перекомпиляции – такого даже в Ada добиться не удавалось.
Лекция 25