Синтаксис си/с++

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

Введение

Статья 533 кратко упомянула virtual-деструктор для полиморфного удаления — в этой статье разберём виртуальные функции систематически: как они обеспечивают полиморфизм (вызов метода, соответствующего реальному, динамическому типу объекта, а не статическому типу указателя/ссылки), и заглянем «под капот» в механизм vtable, реализующий это поведение во время выполнения.

Концепция

Виртуальная функция, объявленная в базовом классе с ключевым словом virtual и переопределённая (override) в производном классе, вызывается полиморфно — даже если вызов происходит через указатель или ссылку статического типа базового класса, реально выполнится версия метода, соответствующая фактическому, динамическому типу объекта, на который указывает этот указатель/ссылка, что и составляет суть полиморфизма в C++. Внутренний механизм, реализующий это поведение — vtable (виртуальная таблица) — каждый класс с хотя бы одной виртуальной функцией получает собственную таблицу указателей на реальные реализации виртуальных функций, и каждый объект такого класса содержит скрытый указатель на vtable своего реального, конкретного типа (vptr), используемый компилятором для нахождения правильной реализации функции во время выполнения, а не на этапе компиляции.

Пример кода: Виртуальные функции и полиморфизм

#include <iostream>

class Shape {
public:
    virtual double area() const { return 0.0; } // virtual — может быть переопределён производными классами
    virtual ~Shape() = default; // virtual-деструктор — статья 532/533, обязателен для полиморфного удаления
};

class Circle : public Shape {
public:
    Circle(double radius) : m_radius(radius) {}
    double area() const override { return 3.14159 * m_radius * m_radius; } // override — явное указание переопределения
private:
    double m_radius;
};

class Square : public Shape {
public:
    Square(double side) : m_side(side) {}
    double area() const override { return m_side * m_side; }
private:
    double m_side;
};

void printArea(const Shape &shape) // принимает базовый класс — НЕ знает реальный тип объекта статически
{
    std::cout << "Площадь: " << shape.area() << std::endl; // вызов ПОЛИМОРФНЫЙ — реальная реализация
                                                              // выбирается согласно ДИНАМИЧЕСКОМУ типу объекта
}

int main()
{
    Circle circle(5.0);
    Square square(4.0);

    printArea(circle); // вызовет Circle::area() — площадь круга
    printArea(square); // вызовет Square::area() — площадь квадрата, ХОТЯ printArea не знает об этом статически
    return 0;
}
// Полиморфное хранение через указатели на базовый класс — типичный паттерн использования
#include <vector>
#include <memory>

int main()
{
    std::vector<std::unique_ptr<Shape>> shapes; // статья 547 — умные указатели
    shapes.push_back(std::make_unique<Circle>(3.0));
    shapes.push_back(std::make_unique<Square>(2.0));

    for (const auto &shape : shapes) {
        std::cout << shape->area() << std::endl; // КАЖДЫЙ вызов area() — полиморфный, согласно реальному типу
    }
    return 0;
}

Пояснения к коду

printArea(const Shape &shape) показывает суть полиморфизма — функция принимает параметр статического типа Shape, не зная на этапе компиляции, будет ли реально передан Circle, Square, или любой другой производный класс, но вызов shape.area() благодаря виртуальности этого метода всегда выполняет правильную, соответствующую реальному типу реализацию — это и есть ключевая практическая выгода виртуальных функций, позволяющая писать код, работающий с целым семейством связанных через наследование типов без необходимости знать конкретный тип на этапе компиляции. Ключевое слово override в Circle::area()/Square::area() — явное, рекомендуемое указание намерения переопределить виртуальный метод базового класса, позволяющее компилятору проверить, что сигнатура действительно точно соответствует виртуальному методу базового класса (защита от опечаток в сигнатуре, которые без override привели бы не к ошибке, а к молчаливому созданию совершенно нового, отдельного метода, не переопределяющего ничего). Пример с std::vector<std::unique_ptr<Shape>> показывает типичный практический паттерн — коллекция объектов разных конкретных типов, объединённых общим базовым классом, обрабатывается единообразно через полиморфные вызовы, без необходимости явно проверять и обрабатывать каждый конкретный тип по отдельности.

Пояснения к коду (продолжение, про vtable)

Внутренний механизм vtable не виден напрямую в коде C++, но именно он обеспечивает наблюдаемое поведение примеров выше — каждый объект типа Circle/Square содержит скрытый указатель (vptr) на таблицу vtable, специфичную для своего конкретного типа, и эта таблица содержит указатели на реальные реализации каждой виртуальной функции для этого конкретного типа; вызов shape.area() компилируется не в прямой вызов конкретной функции (как для невиртуальных методов), а в косвенный вызов через vptr объекта и vtable, что и даёт небольшие, но измеримые накладные расходы по сравнению с обычным, прямым вызовом функции — цена, оправданная гибкостью полиморфизма для случаев, где она действительно нужна.

Подводные камни

  • Забытое ключевое слово virtual в базовом классе для метода, который реально предполагается переопределять полиморфно — без virtual вызов метода через указатель/ссылку на базовый класс ВСЕГДА вызывает реализацию базового класса, независимо от реального, динамического типа объекта (статическое связывание, а не динамическое), что является частой, неочевидной ошибкой для разработчиков, ожидающих полиморфного поведения, но забывших явно пометить метод как виртуальный в самом базовом классе.
  • Отсутствие override при попытке переопределить виртуальный метод, особенно при опечатке в сигнатуре (неверный тип параметра, забытое const) — без override такая ошибка не вызывает ошибку компиляции, а просто создаёт новый, не связанный с виртуальным методом базового класса метод производного класса (что компилятор формально допускает, поскольку имя метода технически может совпадать без переопределения, если сигнатуры различаются), и итоговое поведение программы окажется неожиданным (вызов реализации БАЗОВОГО класса там, где разработчик ожидал переопределённую версию), при этом без явной диагностики этой проблемы — именно поэтому современная практика C++ рекомендует ВСЕГДА использовать override при намеренном переопределении, чтобы компилятор сам проверил точное соответствие сигнатур.
  • Объявление невиртуального деструктора для класса, предназначенного для полиморфного использования и удаления через указатель на базовый класс — аналогично подводному камню статьи 532/533, это приводит к неполному удалению (вызывается только деструктор базового класса, не производного), и для любого класса, предполагающего наследование и потенциальное полиморфное удаление через указатель на базовый тип, деструктор обязан быть virtual.
  • Игнорирование накладных расходов виртуальных вызовов для действительно критичных по производительности участков кода (горячие пути выполнения с очень частыми вызовами) — хотя для подавляющего большинства кода эти накладные расходы незначительны и оправданы гибкостью полиморфизма, для специфичных, действительно измеренных как критичные по производительности случаев (статья 494 QML-цикла про общий принцип профилирования) виртуальные вызовы могут стать заметным фактором, и альтернативы (шаблоны статья 541 с статическим, известным на этапе компиляции полиморфизмом через CRTP, или простое избегание полиморфизма там, где он реально не нужен) стоит рассматривать именно для таких, объективно измеренных случаев, а не отказываться от виртуальных функций «из общих соображений производительности» без реального измерения.