Виртуальные функции и vtable изнутри

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

Полиморфизм Динамическое связывание vtable и vptr Object slicing

Задача, которую решают виртуальные функции

Представим зоопарк: у нас есть разные животные, все умеют издавать звук. Мы хотим хранить их в одном массиве и вызывать «голос» у каждого — но каждый должен звучать по-своему.

естественный подход
class Animal {
public:
    void speak() { cout << "..." ; }
};

class Dog : public Animal {
public:
    void speak() { cout << "Гав!"; }   // хотим, чтобы это вызывалось
};

class Cat : public Animal {
public:
    void speak() { cout << "Мяу!"; }   // и это
};

int main() {
    Animal* zoo[2];
    zoo[0] = new Dog();
    zoo[1] = new Cat();

    zoo[0]->speak();   // ??? что выведется
    zoo[1]->speak();   // ??? и это
}

Проблема: указатель имеет тип Animal*, а объект — Dog или Cat. Какой метод вызвать? Именно этот выбор и решает механизм виртуальных функций.

Демонстрация 1: что меняет слово virtual

Один и тот же код — с virtual и без. Нажмите кнопку и посмотрите на вывод.

main.cpp

Иерархия классов

Готово к запуску
$ // нажмите «Запустить программу»
Пояснение появится здесь после запуска.

Что такое таблица виртуальных методов (vtable)

Это главный механизм, который делает динамическое связывание возможным. Идея проста: для каждого класса с виртуальными функциями компилятор строит массив указателей на функции. А в каждый объект добавляет скрытое поле — указатель на этот массив.

vtable

Virtual method table. Строится компилятором один раз на класс. Это статический массив, содержащий адреса виртуальных функций этого класса.

  • Одна таблица на класс (не на объект).
  • Расположена в статической памяти программы.
  • Слоты [0], [1], ... — по порядку объявления виртуальных функций.

vptr

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

  • Одно поле на объект (обычно 8 байт).
  • Указывает на vtable того класса, к которому объект фактически принадлежит.
  • Устанавливается конструктором и «переустанавливается» на каждом уровне иерархии.
Ключевое наблюдение. Указатель базового класса хранит адрес объекта. Сам объект начинается с vptr. Значит, программа, имея указатель базового класса, может посмотреть в vptr объекта и определить, к какому классу он принадлежит, — а потом вызвать правильную функцию.

Демонстрация 2: vtable для иерархии

Класс Animal и два его наследника — Dog и Cat. Переключайте класс и смотрите, как выглядит его таблица.

объявление классов

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

Демонстрация 3: что происходит при вызове animal->speak()

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

1. Взять адрес объекта из указателя zoo[0]
2. Прочитать vptr — первое поле объекта
3. Перейти по vptr к vtable класса
4. Найти слот с индексом speak (обычно [0])
5. Загрузить адрес функции из этого слота
6. Вызвать функцию по адресу
$ // нажмите «Запустить трассировку»

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

Компилятор добавляет в начало объекта скрытое поле — vptr. Пользовательские поля идут после него.

Объект Dog d vptr → vtable_Dog name = "Рекс" age = 3 vtable_Dog [0] speak → Dog::speak [1] eat → Dog::eat [2] ~Animal → ~Dog [3] wag → Dog::wag Слоты [0] и [1] переопределены — указывают на методы Dog. Слот [2] подменил деструктор базового класса на деструктор Dog. Слот [3] добавлен — новая виртуальная функция, которой нет у Animal.
Скрытое поле. vptr не виден в исходном коде, но реально существует. Из-за него размер объекта с виртуальными функциями увеличивается на 8 байт (на 64-битной системе).

Опасность 1: срезка объекта (object slicing)

Очень коварная ошибка: код компилируется, работает, но делает не то, что вы ожидали.

Срезка при копировании по значению

плохой код
void makeSound(Animal a) {  // ← аргумент по значению, а не по ссылке
    a.speak();
}

int main() {
    Dog dog;
    makeSound(dog);   // выведет "..." (Animal::speak), а не "Гав!"
}
Dog dog vptr → vtable_Dog name = "Рекс" age = 3 breed = "лабрадор" Animal a (копия — срезка!) vptr → vtable_Animal name = "Рекс" age = 3 breed — потерян! Копируется только базовая часть Dog. Все поля и виртуальные функции наследника теряются.

