Сайт о телевидении

Сайт о телевидении

» » Виртуальные функции. Виртуальные функции, чистые виртуальные функции

Виртуальные функции. Виртуальные функции, чистые виртуальные функции

Сергей Малышев (aka Михалыч)

Часть 1. Общая теория виртуальных функций

Посмотрев на название этой статьи, вы можете подумать: "Хм! Кто же не знает, что такое виртуальные функции! Это же..." Если это так, можете смело бросить чтение прямо на этом месте.

А для тех, кто только начинает разбираться в тонкостях С++, но уже имеет, скажем, начальные познания о такой вещи, как наследование, и что-то слышал о полиморфизме, имеет прямой смысл почитать этот материал. Если вы поймете виртуальные функции, то получите ключ к разгадке секретов успешного объектно-ориентированного проектирования.

Вообще говоря - материал не очень сложный. И все о чем тут будет говориться, несомненно, можно найти в книгах. Проблема только в том, что вы, пожалуй, не найдете полного изложения всей проблемы в одной, или двух книгах. Для того чтобы написать о виртуальных функциях, мне пришлось "проштудировать" 6 разных изданий. И даже в этом случае я совсем не претендую на полноту изложения. В списке литературы я указываю только основные, те, что натолкнули меня на стиль изложения и содержание.

Весь материал я решил разделить на 3 части.
Давайте в первой части попытаемся разобраться в общей теории виртуальных функций. Посмотрим во второй части их применение (и их мощь и силу!) на каком-либо более-менее жизненном примере. Ну, и в третей части еще поговорим о такой вещи, как виртуальные деструкторы.

Так что же это такое?

Давайте для начала вспомним, как в классическом программировании на С вы можете передать объект данных в функцию. Ничего сложного в этом нет, надо только задать тип передаваемого объекта в то время, когда вы пишете код функции. То есть, чтобы описать поведение объектов, необходимо заранее знать и описать их тип. Сила ООП в этом случае проявляется в том, что вы можете писать виртуальные функции так, чтобы объект сам определял, какую функцию ему необходимо вызвать, во время выполнения программы.

Говоря иными словами - с помощью виртуальных функций объект сам определяет свое поведение (собственные действия). Технику использования виртуальных функций как раз и называют полиморфизмом. Буквально полиморфизм означает обладание многими формами. Объект в вашей программе в действительности может представлять не один класс, а множество различных классов, если они связаны механизмом наследования с общим базовым классом. Ну и поведение объектов этих классов в иерархии, конечно же будет разным.

Ну вот, а теперь к делу!

Как известно, согласно правилам С++, указатель на базовый класс может ссылаться на объект этого класса, а также на объект любого другого класса, производного от базового. Понимание этого правила очень важно. Давайте рассмотрим простую иерархию неких классов А, В и С. А будет у нас базовым классом, В - выводится (порождается) из класса А, ну а С - выводится из В. Пояснения смотрите на рисунке.

В программе объекты этих классов могут быть объявлены, например, таким образом.

A object_A; //объявление объекта типа А
B object_B; //объявление объекта типа В
C object_C; //объявление объекта типа С

Согласно данному правилу указатель типа А может ссылаться на любой из этих трех объектов. То есть вот это будет верным:


point_to_Object=&object_C; //присвоим указателю адрес объекта С

А вот это уже не правильно:

В *point_to_Object; // объявим указатель на производный класс
point_to_Object=&object_А; //нельзя присвоить указателю адрес базового объекта

Несмотря на то, что указатель point_to_Object имеет тип А*, а не С* (или В*), он может ссылаться на объекты типа С (или В). Может быть правило будет более понятным, если вы будете думать об объекте С, как особом виде объекта А. Ну, например, пингвин - это особая разновидность птиц, но он все таки остается птицей, хоть и не летает. Конечно, эта взаимосвязь объектов и указателей работает только в одном направлении. Объект типа С - особый вид объекта А, но вот объект А не является особым видом объекта С. Возвращаясь к пингвинам смело можно сказать, что если бы все птицы были особым видом пингвинов - они бы просто не умели летать!

Этот принцип становится особенно важным, когда в классах, связанных наследованием определяются виртуальные функции. Виртуальные функции имеют точно такой же вид и программируются так же, как и самые обычные функции. Только их объявление производится с ключевым словом virtual . Например, наш базовый класс А может объявить виртуальную функцию v_function() .

class A
{
public:
virtual void v_function(void);//функция описывает некое поведение класса А
};

Виртуальная функция может объявляться с параметрами, она может возвращать значение, как и любая другая функция. В классе может объявляться столько виртуальных функций, сколько вам потребуется. И находится они могут в любой части класса - закрытой, открытой или защищенной.

