This lesson is not translated yet – the Russian original is below.
Перегрузка выбирает функцию на этапе компиляции по объявленным типам. Виртуальные методы решают обратную задачу: выбрать реализацию во время работы по фактическому типу объекта.
Проблема
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).
Вызов 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>>) снимает и последнюю возможность ошибиться. К умным
указателям придём в следующем модуле.