Как избежать. Всегда передавайте объекты базовых классов по ссылке или указателю: void makeSound(Animal& a) или void makeSound(const Animal* a).

Опасность 2: виртуальный вызов в конструкторе или деструкторе

Логика, которая не сработает

плохой код
class Animal {
public:
    Animal() {
        speak();   // ← вызов виртуальной функции в конструкторе
    }
    virtual void speak() { cout << "Animal::speak"; }
    virtual ~Animal() {}
};

class Dog : public Animal {
public:
    void speak() override { cout << "Гав!"; }
};

int main() {
    Dog dog;   // при создании выведет "Animal::speak", а не "Гав!"
}

Почему так. К моменту, когда тело конструктора Animal() начинает выполняться, объект ещё не стал Dog. Компилятор устанавливает vptr на vtable_Animal, и вызов speak() приводит к Animal::speak. То же самое происходит в деструкторе: он уже «откатился» к базовому классу.

Как избежать. Не вызывайте виртуальные функции из конструкторов и деструкторов. Если нужна логика инициализации — вынесите её в отдельный метод init() и вызывайте после создания объекта.

Опасность 3: невиртуальный деструктор базового класса

Утечка памяти, о которой легко забыть

плохой код
class Base {
public:
    ~Base() { cout << "~Base "; }   // ← НЕ виртуальный
};

class Derived : public Base {
    char* buffer;   // выделяется в конструкторе
public:
    Derived() { buffer = new char[1000]; }
    ~Derived() { delete[] buffer; cout << "~Derived "; }
};

int main() {
    Base* p = new Derived();
    delete p;   // выведет только "~Base" — утечка!
}

Что произошло. Компилятор видит тип Base* и вызывает ~Base. Деструктор Derived не вызывается вообще — выделенная в нём память остаётся висеть.

Золотое правило. Если у класса есть хотя бы одна виртуальная функция, сделайте его деструктор тоже виртуальным. Тогда delete через указатель базового класса корректно вызовет всю цепочку деструкторов.

Статическое и динамическое связывание

Два принципиально разных способа решить, какую функцию вызвать.

Статическое связывание Динамическое связывание
Когда выбирается функция Во время компиляции Во время выполнения
На что опирается Тип указателя Тип объекта, на который указывают
Скорость Быстрее — прямой вызов Чуть медленнее — через vtable
Когда используется Обычные функции, невиртуальные методы Виртуальные функции
Можно ли оптимизировать Да (inline-подстановка) Только в особых случаях
Что важно понимать. Динамическое связывание платит небольшой производительностью за гибкость. В большинстве программ этой разницей можно пренебречь — выигрыш в читаемости и расширяемости кода многократно перекрывает 1–2 наносекунды на вызов.

Шпаргалка

Что нужноСинтаксис
Объявить виртуальную функциюvirtual void f();
Чисто виртуальная (абстрактный класс)virtual void f() = 0;
Переопределить в наследнике (рекомендуется)void f() override { ... }
Запретить дальнейшее переопределениеvoid f() final;
Виртуальный деструкторvirtual ~Base() {}
Передать объект-наследник в функцию без срезкипо ссылке: void fn(Base& b)

Задание

  1. Соберите иерархию Shape → Circle, Square. У базового класса сделайте virtual double area(), virtual void draw() и виртуальный деструктор. Создайте массив Shape* и вызовите методы для каждой фигуры.
  2. Уберите ключевое слово virtual из метода area() базового класса. Что изменится в выводе? Объясните почему.
  3. Напишите функцию printArea(Shape s) — с аргументом по значению. Вызовите её для объекта Circle. Какой метод вызовется? Почему так происходит?
  4. Переделайте аргумент на Shape& s — что изменится?
  5. Почему виртуальный вызов в конструкторе вызывает метод базового класса, а не наследника? Нарисуйте диаграмму vptr на каждом этапе конструирования объекта Dog.
  6. Найдите ошибку в этом коде и объясните, к какой категории она относится:
    class Base {
    public:
        ~Base() {}
        virtual void run() { cout << "Base"; }
    };
    class Derived : public Base {
        int* data;
    public:
        Derived() : data(new int[100]) {}
        ~Derived() { delete[] data; }
    };
    int main() {
        Base* b = new Derived();
        delete b;
    }