Если в классе В, порожденном от класса А нужно описать коке-то другое поведение, то можно объявить виртуальную функцию, названную опять-таки v_function() .

class B: public A
{
public:
virtual void v_function(void);//замещающая функция описывает некое
//новое поведение класса В
};

Когда в классе, подобном В, определяется виртуальная функция, имеющая одинаковое имя с виртуальной функцией класса-предка, такая функция называется замещающей. Виртуальная функция v_function()в В замещает виртуальную функцию с тем же именем в классе А. На самом деле все несколько сложнее и не сводится к простому совпадению имен. Но об этом чуть позже, в разделе "Некоторые тонкости применения".
Ну, а теперь самое важное!

Вернемся к указателю point_to_Object типа А*, который ссылается на объект object_В типа В*. Давайте внимательно посмотрим на оператор, который вызывает виртуальную функцию v_function()для объекта, на который указывает point_to_Object .

A *point_to_Object; // объявим указатель на базовый класс
point_to_Object=&object_B; //присвоим указателю адрес объекта В
point_to_Object->;v_function(); //вызовем функцию

Указатель point_to_Object может хранить адрес объекта типа А или В. Значит во время выполнения этот оператор point_to_Object-gt;v_function(); вызывает виртуальную функцию класса на объект которого он в данный момент ссылается. Если point_to_Object ссылается на объект типа А, вызывается функция, принадлежащая классу А. Если point_to_Object ссылается на объект типа В, вызывается функция, принадлежащая классу В. Итак, один и тот же оператор вызывает функцию класса адресуемого объекта. Это и есть действие, определяемое во время выполнения программы.

Ну и что нам это дает?

Самое время посмотреть - а что же нам дают виртуальные функции? На теорию виртуальных функций в общих чертах мы взглянули. Пора рассмотреть какую-нибудь реальную ситуацию, где можно уяснить практическое значение рассматриваемого предмета в реальном мире программирования.

Классический пример (по моему опыту - в 90% всей литературы по С++), который приводят в этих целях - написание графической программы. Строится иерархия классов, что-то типа "точка -gt; линия -gt; плоская фигура -gt; объемная фигура". И рассматривается виртуальная функция, скажем, Draw(), которая рисует все это... Скучно!

Давайте рассмотрим менее академичный, но все же графический пример. (Классика! Куда от нее деться?). Попробуем рассмотреть гипотетически принцип, который может быть заложен в компьютерную игру. И не просто в игру, а в основу любого (не важно 3D или 2D, крутого или так себе) шутера. Стрелялки, проще говоря. Я не кровожаден по жизни, но, грешен, люблю иногда постреляться!

Итак, мы задумали сделать крутой шутер. Что понадобиться в первую очередь? Конечно же оружие! (Ну, пусть не в первую. Не важно.) В зависимости от того, на какую тему будем сочинять, такое оружие и понадобится. Может это будет набор от простой дубины до арбалета. Может от аркебуза до гранатомета. А может и вовсе от бластера до дезинтегратора. Скоро мы увидим, что это-то как раз и не важно.

Что же, раз есть такая масса возможностей, надо завести базовый класс.

class Weapon
{
public:
... //тут будут данные-члены, которыми может описываться, например, как
//толщина дубины, так и количество гранат в гранатомете
//эта часть для нас не важна

virtual void Use1(void);//обычно - левая кнопка мыши
virtual void Use2(void);//обычно - правая кнопка мыши

... //тут будут еще какие-то данные-члены и методы
};

Не вдаваясь в подробности этого класса, можно сказать, что самыми важными, пожалуй, будут функции Use1() и Use2(), которые описывают поведение (или применение) этого оружия. От этого класса можно порождать любые виды вооружения. Будут добавляться новые данные-члены (типа количества патронов, скорострельности, уровня энергии, длинны лезвия и т.п.) и новые функции. А переопределяя функции Use1() и Use2(), мы будем описывать различие в применении оружия (для ножа это может быть удар и метание, для автомата - стрельба одиночными и очередями).

Коллекцию вооружения надо где-то хранить. Видимо, проще всего организовать для этого массив указателей типа Weapon*. Для простоты предположим, что это глобальный массив Arms, на 10 видов оружия, и все указатели для начала инициализированы нулем.

Weapon *Arms; //массив указателей на объекты типа Weapon

Создавая в начале программы динамические объекты-виды оружия, будем добавлять указатели на них в массив.

Для того чтобы указать, какое оружие находится в пользовании, заведем переменную-индекс массива, значение которой будем изменять в зависимости от выбранного вида оружия.

int TypeOfWeapon;

