Лекции (1129116), страница 31
Текст из файла (страница 31)
В прошлый мы рассмотрели понятие чистых абстрактных классов. Есть также очень важное понятие чистой виртуальной функции:
virtual void f()=0;
Такая функция предназначена только для обозначения места в интерфейсе и ее основное назначение быть переопределенной в классах, производных от данного класса. Классы, в которых есть чистые виртуальные функции называются абстрактными классами. Это не то же самое, что абстрактный тип данных (АТД). АТД – это такой тип данных, который доступен только через функциональный интерфейс. В абстрактном классе ничего о скрытии деталей реализации не говорится. Абстрактный класс предназначен только для того, чтобы быть базовым в некоторой иерархии. На самом деле программирование в терминах абстрактных классов можно сопоставить с программированием и АТД, особенно применительно к множественному наследованию.
В чисто абстрактном классе (ЧАК) нет данных (за исключением статических), и кроме того, все функции члены являются чисто виртуальными. Т.е. ЧАК представляется только ссылкой на ТВМ (Таблица Виртуальных Методов). Оказывается, что ЧАК можно рассматривать как аналог интерфейса АТД. Поэтому между этими понятиями есть некоторое соответствие. При этом в роли реализации выступает класс, который наследует ЧАК.
З
десь может оказаться очень полезным множественное наследование. Если мы хотим реализовать класс, представляющий собой множество, реализованное на базе линейного списка то это можно сделать следующим образом. Нужно создать ЧАК Set, который содержит чисто виртуальные функции Insert, Delete, и т.д. Затем создать абсолютно неабстрактный класс (а скорее АТД) List, который предоставляет средства для работы со списком. И наконец, нужно создать класс List_Set, который является наследником классов Set и List и переопределяет функции класса Set. При этом никаких проблем с множественным наследованием не возникает, потому что класс Set не содержит никаких данных, т.е. наследование данных происходит единичным образом.
При этом класс List_Set может попутно реализовывать и другие абстракции, например стек, если унаследует соответствующий ЧАК Stack (тогда имя этого класса следует сделать другим, например List_Generic). Тогда объект этого класса сможет вести себя и как множество, и как стек, и как другие абстракции (если они будут реализованы). Главное, чтобы данные наследовались только единожды, чтобы не возникало проблем с множественным наследованием.
Возникает концепция множественных интерфейсов, которая сейчас очень популярна с точки зрения программирования. Например, идеология COM-объектов (Component Object Module) фирмы Microsoft как раз использует парадигму множественных интерфейсов. Любой COM-объект поддерживает некоторые интерфейсы, которые, с точки зрения С++, являются ЧАК. Сейчас существуют десятки интерфейсов, более того, программист может сам написать свой интерфейс при написании своего COM-объекта. Компонентное программирование сейчас поддерживают различные фирмы, поскольку это очень удобная штука. СОМ-программирование основывается на технологии OLE-2. OLE-2 как раз описывает механизмы реализации интерфейсов. С языковой точки зрения, компонентное программирование очень хорошо укладывается в концепцию ЧАК, и множественного наследования по одному направлению. В языке Java в чистом виде реализована эта концепция. Java стандартизует существующую практику программирования. Классическое наследование реализуется с помощью ключевого слова extends. Кроме того, есть еще ключевое слово implement, которое позволяет множественно наследовать интерфейсы.
class A extends B
implements список_интерфейсов
Класс А может стать абстрактным классом, если он что-то из этих интерфейсов недореализует. Сам интерфейс определяется так:
interface имя {
описание
}
Описание может включать либо статические члены, либо заголовки методов. Все методы, которые включаются в интерфейс, по определению будут чистыми виртуальными методами. В Java все методы виртуальны, а методы описанные в интерфейсе – чисто виртуальные.
Обратите внимание, что с точки зрения реализации языке С++, наличие виртуальных методов практически не добавляет накладных расходов. Накладные расходы будут при вызове виртуального метода, но не такие, как, например в языке Smaltalk, когда вызов метода требует пробежки по всему списку объектов. С точки зрения памяти, накладных расходов также немного, поскольку к объекту добавляется только ссылка на ТВМ.
Какие методы делать виртуальными? Конструкторы в языке С++ виртуальными по определению быть не могут. Для некоторых классов виртуальность рассматривать вообще не стоит (например, для класса Complex). Если в классе появляются виртуальные методы, то становится заведомо ясно, что стоит объявить виртуальным деструктор (хотя есть возможность в С++ не объявлять его виртуальным). Пусть в классе Х есть виртуальная функция f(), и пусть сам класс Х никаких ресурсов, которые нужно освобождать, не захватывает. Стоит ли писать деструктор? Не только стоит, но и нужно его объявить виртуальным. Известно, что этот класс будет вершиной некоторой иерархии (иначе ни имело бы смысла говорить о виртуальности). У этого класса может появиться наследник, который будет производить захват ресурсов, и следовательно, программист напишет для него некий деструктор. Если деструктор класса Х не будет виртуальным (или его вообще не будет), то деструктор наследника не будет вызван, например в таком коде:
X* px;
… //здесь px меняет свой динамический тип
delete px; // вызывается деструктор класса Х, который ничего не делает
Если же деструктор класса Х будет виртуальным, то вызовется нужный деструктор. Поэтому деструкторы в таких случаях всегда нужно делать виртуальными.
Динамическая идентификация типа.
С виртуализацией тесно связан вопрос динамической идентификации типа. Мы уже говорили об этом, когда начали рассматривать наследование в языке Оберон. В С++ первых версий механизма динамической идентификации типа (RTTI) не было. Идеология программирования с динамической идентификацией типа другая – это подтверждают примеры на языке Оберон. Наличие Оберона-2 говорит о том, что программирование с точки зрения виртуальных функций является более элегантным. Почему же RTTI все-таки появилась. Дело в том, что в некоторых случаях динамическая идентификация типа все-таки полезна. Причем чаще всего, она нужна при работе со стандартными библиотеками.
Представьте себе многооконную систему, в которой есть тип данных Window, есть вызов GetWindow(), который возвращает указатель на объект типа Window. Представьте, что у нас есть некоторый свой класс
MyDlgBox : public CDlgBox { //этот класс выводится из стандартного для
… //данной библиотеки класса, который выведен из Window
char* GetMyStrRes(); //возвращает какой-то наш ресурс в этом классе
}
При программировании в событийно-ориентированной среде код разбивается на небольшие кусочки, которые являются откликами на те или иные события. Допустим нам хочется посмотреть, какое окно в данный момент находится наверху. Мы делаем GetWindow(), если это наш диалог, нам хочется с ним что-то сделать. Но эту функцию нельзя переписать так, чтобы она возвращала наш класс MyDlgBox. Как нам определить, является ли данное окно нашим диалогом? Тут без динамической идентификации типа не обойтись, хотя настоящие примеры, демонстрирующие необходимость RTTI будут очень нетривиальны.
Есть хорошая книга Г. Шилдта "Теория и практика С++", в которой автор отмечает, что нельзя привести простого примера, который показывает, что без RTTI обойтись никак нельзя. Но в результате он приводит достаточно большой пример на эту тему.
А мы поговорим, каким же образом RTTI может быть вообще реализована. В языках Оберон, Delphi, Java механизм RTTI зашит изначально, за счет того, что по определению все объекты этих языков выводятся из единого корня. Причем соответствующий корневой класс уже имеет средства динамической идентификации типа. Такой подход по многим причинам был неприемлем для С++, хотя бы потому, что С++ не является языком объектно-ориентированного программирования – это мультипарадигмальный язык. Страуструп несколько запоздал с механизмом RTTI, и каждая библиотека реализовывала этот механизм по-своему.
В С++, есть подходящая ниша, которую можно использовать для механизма RTTI – это ТВМ. Динамическая идентификация типа имеет смысл только для объектов, имеющих виртуальные методы. В С++ динамическая идентификация типа невозможна для объектов, не содержащих виртуальных методов. Такое ограничение позволяет не менять реализацию объектов, а использовать ТВМ, а это означает совместимость с предыдущими версиями С++. В ТВМ для каждого класса дополнительно появляется информация о типе (например, имя типа, размеры, и т.д.).
В языке Оберон динамическая идентификация типа реализовывалась с помощью двух рассмотренных нами конструкций – проверки типа (t is T) и стража типа (t(T)). Страж типа – это контролируемое преобразование типа. Примерно тоже самое можно делать и в С++ и с помощью виртуальных функций, но чтобы этим воспользоваться, необходимо, чтобы все объекты исходили из единого корня, и нужно еще как-то стандартизовать информацию, выдаваемую виртуальными функциями. Недостаток библиотечных реализаций RTTI только один – то, что они все разные.
В стандартные средства RTTI языка С++ входит псевдофункция typeid(указ. либо ссыл. выражение или тип), которая возвращает ссылку на класс type_info. Гарантируется, что если два выражения обладают одним динамическим типом, то ссылки, возвращаемые typeid будут совпадать. Класс type_info описан приблизительно следующим образом:
class type_info {
… // приватная информация о типе
type_info(); // конструкторы умолчания и копирования приватны
type_info(const type_info&); //т.е. нельзя создавать объекты этого
//класса из соображений надежности
type_info& operator=(const type_info&); //присваивание тоже приватно
public:
bool operator==(const type_info&); //чтобы можно было реализовывать
bool operator!=(const type_info&); //проверку статического типа
virtual ~type_info(); // виртуальный деструктор
char* name(); // имя типа
}
Понятно, что этой информации мало, сравнений на равенство и неравенство мало. Кстати, есть еще функция getnext, которая позволяет получить следующий класс (не в смысле иерархии, а в смысле некоторого порядка). Используя эту функцию можно получить список всех классов с виртуальными функциями, используемых в данной программе. Проверки типа в данном случае работают со статическим типом.
В язык введено специальное понятие, которое позволяет избавится от проверки типа и просто проводить соответствующие преобразование – это динамическое преобразование типа:
dynamic_cast <тип> (выражение) //тип может быть либо ссылочным либо указательным
Эта конструкция выдает в качестве своего значения соответствующий тип. Синтаксически эта конструкция очень похожа на шаблон. В нашем примере с диалогом мы можем поступить следующим образом:
MyDlgBox* p;
Window* pw;
pw = GetWindow();
p = dynamic_cast <MyDlgBox*> (pw);
if(p!=NULL) { … p->GetMyStrRes();}
dynamic_cast очень похожа на страж типа в Обероне, но вместо выдачи сообщения об ошибке (если динамический тип объекта не является данным, либо производным от него) возвращается константа NULL. Если все в порядке, то эта конструкция выдает значение pw (в данном случае). dynamic_cast – это действительно безопасное преобразование типа. Это совершенно не эквивалентно следующему коду:
p = (MyDlgBox*) pw;
Такое преобразование типа небезопасно.
Конечно же, Страуструп не мог после этого не навести некий порядок с преобразованием типов вообще. Появилось еще два синтаксиса для преобразований, поскольку реально, бывают еще неконтролируемые преобразования указателей и ссылок, пользовательские преобразования (контролируются опереторами преобразования типов и конструкторами преобразования), и бывают еще преобразования "из литров в километры".
static_cast <тип> (выражение); //статическое преобразование ссылок и указ.
//полностью эквивалентное такому: p=(MyDlgBox*)pw;
//просто компилятор не будет ругаться
reinterpret_cast <тип> (выражение) //"из литров в километры"
//нужен, например, если надо напечатать значение указателя
Чтобы посмотреть механизм RTTI в языках Java и Delphi нужно просто открыть спецификацию соответствующего корневого класса и посмотреть ее.
Еще одно замечание по поводу виртуальных функций. Виртуальные функции обычно не дают накладных расходов, но иногда все же происходят очень нехорошие вещи. Классическим примером являются многооконные среды. Рассмотрим, например, систему Windows. В этой системе есть базовые наборы объектов, которые являются окнами. Окна общаются между собой путем посылки сообщений. У каждого окна есть цикл сообщений, в котором происходит обработка приходящих сообщений. Программирование становится чисто событийно-ориентированным. Посылка сообщения с точки зрения объектно-ориентированного программирования, эквивалентна вызову некоторого метода. В системе Windows есть около полутора сотен только стандартизированных сообщений. Причем на эти сообщения потенциально должен откликаться любой объект.
Представим, что есть класс CWindow, в котором есть около 150 обработчиков, которые выглядят как виртуальные методы. Производные от CWindow классы могут переопределять соответствующие обработчики. Но ТВМ уже состоит, как минимум, из150 полей. Кроме того, наследники могут добавлять в нее еще свои методы (их может быть тоже 150). Получается, что самое простое приложение с одним окном, меню и некотороми другими свойствами. Для каждого объекта окна придется держать огромную ТВМ, причем, реально приложение будет использовать только небольшое подмножество доступных ему обработчиков.
Когда возникает такая ситуация, что слишком много виртуальных методов оказывается на верхнем уровне, то нужно либо осуществлять редизайн системы, либо использовать какие-то нестандартные методы передачи информации. Такое явление называется трафиком атрибутов. Именно из-за этого Страуструп ввел множественное наследование, потому что очень часто получается так, что передут интерфейс объектов верхнего уровня. Т.е. поскольку система большая и универсальная, то объекты должны иметь очень разнообразный интерфейс. Получается так, что информация с нижних уровней иерархии идет наверх, и базовый класс раздувается. Страуструп попытался бороться с этой проблемой с помощью множественного наследования. В MFC для борьбы с трафиком атрибутов сделан несколько "кривой" механизм на базе макросов, но зато это сделано средствами языка.