Перейти к содержимому

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

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

Полиморфизм времени выполнения

Полиморфизм означает «много форм» — один и тот же интерфейс ведет себя по-разному в зависимости от фактического типа объекта. В C++ полиморфизм во время работы достигается посредством виртуальных функций. Когда вы вызываете виртуальную функцию через базовый указатель или ссылку, вызов отправляется на реализацию фактического производного класса во время выполнения.

Это механизм, который делает OOP мощным: вы можете писать код, который работает на интерфейсе базового класса, и он автоматически работает правильно с любым производным классом.

Как работают vtables

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

polymorphism.cpp
#include <iostream>
#include <memory>
#include <vector>

class Shape {
public:
    virtual ~Shape() = default;

    // Pure virtual — no default implementation
    virtual double area() const = 0;

    // Virtual with default implementation
    virtual std::string name() const { return "Shape"; }
};

class Circle : public Shape {
    double radius_;
public:
    explicit Circle(double r) : radius_{r} {}

    double area() const override {
        return 3.14159265 * radius_ * radius_;
    }

    std::string name() const override { return "Circle"; }
};

class Rectangle : public Shape {
    double w_, h_;
public:
    Rectangle(double w, double h) : w_{w}, h_{h} {}

    double area() const override { return w_ * h_; }
    std::string name() const override { return "Rectangle"; }
};

// Works with ANY Shape — past, present, or future
void print_info(const Shape& s) {
    std::cout << s.name() << ": area = " << s.area() << '\n';
}

int main() {
    Circle c{5.0};
    Rectangle r{3.0, 4.0};

    print_info(c);  // Circle: area = 78.5398
    print_info(r);  // Rectangle: area = 12

    // Polymorphic container
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.push_back(std::make_unique<Circle>(2.0));
    shapes.push_back(std::make_unique<Rectangle>(6.0, 3.0));

    for (const auto& s : shapes) {
        print_info(*s);  // virtual dispatch at runtime
    }
    return 0;
}

Виртуальные разрушители обязательны

Если класс имеет какую-либо виртуальную функцию, его деструктор должен быть виртуальным. Без этого удаление производного объекта через базовый указатель вызывает неопределенное поведение — производный деструктор никогда не работает, утечка ресурсов.

virtual_destructor.cpp
#include <iostream>
#include <memory>

class Base {
public:
    // Without virtual destructor: UNDEFINED BEHAVIOR on delete
    // virtual ~Base() = default;  // ALWAYS do this!
    ~Base() { std::cout << "~Base\n"; }
};

class Derived : public Base {
    int* data_;
public:
    Derived() : data_{new int[1000]} {
        std::cout << "Derived: allocated\n";
    }
    ~Derived() {
        delete[] data_;
        std::cout << "~Derived: freed\n";
    }
};

int main() {
    Base* ptr = new Derived{};
    delete ptr;  // Only ~Base runs! ~Derived is NOT called!
    // Result: memory leak (data_ never freed)
    // Fix: make ~Base() virtual
    return 0;
}

Override и заключительные ключевые слова

Ключевое слово override сообщает компилятору, что вы намерены отменить виртуальную функцию базового класса. Если подпись не совпадает (обычная опечатка), компилятор ловит ее. Ключевое слово final предотвращает дальнейшее переопределение функции или предотвращает наследование из класса.

override_final.cpp
#include <iostream>

class Base {
public:
    virtual ~Base() = default;
    virtual void process(int x) const { std::cout << "Base: " << x << '\n'; }
    virtual void execute() { std::cout << "Base::execute\n"; }
};

class Middle : public Base {
public:
    // override: compiler checks signature matches Base::process
    void process(int x) const override {
        std::cout << "Middle: " << x * 2 << '\n';
    }

    // final: no further derived class can override execute()
    void execute() final { std::cout << "Middle::execute\n"; }
};

class Bottom : public Middle {
public:
    void process(int x) const override {
        std::cout << "Bottom: " << x * 3 << '\n';
    }

    // ERROR if uncommented: execute() is final in Middle
    // void execute() override { }
};