В результате этих усилий, код, описывающий применение оружия в игре может выглядеть, например, так:

if(LeftMouseClick) Arms-gt;Use1();
else Arms->Use2();

Все! Мы создали код, который описывает стрельбу-пальбу-войну еще до того, как решили, какие типы оружия будут использоваться. Более того. У нас вообще еще нет ни одного реального типа вооружения! Дополнительная (иногда очень важная) выгода - этот код можно будет скомпилировать отдельно и хранить в библиотеке. В дальнейшем вы (или другой программист) можете вывести новые классы из Weapon, сохранить их в массиве Arms и использовать. При этом не потребуется перекомпиляции вашего кода.

Особо заметьте, что этот код не требует от вас точного задания типов данных объектов на которые ссылаются указатели Arms, требуется только, чтобы они были производными от Weapon. Объекты определяют во время выполнения, какую функцию Use() им следует вызвать.

Некоторые тонкости применения

Давайте немного времени уделим проблеме замещения виртуальных функций.

Вернемся к началу - к скучным классам А, В и С. Класс С на данный момент стоит у нас в самом низу иерархии, в конце линии наследования. В классе С точно также можно определить замещающую виртуальную функцию. Причем применять ключевое слово virtual совсем необязательно, поскольку это конечный класс в линии наследования. Функция и так будет работать и выбираться как виртуальная. Но! А вот если вам приспичит вывести некий класс D из класса С, да еще и изменить поведение функции v_function(), то тут как раз ничего и не выйдет. Для этого в классе С функция v_function() должна быть объявлена, как virtual. Отсюда правило, которое можно сформулировать так: "единожды виртуальный - всегда виртуальный!". То есть, ключевое слово virtual лучше не отбрасывать - вдруг пригодится?

Еще одна тонкость. В производном классе нельзя определять функцию с тем же именем и с тем же набором параметров, но с другим типом возвращаемого значения, чем у виртуальной функции базового класса. В этом случае компилятор выругается на этапе компиляции программы.

Далее. Если в производном классе ввести функцию с тем же именем и типом возвращаемого значения, что и виртуальная функция базового класса, но с другим набором параметров, то эта функция производного класса уже не будет виртуальной. Даже если вы сопроводите ее ключевым словом virtual, она не будет тем, что вы ожидали. В этом случае с помощью указателя на базовый класс при любом значении этого указателя будет выполняться обращение к функции базового класса. Вспомните правило о перегрузке функций! Это просто разные функции. У вас получится совсем другая виртуальная функция. Вообще говоря подобные ошибки весьма трудноуловимы, поскольку обе формы записи вполне допустимы и надеяться на диагностику компилятора в этом случае не приходится.

Отсюда еще одно правило. При замещении виртуальных функций требуется полное совпадение типов параметров, имен функций и типов возвращаемых значений в базовом и производном классах.

И еще. Виртуальной функцией может быть только нестатическая компонентная функция класса. Виртуальной не может быть глобальная функция. Виртуальная функция может быть объявлена дружественной (friend) в другом классе. Но о дружественных функциях мы поговорим как-нибудь в другой статье.

Вот, собственно, и все на этот раз.

В следующей части вы увидите полностью функциональный пример простейшей программы, которая демонстрирует все те моменты, о которых мы говорили.

Если у вас есть вопросы - пишите, будем разбираться.

Полиморфизм времени исполнения обеспечивается за счет использования производных классов и виртуальных функций. Виртуальная функция - это функция, объявленная с ключевым словом virtual в базовом классе и переопределенная в одном или в нескольких производных классах. Виртуальные функции являются особыми функциями, потому что при вызове объекта производ­ного класса с помощью указателя или ссылки на него С++ определяет во время исполнения про­граммы, какую функцию вызвать, основываясь на типе объекта. Для разных объектов вызываются разные версии одной и той же виртуальной функции. Класс, содержащий одну или более вир­туальных функций, называется полиморфным классом (polymorphic class).

Виртуальная функция объявляется в базовом классе с использованием ключевого слова virtual. Когда же она переопределяется в производном классе, повторять ключевое слово virtual нет не­обходимости, хотя и в случае его повторного использования ошибки не возникнет.

В качестве первого примера виртуальной функции рассмотрим следующую короткую программу:

// небольшой пример использования виртуальных функций
#include
class Base {
public:

cout << *Base\n";
}
};

public:
void who() { // определение who() применительно к first_d
cout << "First derivation\n";
}
};
class seconded: public Base {
public:

cout << "Second derivation\n*";
}
};
int main()
{
Base base_obj;
Base *p;
first_d first_obj;
second_d second_obj;
p = &base_obj;
p->
p = &first_obj;
p->
p = &second_ob;
p->who(); // доступ к who класса second_d
return 0;
}

