4.5. Применение виртуальных функций
Сначала виртуальные функции могут показаться несколько сложными для понимания. Тем не менее, усилия, потраченные на изучение этой темы, оправдываются. Не только потому, что они являются важным инструментом объектно-ориентированного программирования, но еще и потому, что они широко используются в библиотеке MFC. Виртуальные функции позволяют писать простые универсальные подпрограммы для манипулирования объектами различных типов. Они дают возможность изменять свойства существующих базовых классов даже при отсутствии доступа к исходному коду этих классов.
Для понимания концепции виртуальных функций сравним еще раз приведенные выше классы CRectangle и CBlock. Вспомним: класс CBlock является производным от Crectangle. Следовательно, CRectangle – базовый по отношению к CBlock.
Оба этих класса содержат функцию Draw. Допустим, что объявлены экземпляры каждого класса
CRectangle Rect;
CBlock Block;
После таких объявлений версию функции Draw, определенную в классе CRectangle, будет вызывать оператор
Rect.Draw();
а версию функции Draw, определенную в рамках CBlock, будет вызывать оператор
Block.Draw();
В обоих случаях компилятор без проблем определит, какая версия функции вызывается, так как вызов функции содержит ссылку на экземпляр класса.
Однако в языке C++ принято использовать указатель на базовый класс, содержащий либо адрес экземпляра базового, либо адрес экземпляра производного классов. Например, рассмотрим следующий указатель на класс CRectangle.
CRectangle *PRect;
В C++ разрешается присвоить этому указателю адрес как экземпляра класса CRectangle, так и экземпляра класса, производного (прямо или косвенно) от CRectangle, без применения операции приведения типов. Например, в показанном ниже фрагменте программы допустимы оба присваивания.
CRectangle *PRect; // объявляет указатель на класс CRectangle
CRectangle Rect; // создает экземпляр класса CRectangle
CBlock Block; // создает экземпляр класса CBlock
PRect = &Rect; // допустимо: присваивает указателю адрес
// экземпляра CRectangle
PRect = sBlock; // также допустимо: присваивает указателю
// адрес экземпляра CBlock
Указатели на базовые классы
В C++ допускается присваивать адрес производного класса указателю базового, так как его использование в таком контексте вполне корректно. Указатель на базовый класс используется для доступа к членам, определенным только в базовом классе. Все они также определены внутри производного класса путем наследования. Следовательно, если указатель содержит адрес объекта производного класса, то можно получить значение любого члена класса, на который задана ссылка с помощью указателя.
Хотя компилятор может присвоить адрес базового класса указателю производного класса с помощью операции приведения типов, такой указатель небезопасен, так как может использоваться для доступа к членам, не определенным в данный момент в объекте, на который делается ссылка (базовый класс не всегда содержит все члены, определенные в производном).
Если для вызова функции Draw используется указатель PRect, может возникнуть проблема. Компилятор не может определить заранее, на какой тип объекта указывает PRect, пока программа не начнет выполняться. Поэтому компилятор всегда генерирует вызов версии Draw, определенной в классе CRectangle, так как указатель PRect объявлен как указатель на CRectangle.
Допустим, указатель PRect содержит адрес объекта Rect, являющегося экземпляром класса СRectangle.
CRectangle *PRect; CRectangle Rect;
// ...
PRect = &Rect;
В данном случае применение указателя PRect при вызове функции-члена Draw будет инициировать вызов версии Draw, определенной в классе CRectangle.
PRect->Draw();
Предположим теперь, что указатель PRect содержит адрес экземпляра класса Cblock.
CRectangle *PRect; CBlock Block;
// ...
PRect = SBlock;
При использовании этого указателя для вызова функции-члена Draw программа будет по-прежнему вызывать версию Draw, определенную в классе CRectangle.
PRect->Draw( );
В результате получится вызов неправильной версии функции Draw, создающей прозрачный, а не сплошной прямоугольник.
Решением такой проблемы может быть превращение Draw в виртуальную функцию. Определение Draw как виртуальной функции гарантирует, что при запуске программы будет вызвана корректная версия функции, даже если вызов будет осуществляться через указатель базового класса. Чтобы задать функцию Draw как виртуальную, нужно включить в ее объявление в классе CRectangle спецификатор virtual.
class CRectangle
{
// другие объявления ...
public:
virtual void Draw (void);
// другие объявления ...
}
Помните: спецификатор virtual нельзя включить в определение функции Draw, находящееся вне определения класса.
Спецификатор virtual можно включить и в объявление функции Draw в производном классе CBlock, хотя в этом нет необходимости.
class Cblock : public CRectangle
{
// другие объявления ...
public:
virtual void Draw (void);
// другие объявления ...
}
Если функция объявлена в базовом классе как виртуальная, то функция с таким же именем, типом возвращаемого значения и параметрами, объявляемая в производном классе, автоматически рассматривается как виртуальная. Следовательно, нет необходимости повторять спецификатор virtual в каждом производном классе, хотя иногда это облегчает чтение программы.
Если функция Draw определена как виртуальная и программа вызывает ее через указатель PRect, как показано ниже, компилятор автоматически не сгенерирует вызов версии Draw, определенной в классе CRectangle.
CRectangle *Prect;
// ...
PRect->Draw();
Вместо этого компилятор создает специальный код, вызывающий правильную версию функции Draw в процессе выполнения программы. Таким образом, следующие операторы приведут к вызову функции Draw, определенной в классе CRectangle.
CRectangle *PRect;
CRectangle Rect;
// ...
PRect = &Rect;
PRect->Draw ( );
В этом фрагменте вызывается функция Draw класса cblock.
CRectangle *PRect;
CBlock Block;
// ...
PRect = SBlock;
PRect->Draw( );
Так как действительный адрес функции до запуска программы не известен, такой механизм вызова называют поздним (или динамическим) связыванием. Стандартный механизм вызова функций, при котором компилятору заранее известен точный адрес вызова, называется ранним (или статическим) связыванием.
Совет
Программа должна содержать адрес правильной версии функции в каждом объекте. (Точнее, она хранит адрес таблицы адресов виртуальных функций.) Кроме дополнительного объема памяти, это требует косвенного вызова функций, что приводит к более медленной работе по сравнению с вызовом стандартных функций. Поэтому рекомендуется делать функцию-член виртуальной только тогда, когда требуется действительно позднее связывание.
Следующие два параграфа посвящены двум различным способам использования виртуальных функций в программе.
Полиморфизм
Поддержка виртуальных функций является важной характеристикой в объектно-ориентированном программировании и называется полиморфизмом. Полиморфизм – это способность использовать один и тот же оператор для выполнения различных действий. Действие, выполняемое в данный момент, определяется конкретным видом вызываемого объекта. Пример полиморфизма – вызов одной и той же функции
PRect->Draw( );
для рисования прямоугольников, блоков и блоков с закругленными углами. Конкретно выполняемое действие определяется классом объекта, на который в данный момент адресуется указателем PRect.
- 4.1. Производные классы
- 4.2. Конструкторы производных классов
- 4.3. Доступ к наследуемым переменным
- 4.4. Создание иерархии классов
- 4.5. Применение виртуальных функций
- 4.5.1. Применение виртуальных функций для управления объектами классов
- 4.5.2. Применение виртуальных функций для модификации базовых классов