Полиморфизм. Виртуальные функции
Концепция полиморфизма является очень важной в ООП. Этот термин, как показано выше, используется для описания процесса, при котором различные реализации функции могут быть доступны с использованием одного имени. В С++ полиморфизм поддерживается и во время компиляции, и во время исполнения программы. Рассмотренные выше перегрузки функций и операций — это примеры полиморфизма во время компиляции.
Предположим, что нам требуется создать иерархию классов для различных геометрических фигур (точка, линия, окружность, прямоугольник и др.) и разработать соответствующие методы для отображения фигур на экране компьютера (Show). При этом разные типы фигур будут отличаться тем, каким методом они это делают. Те же вопросы возникают для стирания, перемещения и других манипуляций с фигурами на экране. Рассмотренные ранее методы классов позволяют решить эту задачу определением функции Show для каждого класса. Но такой подход имеет существенный недостаток. При введении нового класса потребуется вносить изменения и выполнять повторную компиляцию вследствие появления нового метода с именем Show.
Заложенные в С++ механизмы определения того, какой метод следует в данный момент использовать, позволяют решить эту задачу одним из трех способов.
1. Имеются отличия в типах параметров в объявлении функции, например, при перегрузке функций – Show(int, char) и Show(int, *char) не одно и то же.
2. Задана операция доступа к области действия, поэтому Circle::Show отличается от Point::Show и ::Show.
3. Объект класса идентифицирует метод: Acircle.Show вызывает метод Circle::Show, а Apoint.Show – Point::Show. Аналогично и в случае указателей на объекты: pointptr->Show инициирует Point::Show.
Любой из указанных способов позволяет определить нужную функцию на этапе компиляции и поэтому носит название раннего (или статического) связывания.
Стандартная графическая библиотека предоставляет в распоряжение пользователя объявления классов в соответствующих исходных h-файлах и методы в объектных obj-файлах или библиотечных lib-файлах. Накладываемые при этом ограничения раннего связывания не позволяют пользователю легко добавлять новые классы. С++ предоставляет гибкий механизм для решения этих проблем с помощью специальных методов, называемых виртуальными функциями.
Полиморфизм во время исполнения программы реализуется с помощью виртуальных функций в классах, связанных отношением наследования. Виртуальные функции позволяют производным классам обеспечивать разные версии функции базового класса. Можно объявить виртуальную функцию в базовом классе и затем переопределить ее в любом производном классе. Решение о том, какая именно версия должна исполняться в данный момент, определяется на этапе исполнения программы и поэтому носит название позднего (или динамического) связывания. Класс, обеспечивающий интерфейс для множества других классов, называется полиморфным типом.
Для простоты можно сказать, что виртуальная функция — это функция, вызов которой зависит от типа объекта. На практике это означает, что решение о том, какую функцию Show вызывать, откладывается до момента выявления типа объекта на стадии исполнения. В языке С можно передать объект данных функции, при этом необходимо было задать его тип, когда писалась программа. В ООП можно писать виртуальные функции так, чтобы объект сам определял, какую функцию необходимо вызвать во время исполнения программы. Это достигается использованием указателей на базовые типы и виртуальных функций.
Указатели на производные типы.
Для лучшего понимания виртуальных функций важно понимать принцип взаимодействия классов в С++, связанных отношением наследования — указатели на базовый тип и на производный тип зависимы: указатель на базовый класс может ссылаться на объект этого класса или любой объект производного класса, но указатель производного класса не может ссылаться на объекты базового класса:
Объект базового класса
Указатель на базовый класс Объект производного Указатель на
класса производный
Объект производного класс
класса
Пусть есть цепь наследования классов: А <- B <- C. В программе объекты классов могут объявляться так:
A obA; B obB; C obC;
A* p;// объявлен указатель на базовый класс типаA*
p = &obB; // указателю р присвоен адрес объекта obB
Объект obB можно представлять как особый вид объекта класса А (так золотая рыбка — особая разновидность рыб, но это рыба). Но объект типа А не является особым видом объектов классов В и С. Например, вы обладаете многими чертами ваших родителей, наследуемых от них и их родителей (цвет волос, глаз). Ваши родители и их родители – часть вас, но вы не являетесь их частью.
Все элементы класса С, наследуемые от классов А и В, могут быть доступны через использование указателя р. Однако на элементы, объявленные (собственные) в классе С нельзя ссылаться, используя р. Если требуется иметь доступ к элементам, объявленным в производном классе, используя указатель на базовый класс, его надо привести к указателю на производный класс так:
(( С*)р) -> f(), где функция f() является элементом класса С, причем внешние скобки необходимы. И, наконец, указатель р изменяется при операциях ++ и -- относительно базового класса.
Пример 31.
Рассмотрим программу использования указателей на базовый класс из производных классов по цепи наследования: Base <- Derive <- Derive1.
#include<iostream.h>
#include<conio.h>
#include<stdio.h>
class Base // базовый класс Base
{ public:
void show(void)
{ cout<<"In Base class\n"; }
};
class Derive: public Base // производный класс от Base
{ public:
void show(void)
{ cout<<"In Derive class\n"; }
};
class Derive1: public Derive // производный класс от Base, Derive
{ public:
void show(void)
{ cout<<"In Derive1 class\n"; }
};
void main() // главная функция
{ clrscr();
Base bobj, *pb; // объект и указатель базового класса
Derive dobj, *pd; // объекты и указатели производных классов
Derive1 d1obj, *pd1;
pb=&bobj; // указатель на объект класса Base
bobj.show(); // вызов объектом функции show() класса Base
pb->show(); // вызов указателем функции show() класса Base
pd=&dobj; // указатель на объект класса Derive
dobj.show(); // вызов объектом функции show() класса Derive
pd->show(); // вызов указателем функции show() класса Derive
pd1=&d1obj; // указатель на объект класса Derive1
d1obj.show(); // вызов объектом функции show() класса Derive1
pd1->show(); // вызов указателем функции show() класса Derive1
pb=&dobj; // базовый указатель на объект класса Derive
pb->show(); // вызов указателем функции show() класса Base !
pb=&d1obj; // базовый указатель на объект класса Derive1
pb->show(); // вызов указателем функции show() класса Base !
((Derive1 *)pb)->show(); // вызов show() класса Derive1 указателем Base,
// приведенным к типу Derive1
getch();
}
Результаты программы:
In Base class
In Base class
In Derive class
In Derive class
In Derive1 class
In Derive1 class
In Base class !
In Base class !
In Derive1 class
Виртуальные функции.
Полиморфизм во время исполнения программы поддерживается использованием производных типов и виртуальных функций, которые объявляются со спецификатором virtual в базовом классе и переопределяемых в производных классах. При этом прототипы (интерфейс) таких функций должны быть одинаковы (имена, тип возвращаемого значения, число и типы аргументов не меняются). В противном случае функция будет рассматриваться как перегруженная, а не виртуальная.
Виртуальная функция должна быть элементом класса (она не может иметь спецификатор friend). Однако виртуальная функция может быть "другом" другого класса. Допустимо, чтобы деструктор имел спецификатор virtual, однако для конструктора это запрещено. Вследствие запретов и различий между перегрузкой обычных функций и переопределением виртуальных функций для них часто используется термин "замещение" (overriding).
Слово virtual означает "может быть замещено позднее в классе, производном от этого". Если функция объявлена виртуальной, то она сохраняет это свойство для любого производного класса (на любом уровне вложенности). Если в некотором производном классе функция не замещает виртуальную функцию (не объявлена, пропущена или имеет другой прототип), то вызывается версия виртуальной функции базового класса. При замещении функции в производном классе спецификатор virtual можно не повторять, хотя это не ошибка.
Пример 32.
Модифицируем программу примера 31 так, что в классе Base объявим функцию show() как виртуальную:
class Base // базовый класс Base
{ public:
virtual void show(void) // виртуальная функция класса
{ cout<<"In Base class\n";}
};
В главной функции исключим операторы вызова объектом функции show() (например, bobj.show(); и др.) и оставим вызовы функции show() только с помощью указателей (pb->show(); и др.).
Результаты программы примера 32:
In Base class
In Derive class
In Derive1 class
In Derive class
In Derive1 class
Особенность использования виртуальных функций — при вызове функции, объявленной виртуальной, определяется версия функции в зависимости от того, на объект какого класса будет ссылаться указатель базового класса.
Базовый класс задает основной интерфейс виртуальных функций, который будут иметь производные классы. Но производные классы задают свой метод согласно принципу полиморфизма: "один интерфейс – разные методы". Отделение интерфейса и реализации функций позволяет создавать библиотеки классов (class libraries).
Возникает вопрос: как осуществляется вызов виртуальной функции для активного объекта? Реализации компиляторов пользуются технологией преобразования имени виртуальной функции в индекс в таблице, содержащей указатели на функции, называемой "таблицей виртуальных функций" (virtual function table — vtbl). Каждый класс с виртуальными функциями имеет свою vtbl, идентифицирующую его виртуальные функции. Это можно изобразить схематически:
Объект класса Base: vtbl:
*pf1 | f1() | |
*pf2 | f2() |
Вызов виртуальных функций реализуется как непрямой вызов по vtbl. Эта таблица создается во время компиляции, а связывание происходит во время выполнения программы (отсюда термин позднее связывание). Функции в vtbl позволяют корректно использовать объект даже в тех случаях, когда ни размер объекта, ни расположение его данных не известны в месте вызова.
Пример 33.
Рассмотрим программу использования виртуальных функций при расширяющемся наследовании. Базовый класс Figure описывает плоскую фигуру для вычисления площади, которой достаточно двух измерений. Виртуальная функция show_area() печатает значение площади фигуры. На основе этого класса создаются производные классы – Triangle (треугольник), Rectangle (прямоугольник), Circle (круг с одним измерением), в которых определены конкретные формулы (методы) вычисления площадей. Классы связаны расширяющимся наследованием по схеме: Figure <- (Triangle, Rectangle, Circle).
#include<iostream.h>
#include<conio.h>
class Figure // базовый класс
{ protected: // защищенные элементы, доступные в производных классах
double x, y;
public:
void set_dim (double i, double j=0) // 2-й параметр по умолчанию
{ x = i, y = j; } // задание измерений фигур
virtual void show_area() // виртуальная функция Figure
{ cout<<"Площадь не определена для Figure\n";}
};
class Triangle: public Figure // производный класс
{ public:
void show_area() // виртуальная функция Triangle
{ cout<<"Треугольник с высотой "<< x <<" и основанием "<< y;
cout<<" имеет площадь = "<< x*0.5*y <<"\n";
}
};
class Rectangle: public Figure // производный класс
{ public:
void show_area() // виртуальная функция Rectangle
{ cout<<"Прямогольник со сторонами "<< x <<" и "<< y;
cout<<" имеет площадь = "<< x*y <<"\n";
}
};
class Circle: public Figure // производный класс
{ public:
void show_area() // виртуальная функция Circle
{ cout<<"Круг с радиусом "<< x;
cout<<" имеет площадь = "<< 3.14*x*x <<"\n";
}
};
void main ()
{ clrscr();
Figure f, *p; // объявление объекта и указателя класса Figure
Triangle t; // объявление объекта класса Triangle
Rectangle r; // объявление объекта класса Rectangle
Circle c; // объявление объекта класса Circle
p = &f; // указатель базового типа Figure
p -> set_dim (1,2); // задание размерностей объекта Figure
p -> show_area(); // вывод сообщения о площади объекта типа Figure
p = &t; // базовый указатель на объект типа Triangle
p -> set_dim (3,4); // задание размерностей объекта типа Triangle
p -> show_area(); // вывод сообщения о площади объекта типа Triangle
p = &r; // базовый указатель на объект типа Rectangle
p -> set_dim (5,6); // задание размерностей объекта типа Rectangle
p -> show_area(); // вывод сообщения о площади объекта типа Rectangle
p = &c; // базовый указатель на объект типа Circle
p -> set_dim (2); // задание размерностей объекта типа Circle
p -> show_area(); // вывод сообщения о площади объекта типа Circle
getch(); // задержка экрана результатов
}
Результаты программы:
Треугольник с высотой 3 и основанием 4 имеет площадь = 6
Прямогольник со сторонами 5 и 6 имеет площадь = 30
Круг с радиусом 2 имеет площадь = 12.56
Пример 34.
Рассмотрим программу использования виртуальных функций в классах с конструкторами. Вводится базовый класс Value, который задает некоторое значение. В этом классе описана виртуальная функция getvalue(), печатающая полученное значение. На основе этого класса строится производный класс Mult, вычисляющий произведение двух чисел. Он тоже имеет виртуальную функцию. Оба класса используют конструкторы.
#include<iostream.h>
#include<conio.h>
class Value // базовый класс
{ protected:
int value;
public:
Value (int n) { value = n; } // конструктор с параметром
virtual int getvalue () { return value; } // виртуальная функция
};
class Mult: public Value // производный класс
{ protected:
int mult;
public:
Mult (int n, int m): Value (n) // конструктор производного класса
{ mult = m;}
int getvalue () { return value * mult;}
};
void main ()
{ clrscr ();
cout<<"Работа программы";
Value *basep; // указатель базового типа
basep = new Value (10); // дин-ое создание объекта класса Value
cout<<"Для базового класса Value число = "<<basep->getvalue()<<endl;
delete basep; // освобождение динамической памяти объекта Value
basep = new Mult (10, 2); // базовый указатель на объект типа Mult
cout<<"Для производ. класса Mult число = "<<basep->getvalue()<<endl;
delete basep; //освобождение динамической памяти объекта типа Mult
}
Результаты программы:
Работа программы
Для базового класса Value число = 10
Для производ. класса Mult число = 20
Обычные или виртуальные функции.
Поскольку обычные функции выполняются несколько быстрее, чем виртуальные, то в том случае, когда скорость работы важнее, чем возможность расширения, следует использовать обычные функции. В противном случае, предпочтение следует отдавать виртуальным функциям.
Предположим, что объявляется класс Base с методом Action. Должен ли он быть виртуальным? Общее правило: если существует вероятность, что в производном от Base классе будет использована функция, перекрывающая Action, которой нужен доступ к Base, то Action должна быть виртуальной. Но она должна быть обычной функцией, если очевидно, что в производных типах Action будет выполнять те же действия (даже если это вызывает инициацию других виртуальных функций) или же производные типы не будут пользоваться Action.