Программа выдаст следующий результат:

Base
First derivation
Second derivation

Проанализируем подробно эту программу, чтобы понять, как она работает.

Как можно видеть, в объекте Base функция who() объявлена как виртуальная. Это означает, что эта функция может быть переопределена в производных классах. В каждом из классов first_d и second_d функция who() переопределена. В функции main() определены три переменные. Первой является объект base_obj, имеющий тип Base. После этого объявлен указатель р на класс Base, затем объекты first_obj и second_obj, относящиеся к двум производным классам. Далее указателю р при­своен адрес объекта base_objи вызвана функция who(). Поскольку эта функция объявлена как виртуальная, то С++ определяет на этапе исполнения, какую из версий функции who() употребить, в зависимости от того, на какой объект указывает указатель р. В данном случае им является объект типа Base, поэтому исполняется версия функции who(), объявленная в классе Base. Затем указате­лю р присвоен адрес объекта first_obj. (Как известно, указатель на базовый класс может быть ис­пользован для любого производного класса.) После того, как функция who() была вызвана, С++ снова анализирует тип объекта, на который указывает р, для того, чтобы определить версию фун­кции who(), которую необходимо вызвать. Поскольку р указывает на объект типа first_d, то ис­пользуется соответствующая версия функции who(). Аналогично, когда указателю р присвоен адрес объекта second_obj, то используется версия функции who(), объявленная в классе second_d.

Наиболее распространенным способом вызова виртуальной функции служит использование параметра функции. Например, рассмотрим следующую модификацию предыдущей программы:

/* Здесь ссылка на базовый класс используется для доступа к виртуальной функции */
#include
class Base {
public:
virtual void who() { // определение виртуальной функции
cout << "Base\n";
}
};
class first_d: public Base {
public:
void who () { // определение who() применительно к first_d
cout << "First derivation\n";
}
};

public:
void who() { // определение who() применительно к second_d
cout << "Second derivation\n*";
}
};
// использование в качестве параметра ссылки на базовый класс
void show_who (Base &r) {
r.who();
}
int main()
{
Base base_obj;
first_d first_obj;
second_d second_obj;
show_who (base_ob j) ; // доступ к who класса Base
show_who(first_obj); // доступ к who класса first_d
show_who(second_obj); // доступ к who класса second_d
return 0;
}

Эта программа выводит на экран те же самые данные, что и предыдущая версия. В данном при­мере функция show_who() имеет параметр типа ссылки на класс Base. В функции main() вызов виртуальной функции осуществляется с использованием объектов типа Base, first_d и second_d. Вызываемая версия функции who() в функции show_who() определяется типом объекта, на кото­рый ссылается параметр при вызове функции.

Ключевым моментом в использовании виртуальной функции для обеспечения полиморфизма времени исполнения служит то, что используется указатель именно на базовый класс. Полимор­физм времени исполнения достигается только при вызове виртуальной функции с использовани­ем указателя или ссылки на базовый класс. Однако ничто не мешает вызывать виртуальные функ­ции, как и любые другие «нормальные» функции, однако достичь полиморфизма времени исполнения на этом пути не удается.

На первый взгляд переопределение виртуальной функции в производном классе выглядит как специальная форма перегрузки функции. Но это не так, и термин перегрузка функции не приме­ним к переопределению виртуальной функции, поскольку между ними имеются существенные раз­личия. Во-первых, функция должна соответствовать прототипу. Как известно, при перегрузке обычной функции число и тип параметров должны быть различными. Однако при переопределе­нии виртуальной функции интерфейс функции должен в точности соответствовать прототипу. Если же такого соответствия нет, то такая функция просто рассматривается как перегруженная и она утрачивает свои виртуальные свойства. Кроме того, если отличается только тип возвращаемо­го значения, то выдается сообщение об ошибке. (Функции, отличающиеся только типом возвра­щаемого значения, порождают неопределенность.) Другим ограничением является то, что вирту­альная функция должна быть членом, а не другом класса, для которого она определена. Тем не менее виртуальная функция может быть другом другого класса. Хотя деструктор может быть виртуальным, но конструктор виртуальным быть не может.

В силу различий между перегрузкой обычных функций и переопределением виртуальных фун­кций будем использовать для последних термин переопределение (overriding).

Если функция была объявлена как виртуальная, то она и остается таковой вне зависимости от количества уровней в иерархии классов, через которые она прошла. Например, если класс second_d получен из класса first_d, а не из класса Base, то функция who() останется виртуальной и будет вызываться корректная ее версия, как показано в следующем примере:

