Лекция 17. Виртуальные функции и абстрактные классы

1. Виртуальные функции

Функция-член класса может содержать спецификатор virtual. Такая функция называется виртуальной. Спецификатор virtual может быть использован только в объявлениях нестатических функций-членов класса.

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

class Base { public: virtual void f1(); virtual void f2(); virtual void f3(); void f(); };
class Derived : public Base { public: void f1(); void f2(int); char f3(); void f(); }; // Скрывает Base::f2() // Ошибка – различие только в типе возвращаемого значения!
Derived *dp = new Derived; Base *bp = dp; bp->f(); dp->f(); dp->Base::f(); dp->f1(); bp->f1(); bp->Base::f1(); bp->f2(); dp->f2(0); dp->f2(); dp->Base::f2(); // Преобразование указателя на производный класс в указатель на базовый класс // Вызов Base::f // Вызов Derived::f // Вызов Base::f // Вызов Derived::f1 // Всё равно вызов Derived::f1!!! // Вызов Base::f1 (использование явного квалификатора блокирует механизм виртуальности) // Вызов Base::f2 // Вызов Derived::f2 // Ошибка, т.к. не задан параметр // Вызов Base::f2

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

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

Этот механизм делает производные классы и виртуальные функции ключевыми понятиями при разработке программ на С++. Базовый класс определяет интерфейс, для которого производные классы обеспечивают набор реализаций. Указатель на объект класса может передаваться в контекст, где известен интерфейс, определённый одним из его базовых классов, но этот производный класс неизвестен. Механизм виртуальных функций гарантирует, что этот объект всё равно будет обрабатываться функциями, определёнными для него, а не для базового класса.

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

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

Виртуальная функция может быть объявлена дружественной в другом классе.

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

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

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

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

1.1. Виртуальные деструкторы

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

1.2. «Виртуальные конструкторы»

После знакомства с виртуальными деструкторами возникает вопрос: «Может ли конструктор быть виртуальным?» Ответ – нет, но желаемый эффект можно получить достаточно просто.

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

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

class Base { ... public: Base(); Base(const Base& b); virtual Base* Create() { return new Base(); } virtual Base* Clone() { return new Base(*this); } }; // Конструктор умолчания // Конструктор копирования // Создание нового объекта // Копирование объекта

Так как функции вроде Create() и Clone() являются виртуальными и создают (косвенно) объекты, их часто называют «виртуальными конструкторами». Однако на самом деле они не являются конструкторами в обычном понимании, просто каждая из них использует конструктор для создания подходящего объекта.

Для создания объекта собственного типа производный класс может заместить функции Create() и/или Clone().

class Derived : public Base { ... public: Derived(); Derived(const Derived& d); Derived* Create() { return new Derived(); } Derived* Clone() { return new Derived(*this); } };

Теперь имея объект класса Base или производного от него класса, можно создать новый объект такого же типа.

void f(Base *p) { Base *p1 = p->Create(); } // Указатель, присвоенный p1, имеет корректный, но неизвестный тип

Значения, возвращаемые функциями Derived::Create() и Derived::Clone(), имеют тип Derived*, а не Base*. Это позволяет при необходимости создавать новые объекты без потери информации о типе.

void f2(Derived *p) { Derived *p2 = p->Clone(); }

Тип подменяющей функции должен быть таким же, как тип виртуальной функции, которую она подменяет, за исключением того, что допускаются послабления по отношению к типу возвращаемого значения. Если исходный тип возвращаемого значения был B*, то тип возвращаемого значения подменяющей функции может быть D* при условии, что B является открытым базовым классом для D. Аналогично, вместо B& тип возвращаемого значения может быть ослаблен до D&.

2. Абстрактные классы

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

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

Виртуальная функция называется чистой, если в объявлении функции внутри объявления класса задан чистый спецификатор = 0.

class Shape { public: virtual void draw() = 0; ... }; // Чистая виртуальная функция

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

Shape s; Shape *s; Shape f(); void f(Shape s); Shape& f(Shape &s); // Ошибка: объект абстрактного класса // Всё правильно // Ошибка // Ошибка // Всё правильно

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

3. Параметризация и наследование

