Курс · C++

Как работает vtable

25 июня 20265 мин

Бесплатный урок из курса «С++ без аллокаций памяти» на Stepik.

Полиморфизм в C++ достигается с помощью виртуальных функций. Он позволяет вызывать разные реализации одной и той же функции через указатель или ссылку на базовый класс. Благодаря этому можно работать с объектами разных типов единообразно, а фактическая реализация будет выбрана автоматически во время выполнения.

class Base
{
public:
    const char* name() { return "Base"; }
};
 
class Child: public Base
{
public:
    const char* name() { return "Child"; }
};
 
int main()
{
    Child child;
    Base &obj = child;
    std::cout << "Object is a " << obj.name();
}

Чтобы это работало, компилятор использует специальный механизм — vtable (виртуальная таблицу). Если в классе есть хотя бы одна виртуальная функция, компилятор создаёт специальную структуру, в которой хранятся адреса виртуальных функций.

Каждый объект такого класса получает скрытый указатель vptr, который указывает на соответствующую vtable. Если виртуальная функция не переопределена в производном классе, то в vtable производного класса записывается адрес реализации из базового класса.

Когда мы вызываем виртуальную функцию через указатель или ссылку на базовый класс, используется
vptr → vtable → адрес нужной функции.
Благодаря этому вызов связывается не во время компиляции, а во время выполнения программы.

Таким образом, механизм vtable обеспечивает динамическое связывание и делает возможным полиморфизм в C++

Своя реализация vtable

Чтобы понять как это работает, давайте реализуем приведённый выше пример, используя указатели на функции.

struct Base
{
    void (*name)(Base* me);
};

struct Child
{
    Base v; // моделируем наследование
};

void child_name(Base *ap);

// работает как конструктор класса
void init_child(Child *ap)
{
    ap->v.name = &child_name;
}

Компилятор создает немного другой код, так как он сохраняет указатель на функцию в глобальной виртуальной таблице (обычно называемой «vtable»). То, как это делает компилятор выглядит примерно так:

// Предварительное объявление структуры виртуальной таблицы
struct base_vtable;

struct Base
{
    // Обратите внимание: это *указатель* на vtable, а не конкретный экземпляр
    // Это нужно для нашей реализации полиморфизма - каждый объект хранит только указатель,
    // а не копию всей таблицы функций
    const base_vtable * vtable;
};

struct base_vtable
{
    // Виртуальная таблица хранит указатели только на функции
    // Это основа механизма виртуальных функций в C++
    // Каждая виртуальная функция представлена указателем в vtable
    void (*name)(Base* me);
};

void base_name(Base * v)
{
    // Обратите внимание, что фактическая функция name(), которая будет вызвана,
    // зависит от того, что хранится в vtable конкретного объекта
    v->vtable->name(v);
    // Это называется динамическая диспетчеризация (dynamic dispatch) - решение принимается
    // во время выполнения программы, а не компиляции
}

struct Child 
{
    // Моделирование наследования - интерфейс "базового класса" является первым членом
    // Это гарантирует, что указатель на child можно безопасно привести к указателю на base,
    // так как оба указателя будут указывать на один и тот же адрес в памяти
    Base v; 
};

// Прототип виртуальной функции name() для дочернего класса
void child_name(Base * ap);

// Виртуальная таблица для типа child связывает вызов name() с функцией child_name()
// Эта vtable статическая (const) и создается один раз при компиляции
// Все объекты типа child будут указывать на эту же vtable
const base_vtable child_vtable = {
    .name = &child_name  // для явной инициализации
};

// При создании объектов child необходимо инициализировать vtable,
// чтобы вызывались соответствующие виртуальные функции
// Это эквивалент конструктора в C++ - он устанавливает указатель vtable
// на правильную таблицу для данного типа
void init_child(Child *ap) 
{
    // Важный шаг: связываем объект с его vtable
    // После этого вызовы base_name() будут автоматически вызывать child_name()
    // благодаря косвенному обращению через указатель vtable
    ap->v.vtable = &child_vtable;
}

Когда мы вызываем виртуальную функцию в C++, обычно происходит два разыменования указателя:

  1. Сначала через скрытый указатель vptr объект находит свою виртуальную таблицу (vtable).

  2. Затем из этой таблицы берётся адрес нужной функции, и уже по этому адресу выполняется вызов.

На первый взгляд это кажется «дорогой» операцией, но на практике компилятор умеет сильно оптимизировать такие вызовы. Например:

Зачем тогда всё это?

Во-первых, механизм vtable гарантирует, что вызовы будут работать одинаково, независимо от конкретного класса, который мы используем через указатель на базовый.

Во-вторых, это обеспечивает единообразное поведение для указателей на функции-члены: у компилятора есть единый способ хранить и вызывать их, независимо от того, обычная это функция или виртуальная. Несмотря на дополнительные шаги, vtable делает полиморфизм в C++ и удобным, и предсказуемым, а в большинстве случаев цена за это минимальна благодаря оптимизациям.

Пройти курс на Stepik →

← Все статьи