Программа курса← Программирование на языке C++
1Введение в язык C++
2Как выполняются программы на C++
3Структуры и классы
4Объектно-ориентированное программирование
5Дополнительные возможности C++
6Шаблоны
7Методические материалы

Виртуальные методы и таблица виртуальных методов

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

Проблема

class Shape {
public:
    double Area() const { return 0; }
};

class Circle : public Shape {
public:
    double Area() const { return 3.14159 * r_ * r_; }

private:
    double r_ = 1;
};

Shape* s = new Circle;
std::cout << s->Area();     // 0 – вызвана Shape::Area

Компилятор видит Shape* и подставляет вызов Shape::Area. То, что по указателю лежит Circle, выясняется только во время работы, а вызов уже зашит.

virtual

Одно ключевое слово меняет схему вызова:

class Shape {
public:
    virtual double Area() const { return 0; }
    virtual ~Shape() = default;
};

class Circle : public Shape {
public:
    double Area() const override { return 3.14159 * r_ * r_; }

private:
    double r_ = 1;
};

Shape* s = new Circle;
std::cout << s->Area();     // 3.14159 – вызвана Circle::Area
delete s;

virtual в базе достаточно: во всех наследниках метод остаётся виртуальным, даже если слово не повторять. Повторять его не нужно – вместо этого пишут override.

override ничего не меняет в поведении, но заставляет компилятор проверить, что метод действительно переопределяет виртуальный метод базы. Опечатка в имени, пропущенный const, другой тип параметра – всё это без override создаёт новый метод, который просто никогда не вызовется. Ставьте override всегда.

Как это устроено

Механизм прямолинеен. Для каждого класса с виртуальными методами компилятор создаёт таблицу виртуальных методов (vtable) – массив адресов реализаций. В каждый объект такого класса добавляется скрытое поле – указатель на таблицу своего класса (vptr).

объект Circle vptr = • r_ объект Square vptr = • side_ vtable Circle Area → Circle::Area ~Shape → ~Circle vtable Square Area → Square::Area ~Shape → ~Square одна таблица на класс

Вызов s->Area() превращается примерно в такое: взять vptr объекта, прочитать по фиксированному смещению адрес нужного метода, вызвать по нему. Смещение известно на этапе компиляции – оно одинаково для всех классов иерархии, потому что таблицы наследников строятся из таблицы базы.

Цена:

  • память – один указатель на объект и одна таблица на класс;
  • время – одно косвенное обращение вместо прямого вызова;
  • оптимизация – встроить (inline) виртуальный вызов компилятор обычно не может, и это чаще дороже самого косвенного вызова.

Абсолютные числа невелики: косвенный вызов сам по себе стоит единицы тактов. Заметным он становится в горячих циклах, где мешает векторизации и встраиванию. Отсюда девиз C++: «вы не платите за то, чем не пользуетесь» – класс без virtual не содержит ни vptr, ни таблицы и по раскладке совпадает с обычной структурой.

Чисто виртуальные методы и абстрактные классы

Иногда у базового класса нет разумной реализации – он описывает только интерфейс:

class Shape {
public:
    virtual double Area() const = 0;    // чисто виртуальный
    virtual ~Shape() = default;
};

Shape s;            // ошибка: класс абстрактный
Shape* p = new Circle;   // а так можно

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

Класс, у которого все методы чисто виртуальные, а полей нет, играет роль интерфейса – отдельного ключевого слова для этого в C++ нет.

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

Самое важное практическое правило модуля:

class Base {
public:
    virtual ~Base() = default;   // обязательно, если класс будут наследовать
};

Без него удаление через указатель на базу вызовет только ~Base, а часть наследника останется неразрушенной:

class Resource : public Base {
    std::string data_;           // деструктор не вызовется – утечка
};

Base* p = new Resource;
delete p;                        // неопределённое поведение

Если у класса есть хоть один виртуальный метод – у него должен быть виртуальный деструктор.

Альтернатива для классов, которые не предполагается удалять через указатель на базу, – деструктор protected и невиртуальный: тогда delete p просто не скомпилируется.

Вызов версии базового класса

Переопределённый метод часто должен дополнить базовую реализацию, а не заменить её:

class Derived : public Base {
public:
    void Save() const override {
        Base::Save();            // явная квалификация отключает виртуальный вызов
        SaveOwnPart();
    }
};

Квалифицированный вызов Base::Save() всегда прямой – именно так и надо, иначе получилась бы бесконечная рекурсия.

Виртуальность в конструкторах и деструкторах

class Base {
public:
    Base() { Init(); }                   // не то, что кажется
    virtual void Init() { /* база */ }
};

class Derived : public Base {
public:
    void Init() override { /* наследник */ }
};

Derived d;      // вызовется Base::Init, а не Derived::Init

Пока работает конструктор базы, vptr объекта указывает на таблицу базы – производной части ещё нет. В деструкторе картина зеркальная: часть наследника уже разрушена.

Не вызывайте виртуальные методы из конструкторов и деструкторов. Компилятор не ошибается – он делает единственно безопасное, но программисты регулярно ждут другого. Если инициализация требует поведения наследника, вынесите её в отдельный метод, который вызывается после создания объекта.

Полиморфизм в деле

std::vector<Shape*> shapes;
shapes.push_back(new Circle(2));
shapes.push_back(new Square(3));

double total = 0;
for (const Shape* s : shapes) {
    total += s->Area();       // каждый посчитает по-своему
}

for (Shape* s : shapes) {
    delete s;                 // работает благодаря виртуальному деструктору
}

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

Ручные new и delete здесь – временная мера: контейнер владеющих указателей (std::vector<std::unique_ptr<Shape>>) снимает и последнюю возможность ошибиться. К умным указателям придём в следующем модуле.