// порождение от first_d, а не от Base
class second_d: public first_d {
public:
void who() { // определение who() применительно к second_d
cout << "Second derivation\n*";
}
};

Если в производном классе виртуальная функция не переопределяется, то тогда используется ее версия из базового класса. Например, запустим следующую версию предыдущей программы:

#include
class Base {
public:
virtual void who() {
cout << "Base\n";
}
};
class first_d: public Base {
public:
void who() {
cout << "First derivation\n";
}
};
class second_d: public Base {
// who() не определяется
};
int main()
{
Base base_obj;
Base *p;
first_d first_obj; ,
second_d second_obj;
p = &base_obj;
p->who(); // доступ к who класса Base
p = &first obj;
p->who(); // доступ к who класса first_d
p = &sepond_ob;
p->who(); /* доступ к who() класса Base, поскольку second_d не переопределяет */
return 0;
}

Эта программа выдаст следующий результат:

Base
First derivation
Base

Надо иметь в виду, что характеристики наследования носят иерархический характер. Чтобы проиллюстрировать это, предположим, что в предыдущем примере класс second_d порожден от класса first_d вместо класса Base. Когда функцию who() вызывают, используя указатель на объект типа second_d (в котором функция who() не определялась), то будет вызвана версия функции who(), объявленная в классе first_d, поскольку этот класс - ближайший к классу second_d. В общем случае, когда класс не переопределяет виртуальную функцию, С++ использует первое из определений, которое он находит, идя от потомков к предкам.

Как отмечалось ранее, виртуальные функции в комбинации с производными типами позволяют языку С++ поддерживать полиморфизм времени исполнения. Этот полиморфизм ва­жен для объектно-ориентированного программирования, поскольку он позволяет переопреде­лять функции базового класса в классах-потомках с тем, чтобы иметь их версию применительно к данному конкретному классу. Таким образом, базовый класс определяет общий интерфейс, кото­рый имеют все производные от него классы, и вместе с тем полиморфизм позволяет производным классам иметь свои собственные реализации методов. Благодаря этому полиморфизм часто опре­деляют фразой «один интерфейс - множество методов».

Успешное применение полиморфизма связано с пониманием того, что базовые и производные классы образуют иерархию, в которой переход от базового к производному классу отвечает пере­ходу от большей к меньшей общности. Поэтому при корректном использовании базовый класс обес­печивает все элементы, которые производные классы могут непосредственно использовать, плюс набор функций, которые производные классы должны реализовать путем их переопределения.

Наличие общего интерфейса и его множественной реализации является важным постольку, поскольку помогает программистам разрабатывать сложные программы. Например, доступ ко всем объектам, производным некоторого базового класса, осуществляется одинаковым способом, даже если реальные действия этих объектов отличаются при переходе от одного производного класса к другому. Это означает, что необходимо запомнить только один интерфейс, а не не­сколько. Более того, отделение интерфейса от реализации позволяет создавать библиотеки клас­сов, поставляемые независимыми разработчиками. Если эти библиотеки реализованы корректно,
то они обеспечивают общий интерфейс, и их можно использовать для вывода своих собственных специфических классов.

Чтобы понять всю мощь идеи «один интерфейс - множество методов», рассмотрим следую­щую короткую программу. Она создает базовый класс figure. Этот класс используется для хране­ния размеров различных двумерных объектов и для вычисления их площадей. Функция set_dim() является стандартной функцией-членом, поскольку ее действия являются общими для всех произ­водных классов. Однако функция show_area() объявляется как виртуальная функция, поскольку способ вычисления площади каждого объекта является специфическим. Программа использует класс figure для вывода двух специфических классов square и triangle.

#include
class figure {
protected:
double x, y;
public:
void set_dim(double i, double j) {
x = i;
у = j;
}
virtual void show_area() {
cout << "No area computation defined ";
cout << "for this class. \n";
}
};

public:
void show_area() {
cout << "Triangle with height ";
cout << x << " and base " << y;
cout << " has an area of ";
cout << x * 0.5 * у << ". \n";
}
};

public:
void show_area() {
cout << "Square with dimensions ";
cout << x << "x" << y;
cout << " has an area of ";
cout << x * у << ". \n";
}
};
int main ()
{


square s;
р = &t;
p->set_dim(10.0, 5.0);
p->show_area();
p = &s;
p->set_dim(10.0, 5.0);
p->show_area ();
return 0;
}

