Skip to content

Virtual Functions, vtables & Polymorphism

Dive into runtime polymorphism: how virtual dispatch works under the hood, when to use override and final, abstract classes, and RTTI.

Runtime Polymorphism

Polymorphism means "many forms" — the same interface behaves differently depending on the actual type of the object. In C++, runtime polymorphism is achieved through virtual functions. When you call a virtual function through a base pointer or reference, the call is dispatched to the actual derived class's implementation at runtime.

This is the mechanism that makes OOP powerful: you can write code that operates on a base class interface, and it automatically works correctly with any derived class — even ones written after your code.

How vtables Work

When a class has virtual functions, the compiler creates a vtable (virtual function table) — an array of function pointers. Each object of that class contains a hidden vptr (pointer to the vtable). When a virtual function is called, the program follows the vptr to the vtable, looks up the function pointer, and calls through it. This indirection is why virtual calls have a small overhead compared to direct calls.

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 Destructors Are Mandatory

If a class has any virtual function, its destructor must be virtual. Without it, deleting a derived object through a base pointer causes undefined behavior — the derived destructor never runs, leaking resources.

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 and final Keywords

The override keyword tells the compiler you intend to override a base class virtual function. If the signature doesn't match (a common typo), the compiler catches it. The final keyword prevents further overriding of a function or prevents inheritance from a class.

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;
}

Abstract Classes & NVI Pattern

A class with at least one pure virtual function (= 0) is abstract — it cannot be instantiated. The Non-Virtual Interface (NVI) pattern provides a public non-virtual entry point that calls a private virtual function, letting the base class control pre/post conditions.

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) lets you query the actual type of a polymorphic object at runtime. dynamic_cast(base_ptr) safely casts a base pointer to a derived pointer, returning nullptr if the cast fails. For references, a failed dynamic_cast throws std::bad_cast.

typeid(obj) returns a std::type_info reference that can be compared to identify the runtime type.

Use RTTI sparingly — frequent dynamic_cast usage often signals a design problem. Prefer virtual functions for type-specific behavior.

Pitfall

Never call virtual functions from a constructor or destructor. During construction, the object's dynamic type is the class currently being constructed, not the final derived class. A virtual call in Base::Base() will call Base::method(), not Derived::method() — even if the object being created is a Derived. This is a well-known source of bugs that can be extremely difficult to diagnose.

Key Takeaways
  • Virtual functions enable runtime polymorphism via vtables and vptrs
  • Any class with virtual functions must have a virtual destructor
  • Always use override when overriding — it catches signature mismatches at compile time
  • Pure virtual functions (= 0) make a class abstract and force derived classes to implement them
  • The NVI pattern lets base classes enforce pre/post conditions around customization points
  • Avoid calling virtual functions in constructors/destructors — the derived class is not yet (or no longer) fully constructed

Quiz — Test Your Knowledge

(20 XP)

1. What happens if a polymorphic base class does NOT have a virtual destructor?

2. What does the `override` keyword do?

3. Why should you avoid calling virtual functions from a constructor?