Бесплатный урок из курса «С++ без аллокаций памяти» на 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++, обычно происходит два разыменования указателя:
-
Сначала через скрытый указатель
vptrобъект находит свою виртуальную таблицу (vtable). -
Затем из этой таблицы берётся адрес нужной функции, и уже по этому адресу выполняется вызов.
На первый взгляд это кажется «дорогой» операцией, но на практике компилятор умеет сильно оптимизировать такие вызовы. Например:
-
Если компилятор может точно определить тип объекта на этапе компиляции, он просто подставит вызов напрямую, без всяких таблиц.
-
Иногда он даже может встроить (inline) функцию, что полностью убирает накладные расходы на виртуальность.
Зачем тогда всё это?
Во-первых, механизм vtable гарантирует, что вызовы будут работать одинаково, независимо от конкретного класса, который мы используем через указатель на базовый.
Во-вторых, это обеспечивает единообразное поведение для указателей на функции-члены: у компилятора есть единый способ хранить и вызывать их, независимо от того, обычная это функция или виртуальная. Несмотря на дополнительные шаги, vtable делает полиморфизм в C++ и удобным, и предсказуемым, а в большинстве случаев цена за это минимальна благодаря оптимизациям.