Как можно видеть на основе анализа этой программы, интерфейс классов square и triangle явля­ется одинаковым, хотя оба обеспечивают свои собственные методы для вычисления площади каж­дой из фигур. На основе объявления класса figure можно вывести класс circle, вычисляющий пло­щадь, ограниченную окружностью заданного радиуса. Для этого необходимо создать новый производный класс, в котором реализовано вычисление площади круга. Вся сила виртуальной функции основана на том факте, что можно легко вывести новый класс, разделяющий один и тот же общий интерфейс с другими подобными объектами. В качестве примера здесь показан один из способов реализации:


public:
void show_area() {
cout << "Circle with radius ";
cout << x;
cout << "has an area of ";
cout << 3.14 * x * x;
}
};

Прежде чем использовать класс circle, посмотрим внимательно на определение функции show_area(). Обратим внимание, что она использует только величину х, которая выражает ради­ус. Как известно, площадь круга вычисляется по формуле π R 2 . Однако функция set_dim(), опре­деленная в классе figure, требует не одного, а двух аргументов. Поскольку класс circle не нужда­ется во второй величине, то как же нам быть в данной ситуации?

Имеются два пути для решения этой проблемы. Первый заключается в том, чтобы вызвать set_dim(), используя в качестве второго параметра фиктивный параметр, который не будет ис­пользован. Недостатком такого подхода служит необходимость запомнить этот исключительный случай, что по существу нарушает принцип «один интерфейс - множество методов».

Лучшее решение данной проблемы связано с использованием параметра у в set_dim() со значе­нием по умолчанию. В таком случае при вызове set_dim() для круга необходимо указать только радиус. При вызове set_dim() для треугольника или прямоугольника укажем обе величины. Ниже показана программа, реализующая этот подход:

#include
class figure {
protected:
double x, y;
public:
void set_dim (double i, double j=0) {
x = i;
y = j;
}
virtual void show_area() {
cout << "No area computation defined ";
cout << "for this class .\n";
}
};
class triangle: public figure {
public:
void show_area() {
cout << "Triangle with height ";
cout << x << " and base " << y;
cout << " has an area of ";
cout << x * 0.5 * у << ". \n";
}
};
class square: public figure {
public:
void show_area() {
cout << "Square with dimensions ";
cout << x << "x" << y;
cout << " has an area of ";
cout << x * у << ". \n";
}
};
class circle: public figure {
public:
void show_area() {
cout << "Circle with radius ";
cout << x;
cout << has an area of ";
cout << 3.14 * x * x;
}
};
int main ()
{
figure *p; /* создание указателя базового типа */
triangle t; /* создание объектов порожденных типов */
square s;
circle с;
р = &t;
p->set_dim(10.0, 5.0);
p->show_area ();
p = &s;
p->set_dim(10.0, 5.0);
p->show_area ();
p = &c;
p->set_dim(9. 0) ;
p->show_area ();
return 0;
}

Этот пример также показывает, что при определении базового класса важно проявлять максималь­но возможную гибкость. Не следует налагать на программу какие-то ненужные ограничения.

Чисто виртуальные функции

К механизму виртуальных функций обращаются в тех случаях, когда в базовый класс необходимо поместить функцию, которая должна по-разному выполняться в производных классах. Точнее, по-разному должна выполняться не единственная функция из базового класса, а в каждом производственном классе требуется свой вариант этой функции.

До объяснения возможностей виртуальных функций отметим, что классы, включающие такие функции, играют особую роль в объектно-ориентированном программировании. Именно поэтому они носят специальное название - полиморфные. .

Виртуальными могут быть не любые функции, а только нестатические компонентные функции какого-либо класса. После того как функция определена как виртуальная, ее повторное определение в производном классе (с тем же самым прототипом) создает в этом классе новую виртуальную функцию, причем спецификатор virtual может не использоваться.

В производном классе нельзя определять функцию с тем же именем и с тем же набором параметров, но с другим типом возвращаемого значения, чем у виртуальной функции базового класса. Это приводит к ошибке на этапе компиляции.

Если в производном классе ввести функцию с тем же именем и типом возвращаемого значения, что и виртуальная функция базового класса, но с другим набором параметров, то эта функция производного класса не будет виртуальной. В этом случае с помощью указателя на базовый класс при любом значении этого указателя выполняется обращение к функции базового класса (несмотря на спецификатор virtual и присутствие в производном классе похожей функции).

Методы(функции)

Виртуальные методы объявляются в базовом классе с ключевым словом virtual, а в производном классе могут быть переопределены. Прототипы виртуальных методов как в базовом, так и в производном классе должны быть одинаковы.

Применение виртуальных методов позволяет реализовывать механизм позднего связывания, при котором определение вызываемого метода происходит на этапе выполнения, а не на этапе компиляции. При этом вызываемый виртуальный метод зависит от типа объекта, для которого он вызывается. При раннем связывании, используемом для не виртуальных методов, определение вызываемого метода происходит на этапе компиляции.