// A final class cannot be inherited from at all
class Leaf final : public Base {
public:
    void process(int x) const override {
        std::cout << "Leaf: " << x << '\n';
    }
};

// ERROR if uncommented: cannot inherit from final class
// class Attempt : public Leaf {};

int main() {
    Bottom b;
    Base& ref = b;
    ref.process(10);  // Bottom: 30 (virtual dispatch)
    ref.execute();    // Middle::execute (final version)
    return 0;
}

Абстрактные классы и модель NVI

Класс с по меньшей мере одной функцией pure virtual (= 0) является абстрактным — он не может быть инстанцирован. Невиртуальный интерфейс (NVI) шаблон обеспечивает публичную невиртуальную точку входа, которая вызывает частную виртуальную функцию, позволяя базовому классу управлять пред/пост условиями.

nvi_pattern.cpp
#include <iostream>
#include <string>

// NVI Pattern: public non-virtual calls private virtual
class Validator {
public:
    virtual ~Validator() = default;

    // Public non-virtual interface — controls the workflow
    bool validate(const std::string& input) const {
        if (input.empty()) {
            std::cout << "[Validator] Input is empty\n";
            return false;
        }
        // Delegate to derived class implementation
        bool result = do_validate(input);
        std::cout << "[Validator] " << (result ? "PASS" : "FAIL") << '\n';
        return result;
    }

private:
    // Pure virtual — derived classes MUST implement
    virtual bool do_validate(const std::string& input) const = 0;
};

class EmailValidator : public Validator {
    bool do_validate(const std::string& input) const override {
        return input.find('@') != std::string::npos
            && input.find('.') != std::string::npos;
    }
};

class LengthValidator : public Validator {
    std::size_t min_len_;
    bool do_validate(const std::string& input) const override {
        return input.length() >= min_len_;
    }
public:
    explicit LengthValidator(std::size_t min) : min_len_{min} {}
};

int main() {
    EmailValidator email_v;
    LengthValidator len_v{5};

    email_v.validate("user@example.com");  // PASS
    email_v.validate("invalid");            // FAIL
    len_v.validate("hi");                   // FAIL
    len_v.validate("hello!");               // PASS
    return 0;
}

RTTI и Dynamic cast

RTTI (Run-Time Type Information) позволяет запрашивать фактический тип полиморфного объекта во время выполнения. dynamic_cast(base_ptr) безопасно отливает базовый указатель на производный указатель, возвращая nullptr, если отливка не удалась. Для справок неудачный dynamic_cast бросает std::bad_cast.

typeid(obj) возвращает ссылку std::type_info, которую можно сравнить для идентификации типа среды выполнения.

Используйте RTTI экономно — частое использование dynamic_cast часто сигнализирует о проблеме проектирования. Предпочитает виртуальные функции для типового поведения.

Подводный камень

Никогда не называйте виртуальные функции конструктором или разрушителем. Во время строительства динамическим типом объекта является класс, который в настоящее время строится, а не конечный производный класс. Виртуальный вызов в Base::Base() будет называться Base::method(), а не Derived::method() — даже если создаваемый объект является Derived. Это известный источник багов, которые очень трудно диагностировать.

Ключевые выводы
  • Виртуальные функции позволяют работать с полиморфизмом через vtables и vptrs
  • Любой класс с виртуальными функциями должен иметь разрушитель virtual
  • Всегда используйте override при переопределении - он улавливает несоответствия подписей во время компиляции
  • Чистые виртуальные функции (= 0) делают класс абстрактным и заставляют производные классы реализовывать их.
  • NVI-паттерн позволяет базовым классам обеспечивать соблюдение предварительных/пост-условий вокруг точек настройки
  • Избегайте вызова виртуальных функций в конструкторах / деструкторах — производный класс еще не полностью построен.

Тест — проверьте свои знания

(20 XP)

1. Что произойдет, если у полиморфного базового класса нет виртуального деструктора?

2. Что делает ключевое слово `override`?

3. Почему стоит избегать вызова виртуальных функций конструктором?