лекции (2007) (1160825), страница 13
Текст из файла (страница 13)
Глава 4. Абстрактные классы и интерфейсы.
При рассмотрении динамического связывания был рассмотрен пример с графическим редактором, который позволяет рисовать разные фигуры. Была введена определённая иерархия типов: определён класс "Figure", из него выведены классы "Circle", "Line", "Point" и т.д. При этом в классе "Figure" мы вводили виртуальную функцию отрисовки "Draw", которую потом замещали конкретными реализациями в наследуемых классах. Но помимо этого придётся писать реализацию "Draw" и для самого класса "Figure" (иначе, например, не получится полностью заполнить таблицу виртуальных методов для класса "Figure"). Но нельзя нарисовать то, о чём ничего пока неизвестно.
Так мы приходим к понятию абстрактного метода - метода, который по своей сути пока не может иметь какой-либо реализации и рассчитан на то, что в наследуемых классах эта реализация появится. Соответственно, классы, в которых есть абстрактные методы, называются абстрактными классами. Важно различать понятия "абстрактный класс" и "абстрактный тип данных": как уже говорилось, абстрактный класс - это класс, который содержит абстрактные методы; абстрактный тип данных - это тип, у которого скрыта вся структура и реализация, а доступен только функциональный интерфейс. Конечно, абстрактный класс может являться абстрактным типом данных, но такое бывает далеко не всегда; есть примеры абстрактных классов, не являющихся АТД, и, наоборот, примеры АТД, не являющихся абстрактными классами.
В языке C++ понятие абстрактного метода сводится к такому понятию, как "чистая виртуальная функция":
class Figure
{
...
virtual void Draw() = 0;
...
}
Такой необычный синтаксис объясняется тем, что создатель языка, Бьерн Страуструп, пытался сделать язык, максимально похожий на C, и поэтому не стал вводить новое ключевое слово. У чистой виртуальной функции в абстрактном классе не существует тела, но она должна подменяться в каждом производном классе. Тем не менее, чистую виртуальную функцию всё-таки можно вызвать в конструкторе абстрактного класса, который вызовется при создании объекта производного класса. Поэтому какая-то реализация у чистых виртуальных функций всё-таки есть, и она обычно пишется разработчиками компиляторов. Ещё одним методом вызова абстрактного метода является его явный вызов со снятием виртуальности ("Figure::Draw"). Конечно, вызов абстрактного метода с большой вероятностью будет ошибочен.
Объект абстрактного класса не может создаваться в программе (так сделано в подавляющем большинстве современных ЯП, исключение составляет язык Delphi). Тем не менее, можно завести ссылку на абстрактный класс (в рассматриваемом примере - "Figure* pf") и использовать её с объектами производных типов. В Delphi можно создавать объекты абстрактного класса (правда, компилятор скорее всего выдаст соответствующее предупреждение), но при попытке вызвать абстрактный метод будет порождено исключение (которое можно "поймать", используя механизм обработки исключений).
В языках C#, Java, Delphi и Ada для абстрактных классов и методов введено ключевое слово "abstract". Синтаксически это выглядит следующим образом:
1) В C# и Java:
public abstract class X
{
...
public abstract void f( );
...
}
2) В Delphi:
type Figure = class
...
procedure Draw; abstract;
...
end;
3) В Ada:
type T is abstract tagged record ... end record;
...
type T1 is abstract tagged null record;
..
procedure Draw(F : in Figure) is abstract;
При разговоре об абстрактных классах и методах возникает ещё одно понятие - понятие интерфейса. Интерфейс (иногда его также называют "полностью абстрактный класс") - это класс, у которого из нестатических членов есть только абстрактные методы. Простой пример интерфейса - реализация множества: над множеством определены операции включения, исключения и т.п., но они не могут быть реализованы, пока неизвестна природа этого множества.
class Set
{
...
virtual void Incl (T& X) = 0;
virtual void Excl (T& X) = 0;
virtual bool In (T& X) = 0;
...
}
Пользователю достаточно знать только этот интерфейс, а различные реализации множеств могут получаться, например, при помощи множественного наследования:
class BSet : public Set, private Bitset {...}
class SListSet : public Set, private SList {...}
...
Плюс состоит в том, что можно легко менять реализацию, при этом не меняя сам интерфейс. Ещё один плюс такого подхода в том, что неизменность интерфейса позволяет не перекомпилировать модули, использующие этот интерфейс, при изменении его реализации:
Set* MakeSet() {return new SListSet();}
В данном примере для того, чтобы сменить реализацию, достаточно в функции MakeSet всего лишь заменить SListSet на BSet.
Во многие современные ЯП понятие интерфейса вошло в явном виде: это языки C#, Java, Delphi, Ada-2005. Синтаксически:
1) В C#:
interface Set
{
...
void Incl (T& X);
void Excl (T& X);
bool In (T& X);
...
}
2) В Java - точно так же, как в языке C#, плюс интерфейс может содержать набор констант (статических финальных переменных):
interface Set
{
...
void Incl (T& X);
void Excl (T& X);
bool In (T& X);
static final int i = 10;
...
}
3) в Delphi:
type T = interface (BaseInterface, ...)
...
end;
Кроме того, понятие интерфейса в определённом смысле сливает концепции наследования и абстрактных классов воедино.
1) В языке C# при наследовании может быть только один "настоящий класс" (имеется в виду, не полностью абстрактный), остальные могут быть только интерфейсами:
class X : Base, I1, I2, ... {...}
2) В Java - всё то же самое, отличается только синтаксис (введена конструкция "extends - implements"):
class X extends Base implements I1, I2, ..., In {...}
Если программист не хочет объявлять такой унаследованный класс абстрактным, придётся реализовывать все абстрактные методы всех интерфейсов, которые были указаны при наследовании.
С помощью интерфейсов можно решить некоторые языковые проблемы. Например, в языке Java для глубокого копирования объектов необходимо вызывать метод Clone(). Но свойство копироваться - это не свойство объектов конкретного класса, этим свойством может обладать любой объект. Поэтому в Java существует стандартный интерфейс Cloneable, в который "как бы" входит функция Clone(), а класс Object, являющийся верхним в иерархии классов объектов, этот интерфейс реализует ("class Object implements Cloneable, ..."). Почему Clone() "как бы" входит в Cloneable? Потому, что Cloneable принято относить к интерфейсам-маркерам - интерфейсам, которые сами являются пустыми, но о которых компилятор знает какую-то дополнительную информацию (например, компилятор проверяет, чтобы в классе, реализующем Cloneable, была переопределена функция Clone() ).
Ещё один пример - специальный цикл "foreach" в языке C#. Если имеется контейнер C, содержащий данные типа T, то при использовании цикла "foreach (T i in C) {...}" переменная i будет последовательно проходить по всем объектам контейнера C. Другими словами, контейнер можно рассматривать двояко: с одной стороны, с ним можно работать, как с чем-то целым (например, сделать его копию), а с другой стороны, его можно рассматривать как некий массив с данными и индексировать как C[i]. В языке C++ для такой работы с контейнером достаточно просто перекрыть операцию индексирования, но в C# запрещено переопределять стандартные операции (как, впрочем и в Java, и в Delphi). Поэтому вводится такое понятие, как индексер ("enumerator"). Скажем, в C# в роли индексера может выступать функция "this (T x) {...}", возвращающая объект типа T из контейнера; если её перекрыть для какого-то типа T, то по этому типу можно будет индексировать контейнер. Здесь тоже применяются интерфейсы: существует стандартный интерфейс IEnumerator (в котором определены различные методы, вроде MoveNext, Current, Reset и т.д.), и интерфейс IEnumerable, состоящий из одного метода GetEnumerator(), возвращающего интерфейс IEnumerator. Таким образом, если класс реализует интерфейс IEnumerable, то для него можно будет вызвать метод GetEnumerator() и получить интерфейс IEnumerable, а значит, и использовать его методы.
Интерфейсы также используются при работе с XML и во многих других случаях.
Глава 5. Дополнительные вопросы.
В этой главе будут рассмотрены два вопроса: множественное наследование и RTTI (динамическая идентификация типов).
5.1. Множественное наследование.
Вопрос о том, необходимо ли в ЯП множественное наследование, очень спорный. Достаточно распространено мнение о том, что язык Smalltalk содержит всё, что нужно для объектно-ориентированного программирования (а раз в Smalltalk нет множественного наследования, то оно и не нужно).
Того же направления придерживался Брэд Кокс, создатель Objective C (первой объектно-ориентированной реализации C): он считал, что в языках, базирующихся на C, нельзя вводить множественное наследование, так как это приведёт к значительному усложнению языка. Надо отметить, что Objective C получился очень простым и понятным языком, при этом полностью обратно совместимым с языком C (то есть любая программа на языке C являлась программой на языке C++). Более того, Objective C предлагал объектно-ориентированное программирование в стиле Smalltalk (с отправкой сообщений, "message-oriented"), а также множество других удобных нововведений. Но Objective C немного опоздал со своим выходом: он вышел в 1986-м году, а в конце 1985-го уже состоялся первый коммерческий релиз C++ (развития языка "C with Classes") от создателя C, Бьёрна Страуструпа. Ниша уже была занята, и Objected C не получил широкой популярности.
Язык C++, наоборот, стал очень популярен и стал развиваться не только по логическим, но и по маркетинговым соображениям. На конец 80-х был запланирован выпуск второй версии C++ (если точно, то C++ 2.0 вышел в 1989-ом году). Но выпускать версию с новым индексом, имея только мелкие доработки, было маркетологически неправильно; требовалось ввести что-то новое и достаточно мощное. У Страуструпа было 2 варианта - ввести либо шаблоны, либо множественное наследование (реализация обоих механизмов сразу заняла бы значительное время). Страуструп выбрал именно множественное наследование. Шаблоны появились позднее, и их реализация до сих пор отличается в разных компиляторах, при этом почти нигде нет полной совместимости шаблонов со стандартом языка.
Синтаксически множественное наследование в C++ выглядит так:
class X: список баз с модификаторами доступа {...}
Основная проблема, возникающая при множественном наследовании - это проблема конфликта имен. В списке баз не может быть одинаковых классов, но в разных классах могут быть одноимённые функции. Например, класс X наследует от двух независимых классов Y и Z, в каждом из которых есть функция f(). Тогда возникает вопрос, какая из этих двух функций должна использоваться в классе X. Проблема решается с помощью механизма уточнения имён: необходимо явно указывать либо "Y::f()", либо "Z::f()".
Y Z
\ /
X
Аналогичная ситуация возникает, если классы Y и Z наследуются от X, а класс W наследуется от Y и Z:
X X
| |
Y Z
\ /
W
Здесь проблема решается так же: с помощью "Y::f()" и "Z::f()".
Ещё одна проблема связана с распределением памяти. Пусть в классе X есть виртуальная функция f(), в классе Y - виртуальная функция g(), а класс W наследует от X и Y. Тогда, если ничего не менять в механизме ТВМ, возникает следуюшая проблема: при распределении памяти для класса W часть, взятая из класса Y, наверняка будет иметь ненулевое смещение (скорее всего, сначала память будет выделена под X, потом под Y, и затем под W); тогда функции g() нельзя передавать this, поскольку она начнёт работать с объектом класса W как с объектом класса Y, что вызовет ошибку. Ситуация ещё больше осложняется при использовании виртуальных функций: если f() в классе X и g() в классе Y виртуальны, а в классе W описаны их замещения, то при использовании указателей на классы X или Y с объектами класса Z будут возникать ошибки (при вызовах f() и g() ). Они связаны с тем, как устроены таблицы виртуальных методов для разных классов. Выход - для каждого класса надо запоминать какое-то смещение и при необходимости добавлять его к this, а это дополнительные расходы на память. Обычно для реализации этой идеи служат слегка модифицированные таблицы виртуальных методов (это хорошо сочетается и с механизмом виртуальных функций). Получается, что за множественное наследование приходится "платить" даже в том случае, если оно не используется.
Есть ещё одна проблема связана с необходимостью так называемого "ромбовидного" наследования. Типичный пример - потоки ввода/вывода в C++:
ios - int fd;...
/ \