Как устроена vtable изнутри

Логичный следующий вопрос после «что делает virtual»: «А как именно программа во время выполнения узнаёт, какой метод вызвать?» Ответ — vtable.

vtable (virtual table, таблица виртуальных функций) — скрытая таблица указателей на функции, которую компилятор создаёт для каждого класса с виртуальными методами. У каждого объекта такого класса есть скрытый указатель на «свою» таблицу.

Что выведет этот код?

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

#include <iostream>

class Zhivotnoe {
public:
    virtual void izdatZvuk() {
        std::cout << "..." << std::endl;
    }
    virtual void dvigatsya() {
        std::cout << "Как-то двигается" << std::endl;
    }
};

class Sobaka : public Zhivotnoe {
public:
    void izdatZvuk() override {
        std::cout << "Гав!" << std::endl;
    }
    // dvigatsya НЕ переопределён
};

int main() {
    Zhivotnoe* p = new Sobaka();
    p->izdatZvuk();
    p->dvigatsya();
    delete p;
    return 0;
}

Вывод:

Гав!
Как-то двигается

Обратите внимание: Sobaka переопределила только izdatZvuk, а dvigatsya унаследовала как есть. И оба вызова отработали корректно — izdatZvuk взял версию из Sobaka, а dvigatsya использовал версию из Zhivotnoe, потому что для неё переопределения не нашлось. Чтобы понять, как программа «на лету» это разруливает, нужно заглянуть в vtable.

Как это работает под капотом

Когда в классе появляется хотя бы один virtual-метод, компилятор создаёт для этого класса скрытый статический массив — таблицу указателей на функции, по одному указателю на каждый виртуальный метод. Эта таблица создаётся один раз на класс (не на каждый объект!) и хранит адреса тех версий методов, которые актуальны именно для этого класса.

У класса Zhivotnoe vtable выглядит примерно так:

ИндексМетодУказывает на
0izdatZvukZhivotnoe::izdatZvuk
1dvigatsyaZhivotnoe::dvigatsya

А у класса Sobaka, который переопределил только первый метод, своя vtable — со своим набором адресов:

ИндексМетодУказывает на
0izdatZvukSobaka::izdatZvuk (переопределён)
1dvigatsyaZhivotnoe::dvigatsya (унаследован как есть)

Каждый объект класса с виртуальными методами получает один скрытый указатель — его обычно называют vptr — который указывает на vtable именно того класса, чей это объект на самом деле. Когда объект Sobaka создаётся через new Sobaka(), его vptr настраивается на vtable класса Sobaka, а не Zhivotnoe — и это происходит независимо от того, через указатель какого типа к объекту потом обращаются.

Как выглядит сам вызов метода

Когда вы пишете p->izdatZvuk(), а izdatZvuk — виртуальный, компилятор превращает этот вызов примерно в такую последовательность действий: взять vptr объекта, по нему найти vtable, в vtable по фиксированному индексу метода izdatZvuk (индекс 0 в нашем примере) достать адрес функции и вызвать функцию по этому адресу. Всё это происходит в момент выполнения программы, а не заранее — отсюда и название «позднее связывание». Именно поэтому p->dvigatsya() в примере выше нашёл в vtable объекта Sobaka адрес именно Zhivotnoe::dvigatsya — потому что Sobaka его не переопределяла, и в её персональной vtable по этому индексу остался «унаследованный» адрес.

Где физически лежит vptr

Указатель vptr — не выдумка компилятора «где-то в стороне», а вполне реальные лишние байты внутри самого объекта, обычно в самом его начале. Именно поэтому класс с виртуальными методами занимает чуть больше памяти, чем такой же класс без них: пустой класс без виртуальных методов может весить 1 байт, а стоит добавить хотя бы один virtual-метод — размер вырастет минимум на размер указателя (8 байт на большинстве 64-битных систем) за счёт добавившегося vptr. На собеседовании иногда прямо спрашивают, почему sizeof объекта увеличился после добавления виртуального метода — вот и ответ.

Почему это не бесплатно

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

Частые ошибки на собеседовании

  • Говорят, что vtable создаётся для каждого объекта — нет, таблица одна на класс, а у каждого объекта есть лишь указатель vptr на неё.
  • Не могут объяснить, почему виртуальный вызов чуть медленнее обычного — правильный ответ: из-за дополнительного разыменования указателя на функцию через vtable вместо прямой подстановки адреса при компиляции.
  • Путаются, что происходит с неопределёнными в наследнике методами — они просто продолжают указывать на реализацию базового класса в персональной vtable наследника, а не «пропадают».

Итог

  • vtable — таблица указателей на функции, по одной на класс с виртуальными методами.
  • Каждый объект хранит скрытый vptr, указывающий на vtable своего реального класса.
  • Виртуальный вызов — это чтение адреса функции через vptr и vtable в рантайме, отсюда и «позднее связывание», и небольшой (обычно незаметный) накладной расход по скорости.
Проверьте себя
1. Сколько экземпляров vtable создаётся для класса с виртуальными методами, если создать 1000 объектов этого класса?
A1000 — по одной таблице на каждый объект
BОдна — таблица создаётся на класс, а не на объект, каждый объект хранит лишь указатель на неё
CНи одной — vtable существует только во время компиляции
DЗависит от количества виртуальных методов в классе
2. Класс-наследник не переопределил один из виртуальных методов базового класса. Что окажется в его персональной vtable по индексу этого метода?
AПустой указатель (nullptr), вызов такого метода приведёт к ошибке
BАдрес реализации метода из базового класса
CМетод исчезает из vtable наследника полностью
DКомпилятор откажется собирать программу