Шаблон является механизмом параметризации определения класса или функции произвольным типом. Код, реализующий шаблон, идентичен для всех типов параметров, также как и большая часть кода, использующая шаблон. Абстрактный класс определяет интерфейс. Большая часть кода различных реализаций абстрактного класса может совместно использовать в иерархии классов, и большинство фрагментов, использующих абстрактный класс, не зависит от его реализации. С точки зрения проектирования оба подхода настолько близки, что заслуживают общего названия. Так как оба метода позволяют выразить алгоритм один раз и использовать его со множеством типов, их вместе часто называют полиморфными. Для того чтобы всё-таки их различать, то, что обеспечивают виртуальные функции, называют полиморфизмом времени выполнений, а то, что предоставляют шаблоны, – полиморфизмом времени компиляции или параметрическим полиморфизмом.

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

4. Пример «Геометрические фигуры»

Перепишем пример, использовавшийся в лекции 16, – теперь базовый класс Shapes будет реализован как абстрактный класс.

Файл Shapes.h

#define SHAPES class Shapes // Класс Shapes является абстрактным, т.к. содержит чистые виртуальные функции { protected: static int count; int color; int left, top, right, bottom; Shapes() { count++; } public: enum {LEFT, UP, RIGHT, DOWN}; virtual ~Shapes() { count--; } // Виртуальный деструктор (см. «Виртуальные деструкторы») static int GetCount() { return count; } int Left() const { return left; } int Top() const { return top; } int Right() const { return right; } int Bottom() const { return bottom; } virtual void Draw() = 0; // Чистые виртуальные функции virtual void Move(int where, const Shapes *shape) = 0; virtual Shapes* NewShape() = 0; virtual Shapes* Clone() = 0; };

Файл Shapes.cpp

#include "Shapes.h" int Shapes::count = 0;

Файл Circle.h

#if !defined(SHAPES) #include "Shapes.h" #endif class Circle : public Shapes { private: int cx, cy, radius; public: Circle(int x = 0, int y = 0, int r = 0, int c = 0); ~Circle() { } void Draw(); void Move(int where, const Shapes *shape); Circle* NewShape() { return new Circle(); } // При прямом вызове конструктора копирования не осуществляется вызов конструктора базового класса, и поэтому, в данном случае, // не происходит увеличения значения переменной Shapes::count, содержащей количество объектов класса Shapes Circle* Clone() { Circle *p = new Circle(); *p = *this; return p ; } };

Файл Circle.cpp

#include "Circle.h" Circle::Circle(int x, int y, int r, int c) { cx = x; cy = y; radius = r; color = c; left = cx - radius; top = cy - radius; right = cx + radius; bottom = cy + radius; } void Circle::Draw() { ... } void Circle::Move(int where, const Shapes *shape) { switch (where) { case LEFT: cx = shape->Left() - radius; cy = (shape->Top() + shape->Bottom()) / 2; break; case UP: cx = (shape->Left() + shape->Right()) / 2; cy = shape->Top() - radius; break; case RIGHT: cx = shape->Right() + radius; cy = (shape->Top() + shape->Bottom()) / 2; break; case DOWN: cx = (shape->Left() + shape->Right()) / 2; cy = shape->Bottom() + radius; break; } left = cx - radius; top = cy - radius; right = cx + radius; bottom = cy + radius; }

Файл Triangle.h

#if !defined(SHAPES) #include "Shapes.h" #endif class Triangle : public Shapes { private: int x1, y1, x2, y2, x3, y3; public: Triangle(int x1 = 0, int y1 = 0, int x2 = 0, int y2 = 0, int x3 = 0, int y3 = 0, int c = 0); ~Triangle() { } void Draw(); void Move(int where, const Shapes *shape); Triangle* NewShape() { return new Triangle(); } // При прямом вызове конструктора копирования не осуществляется вызов конструктора базового класса, и поэтому, в данном случае, // не происходит увеличения значения переменной Shapes::count, содержащей количество объектов класса Shapes Triangle* Clone() { Triangle *p = new Triangle(); *p = *this; return p ; } };

Файл Triangle.cpp