На этапе компиляции строится таблица виртуальных методов, а конкретный адрес проставляется уже на этапе выполнения.

При вызове метода с использованием указателя на класс действуют следующие правила:

  • для виртуального метода вызывается метод, соответствующий типу объекта, на который указывает указатель.
  • для не виртуального метода вызывается метод, соответствующий типу самого указателя.

В следующем примере иллюстрируется вызов виртуальных методов:

Class A // Объявление базового класса{ public: virtual void VirtMetod1(); // Виртуальный метод void Metod2(); // Не виртуальный метод};void A::VirtMetod() { cout << "Вызван A::VirtMetod1\n";} void A::Metod2() { cout << "Вызван A::Metod2\n"; } class B: public A // Объявление производного класса{public: void VirtMetod1(); // Виртуальный метод void Metod2(); // Не виртуальный метод};void B::VirtMetod1() { cout << "B::VirtMetod1\n";}void B::Metod2() { cout << "B::Metod2\n"; }void main() { B aB; // Объект класса B B *pB = &aB; // Указатель на объект класса B A *pA = &aB; // Указатель на объект класса A pA->VirtMetod1(); // Вызов метода VirtMetod класса B pB->VirtMetod1(); // Вызов метода VirtMetod класса B pA->Metod2(); // Вызов метода Metod2 класса A pB->Metod2(); // Вызов метода Metod2 класса B}

Результатом выполнения этой программы будут следующие строки:

Вызван B::VirtMetod1Вызван B::VirtMetod1Вызван A::Metod2Вызван B::Metod2

Чисто виртуальной функцией называется виртуальная функция, указанная с инициализатором

Например:

Virtual void F1(int) =0;

Объявление класса может содержать виртуальный деструктор, используемый для удаления объекта определенного типа. Однако виртуального конструктора в языке С++ не существует. Некоторой альтернативой, позволяющей создавать объекты заданного типа, могут служить виртуальные методы, в которых выполняется вызов конструктора для создания объекта данного класса.

Начну с повторения: вы не должны вызывать виртуальные функции во время работы конструкторов или деструкторов, потому что эти вызовы будут делать не то, что вы думаете, и результатами их работы вы будете недовольны. Если вы – программист на Java или C#, то обратите на это правило особое внимание, потому что это в этом отношении C++ ведет себя иначе.

Предположим, что имеется иерархия классов для моделирования биржевых транзакций, то есть поручений на покупку, на продажу и т. д. Важно, чтобы эти транзакции было легко проверить, поэтому каждый раз, когда создается новый объект транзакции, в протокол аудита должна вноситься соответствующая запись. Следующий подход к решению данной проблемы выглядит разумным:


