Виртуальные функции в C++: почему без них не обойтись?
Представьте, что вы — режиссер, который снимает фильм про животных. У вас есть сценарий: "Все животные издают звук". Вы снимаете сцену с собакой — она лает. Сцену с кошкой — она мяукает. Всё логично. А теперь представьте, что ваш ассистент (компилятор) приносит вам коробку с игрушечными животными и говорит: "Снимайте!". Вы нажимаете кнопку, а игрушка... молчит. Почему? Потому что вы забыли про виртуальные функции.
В программировании на C++ эта проблема встречается постоянно. Вы пишете класс "Животное", внутри него метод sound(). Потом создаёте классы "Собака" и "Кошка", которые наследуются от "Животного". И вроде бы всё хорошо, пока вы не пытаетесь работать с этими объектами через указатель на базовый класс. Тут-то и выясняется, что C++ по умолчанию вызывает метод базового класса, а не переопределённый в наследнике. Виртуальные функции решают эту проблему раз и навсегда.
В этой статье мы детально разберём, что такое виртуальные функции, зачем они нужны, как работают под капотом, и почему без них невозможно представить современный C++.
Что такое виртуальная функция простыми словами
Виртуальная функция — это метод класса, который вы помечаете ключевым словом virtual. Такая функция может быть переопределена в производных классах, и при вызове через указатель или ссылку на базовый класс будет вызвана именно версия из класса наследника.
Звучит сложно? Давайте на примере из жизни. Представьте пульт управления роботами. У вас есть базовый класс "Робот" с методом doWork(). Есть робот-пылесос и робот-курьер. Если вы нажимаете кнопку "Работать", то должны запуститься соответствующие алгоритмы: пылесос начинает убирать, курьер — доставлять посылку. Без виртуальных функций вы бы нажимали кнопку, а робот просто стоял бы на месте (вызывался бы метод базового класса).
В C++ виртуальные функции — это основа полиморфизма. Полиморфизм позволяет единообразно работать с объектами разных типов, если они наследуются от одного предка.
Проблема без виртуальных функций: наглядный пример
Давайте посмотрим, что происходит, если не использовать virtual.
#include <iostream>
#include <vector>
class Animal {
public:
void sound() {
std::cout << "Some generic animal sound" << std::endl;
}
};
class Dog : public Animal {
public:
void sound() {
std::cout << "Woof! Woof!" << std::endl;
}
};
class Cat : public Animal {
public:
void sound() {
std::cout << "Meow!" << std::endl;
}
};
int main() {
std::vector<Animal*> animals;
animals.push_back(new Dog());
animals.push_back(new Cat());
for (Animal* a : animals) {
a->sound(); // Что выведется?
}
// Очистка памяти
for (Animal* a : animals) delete a;
return 0;
}
Запустите этот код. Вы увидите:
Some generic animal sound
Some generic animal sound
Почему? Потому что компилятор смотрит на тип указателя (Animal*), а не на реальный объект. Он вызывает метод базового класса. Это называется раннее связывание (early binding) — решение о том, какой метод вызвать, принимается на этапе компиляции.
А мы хотим позднее связывание (late binding) — чтобы решение принималось во время выполнения программы, в зависимости от типа объекта. Именно это и дают виртуальные функции.
Решение: добавляем virtual
Исправим пример, добавив ключевое слово virtual.
class Animal {
public:
virtual void sound() { // Добавили virtual
std::cout << "Some generic animal sound" << std::endl;
}
};
// Классы Dog и Cat остаются без изменений
class Dog : public Animal {
public:
void sound() override { // override — необязательно, но желательно
std::cout << "Woof! Woof!" << std::endl;
}
};
class Cat : public Animal {
public:
void sound() override {
std::cout << "Meow!" << std::endl;
}
};
int main() {
std::vector<Animal*> animals;
animals.push_back(new Dog());
animals.push_back(new Cat());
for (Animal* a : animals) {
a->sound(); // Теперь всё работает!
}
for (Animal* a : animals) delete a;
return 0;
}
Вывод:
Woof! Woof!
Meow!
Магия! Компилятор теперь смотрит на реальный тип объекта и вызывает правильный метод. Это и есть полиморфизм в действии.
Как работают виртуальные функции под капотом
Когда вы объявляете функцию как virtual, компилятор создаёт для класса таблицу виртуальных функций (vtable). Это массив указателей на функции. Каждый объект класса получает скрытый указатель (vptr), который ссылается на эту таблицу.
При вызове виртуальной функции программа:
- Берёт vptr объекта
- Идёт по нему в vtable
- Находит нужную функцию по индексу
- Вызывает её
Это добавляет небольшой overhead (на один косвенный вызов больше), но даёт невероятную гибкость. Запомните: если класс имеет хотя бы одну виртуальную функцию, размер его объекта увеличивается на размер указателя (обычно 8 байт на 64-битных системах).
Кстати, деструктор тоже стоит делать виртуальным, если вы планируете удалять объекты через указатель на базовый класс. Иначе вы рискуете вызвать только деструктор базового класса, а память наследника не освободится.
class Animal {
public:
virtual ~Animal() {} // Виртуальный деструктор — обязательно!
virtual void sound() = 0; // Чисто виртуальная функция
};
Чистые виртуальные функции и абстрактные классы
Иногда вы хотите, чтобы базовый класс задавал только интерфейс, но не предоставлял реализацию. Для этого используются чистые виртуальные функции. Они объявляются с = 0 в конце.
class Shape {
public:
virtual double area() = 0; // Чисто виртуальная функция
virtual void draw() = 0;
};
class Circle : public Shape {
private:
double radius;
public:
Circle(double r) : radius(r) {}
double area() override {
return