#include "Triangle.h" int Max(int a, int b, int c); int Min(int a, int b, int c); Triangle::Triangle(int x1, int y1, int x2, int y2, int x3, int y3, int c) { this->x1 = x1; this->y1 = y1; this->x2 = x2; this->y2 = y2; this->x3 = x3; this->y3 = y3; color = c; left = Min(x1, x2, x3); top = Min(y1, y2, y3); right = Max(x1, x2, x3); bottom = Max(y1, y2, y3); } void Triangle::Draw() { ... } void Triangle::Move(int where, const Shapes *shape) { int dx, dy; switch (where) { case LEFT: dx = shape->Left() - right; dy = (shape->Top() - top + shape->Bottom() - bottom) / 2; break; case UP: dx = (shape->Left() - left + shape->Right() - right) / 2; dy = shape->Top() - bottom; break; case RIGHT: dx = shape->Right() - left; dy = (shape->Top() - top + shape->Bottom() - bottom) / 2; break; case DOWN: dx = (shape->Left() - left + shape->Right() - right) / 2; dy = shape->Bottom() - top; break; } x1 += dx; y1 += dy; x2 += dx; y2 += dy; x3 += dx; y3 += dy; left = Min(x1, x2, x3); top = Min(y1, y2, y3); right = Max(x1, x2, x3); bottom = Max(y1, y2, y3); }

Файл main.cpp

#include "Circle.h" #include "Triangle.h" void main() { Shapes* shapes[10]; // Т.к. класс Shapes является абстрактным, // можно объявить массив указателей, но не массив фигур shapes[0] = new Circle(100, 100, 30, 50); shapes[1] = new Triangle(0, 0, 20, 0, 0, 20, 90); shapes[2] = new Circle(200, 200, 50, 20); shapes[3] = shapes[0]->NewShape(); shapes[4] = shapes[1]->NewShape(); shapes[5] = shapes[0]->Clone(); shapes[6] = shapes[1]->Clone(); for(int i = 0; i < Shapes::GetCount(); i++) shapes[i]->Draw(); for(int i = 1; i < Shapes::GetCount(); i++) shapes[i]->Move(Shapes::LEFT, shapes[i - 1]); for(int i = 0; i < Shapes::GetCount(); i++) shapes[i]->Draw(); for(int i = 0, n = Shapes::GetCount(); i < n; i++) delete shapes[i]; }

5. Пример «Шаблон стека»

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

Файл Stack.h

template <class T> class Stack { private: enum { SIZE = 3 }; T stack[SIZE]; T *cur; public: class StackError { public: virtual ~StackError() { } virtual Stack* GetPtr() = 0; virtual void Print() = 0; }; class StackEmpty : public StackError { private: Stack *stack; public: StackEmpty(Stack *p) : stack(p) { } Stack* GetPtr() { return stack; } void Print(); }; class StackFull : public StackError { private: Stack *stack; T n; public: StackFull(Stack *p, T i) : stack(p), n(i) { } Stack* GetPtr() { return stack; } T GetValue() { return n; } void Print(); }; Stack() { cur = stack; } ~Stack() { } T Push(const T& n); T Pop(); int IsEmpty() { return cur == stack; } T operator >> (T& s) { s = Pop(); return s; } T operator << (const T& s) { return Push(s); } };

Файл Stack.cpp

#include <iostream> #include "Stack.h" template <class T> T Stack<T>::Push(const T& n) { if (cur - stack < SIZE) { *cur++ = n; return n; } else throw StackFull(this, n); } template <class T> T Stack<T>::Pop() { if (cur != stack) return *--cur; else throw StackEmpty(this); } template <class T> void Stack<T>::StackEmpty::Print() { std::cout << "Attempt to get a value from the empty stack at the address " << GetPtr() << std::endl; } template <class T> void Stack<T>::StackFull::Print() { std::cout << "Attempt to put a value " << GetValue() << " to the full stack at the address " << GetPtr() << std::endl; }

Файл main.cpp

#include "Stack.cpp" void main() { Stack<int> is; int n; try { is << 1; is << 2; is << 3; is >> n; printf("%d\n", n); is >> n; printf("%d\n", n); is >> n; printf("%d\n", n); is >> n; printf("%d\n", n); } catch (Stack<int>::StackError& s) // Перехватываем исключения обоих классов. В инструкции catch должна быть объявлена { s.Print(); } // ссылка на исключение, т.к. класс StackError является абстрактным классом. Stack<char> cs; char c; try { cs << 'a'; cs << 'b'; cs << 'c'; cs << 'd'; cs << 'e'; cs >> c; printf("%c\n", c); cs >> c; printf("%c\n", c); } catch (Stack<char>::StackError& s) { s.Print(); } }