class Transaction { // базовый класс для всех

public: // транзакций

virtual void logTransaction() const = 0; // выполняет зависящую от типа

// запись в протокол

Transaction::Transaction() // реализация конструктора

{ // базового класса

logTransaction();

class BuyTransaction: public Transaction { // производный класс

// транзакции данного типа

class SellTransaction: public Transaction { // производный класс

virtual void logTransaction() const = 0; // как протоколировать

// транзакции данного типа


Посмотрим, что произойдет при исполнении следующего кода:


BuyTransaction b;


Ясно, что будет вызван конструктор BuyTransaction, но сначала должен быть вызван конструктор Transaction, потому что части объекта, принадлежащие базовому классу, конструируются прежде, чем части, принадлежащие производному классу. В последней строке конструктора Transaction вызывается виртуальная функция logTransaction, тут-то и начинаются сюрпризы. Здесь вызывается та версия logTransaction, которая определена в классе Transaction, а не в BuyTransaction, несмотря на то что тип создаваемого объекта – BuyTransaction. Во время конструирования базового класса не вызываются виртуальные функции, определенные в производном классе. Объект ведет себя так, как будто он принадлежит базовому типу. Короче говоря, во время конструирования базового класса виртуальных функций не существует.

Есть веская причина для столь, казалось бы, неожиданного поведения. Поскольку конструкторы базовых классов вызываются раньше, чем конструкторы производных, то данные-члены производного класса еще не инициализированы во время работы конструктора базового класса. Это может стать причиной неопределенного поведения и близкого знакомства с отладчиком. Обращение к тем частям объекта, которые еще не были инициализированы, опасно, поэтому C++ не дает такой возможности.

Есть даже более фундаментальные причины. Пока над созданием объекта производного класса трудится конструктор базового класса, типом объекта является базовый класс. Не только виртуальные функции считают его таковым, но и все прочие механизмы языка, использующие информацию о типе во время исполнения (например, описанный в правиле 27 оператор dynamic_cast и оператор typeid). В нашем примере, пока работает конструктор Transaction, инициализируя базовую часть объекта BuyTransaction, этот объект относится к типу Transaction. Именно так его воспринимают все части C++, и в этом есть смысл: части объекта, относящиеся к BuyTransaction, еще не инициализированы, поэтому безопаснее считать, что их не существует вовсе. Объект не является объектом производного класса до тех пор, пока не начнется исполнение конструктора последнего.

То же относится и к деструкторам. Как только начинает исполнение деструктор производного класса, предполагается, что данные-члены, принадлежащие этому классу, не определены, поэтому C++ считает, что их больше не существует. При входе в деструктор базового класса наш объект становится объектом базового класса, и все части C++ – виртуальные функции, оператор dynamic_cast и т. п. – воспринимают его именно так.

В приведенном выше примере кода конструктор Transaction напрямую обращается к виртуальной функции, что представляет собой откровенное нарушение принципов, описанных в данном правиле. Это нарушение легко обнаружить, поэтому некоторые компиляторы выдают предупреждение (а другие – нет; дискуссию о предупреждениях см. в правиле 53). Но даже без такого предупреждения ошибка наверняка проявится до времени исполнения, потому что функция logTransaction в классе Transaction объявлена чисто виртуальной. Если только она не была где-то определена (маловероятно, но возможно – см. правило 34), то такая программа не скомпонуется: компоновщик не найдет необходимую реализацию Transaction::logTransaction.

Не всегда так просто обнаружить вызов виртуальной функции во время работы конструктора или деструктора. Если Transaction имеет несколько конструкторов, каждый из которых выполняет одну и ту же работу, то следует проектировать программу так, чтобы избежать дублирования кода, поместив общую часть инициализации, включая вызов logTransaction, в закрытую невиртуальную функцию инициализации, скажем, init:


class Transaction {

{ init(); } // вызов невиртуальной функции

Virtual void logTransaction() const = 0;

logTransaction(); // а это вызов виртуальной

// функции!


Концептуально этот код не отличается от приведенного выше, но он более коварный, потому что обычно будет скомпилирован и скомпонован без предупреждений. В этом случае, поскольку logTransaction – чисто виртуальная функция класса Transaction, в момент ее вызова большинство систем времени исполнения прервут программу (обычно выдав соответствующее сообщение). Однако если logTransaction будет «нормальной» виртуальной функцией, у которой в классе Transaction есть реализация, то эта функция и будет вызвана, и программа радостно продолжит работу, оставляя вас в недоумении, почему при создании объекта производного класса была вызвана неверная версия logTransaction. Единственный способ избежать этой проблемы – убедиться, что ни один из конструкторов и деструкторов не вызывает виртуальных функций при создании или уничтожении объекта, и что все функции, к которым они обращаются, следуют тому же правилу.

Но как вы можете убедиться в том, что вызывается правильная версия log-Transaction при создании любого объекта из иерархии Transaction? Понятно, что вызов виртуальной функции объекта из конструкторов не годится.

Есть разные варианты решения этой проблемы. Один из них – сделать функцию logTransaction невиртуальной в классе Transaction, затем потребовать, чтобы конструкторы производного класса передавали необходимую для записи в протокол информацию конструктору Transaction. Эта функция затем могла бы безопасно вызвать невиртуальную logTransaction. Примерно так:


class Transaction {

explicit Transaction(const std::string& loginfo);

void logTransaction(const std::string& loginfo) const; // теперь –

// невиртуальная

// функция

Transaction::Transaction(const std::string& loginfo)

logTransaction(loginfo); // теперь –

// невиртуальный

class BuyTransaction: public Transaction {

BuyTransaction(parameters )

: Transaction(createLogString(parameters )) // передать информацию

{...} // для записи в протокол

... // конструктору базового

static std::string createLogString(parameters );


Другими словами, если вы не можете вызывать виртуальные функции из конструктора базового класса, то можете компенсировать это передачей необходимой информации конструктору базового класса из конструктора производного.

В этом примере обратите внимание на применение закрытой статической функции createLogString в BuyTransaction. Использование вспомогательной функции для создания значения, передаваемого конструктору базового класса, часто удобнее (и лучше читается), чем отслеживание длинного списка инициализации членов для передачи базовому классу того, что ему нужно. Сделав эту функцию статической, мы избегаем опасности нечаянно сослаться на неинициализированные данные-члены класса BuyTransaction. Это важно, поскольку тот факт, что эти данные-члены еще не определены, и является основной причиной, почему нельзя вызывать виртуальные функции из конструкторов и деструкторов.