Skip to content

Virtual Functions and Inheritance

Video: Back to Basics: Object-Oriented Programming in C++ - Amir Kirsh - CppCon 2022

Virtual functions allow you to write code that works with base class pointers but calls the appropriate derived class methods at runtime. Without virtual functions, the C++ compiler resolves function calls at compile time.

Virtual Functions: Runtime Polymorphism

Virtual functions enable runtime polymorphism - the correct function is called based on the actual object type:

cpp
class Base {
public:
    virtual void show() {  // Virtual function
        std::cout << "Base::show()" << std::endl;
    }
};

class Derived : public Base {
public:
    void show() override {  // Overrides Base::show()
        std::cout << "Derived::show()" << std::endl;
    }
};

void test_virtual() {
    Derived d;
    Base* ptr = &d;

    ptr->show();  // Calls Derived::show() - polymorphism!
    d.show();     // Also calls Derived::show()
}

Note: Always make destructors virtual in base classes intended for polymorphism. This ensures that the derived class destructor is called when an object is destroyed through a base class pointer and resources are properly cleaned up.

How does C++ implement virtual functions?

Virtual functions are implemented using Virtual Function Tables (VTables):

VTable Structure

cpp
class Animal {
public:
    virtual void speak() { std::cout << "Animal sound" << std::endl; }
    virtual void move() { std::cout << "Animal moves" << std::endl; }
    virtual ~Animal() = default;
};

class Dog : public Animal {
public:
    void speak() override { std::cout << "Woof!" << std::endl; }
    void move() override { std::cout << "Dog runs" << std::endl; }
};

class Cat : public Animal {
public:
    void speak() override { std::cout << "Meow!" << std::endl; }
    // Uses inherited move() function
};

Internal VTable representation:

cpp
Animal VTable:
[0] -> Animal::speak()
[1] -> Animal::move()
[2] -> Animal::~Animal()

Dog VTable:
[0] -> Dog::speak()     // Overridden
[1] -> Dog::move()      // Overridden  
[2] -> Dog::~Dog()      // Compiler-generated

Cat VTable:
[0] -> Cat::speak()     // Overridden
[1] -> Animal::move()   // Inherited
[2] -> Cat::~Cat()      // Compiler-generated

VTable Pointer (vptr)

Each object with virtual functions contains a hidden vptr:

cpp
class Animal {
    // Hidden: Animal* _vptr;  // Points to VTable
public:
    virtual void speak() = 0;
    int age;  // Regular member variable
};

// Memory layout of Animal object:
// [vptr][age]
//   |
//   v
// Animal VTable: [speak_ptr][destructor_ptr]

Virtual Function Call Process

The following steps are taken when calling a virtual function:

  1. Dereference vptr to access the object's VTable pointer
  2. Perform a vtable lookup to find the function pointer that needs to be called
  3. Call the function through the pointer

Performance Implications

Virtual function calls have small overhead:

  • Memory: Cost of storing the vptr and the VTable can affect cache performance
  • Time: At runtime, the control first needs to jump to the vtable and then to the actual function that will be called. This causes one extra indirection per virtual call, and may result in instruction cache misses as well.
  • Optimization: Compiler cannot inline and optimize virtual function calls since it doesn't know which function to call at compile time. This can lead to slower code generation and runtime performance.

Covariant Return Types

When overriding a virtual function, you can return a different type than the base class. This is called covariant return type. This is restricted to pointers and references, and they need to be of the same type or a type derived from the base class.

cpp
class Base {
public:
    virtual Base* clone() const {
        return new Base(*this);
    }
};

class Derived : public Base {
public:
    // Covariant return type: can return Derived* instead of Base*
    Derived* clone() const override {
        return new Derived(*this);
    }
};

dynamic_cast and RTTI

dynamic_cast is used to cast a derived class pointer or reference to a base class or vice versa. It is a safe way to cast pointers and references, and throws std::bad_cast if the conversion is not possible. dynamic_cast works if atleast one function in the class is marked virtual. This ensures that run-time type information is inserted in the class as part of the vtable. RTTI allows you to check the type of an object at runtime.

cpp
class Base {
public:
    virtual ~Base() = default;
};

class Derived : public Base {
public:
    ~Derived() override {
        std::cout << "Derived::~Derived()" << std::endl;
    }
};

int main() {
    Base* base = new Derived();
    Derived* derived = dynamic_cast<Derived*>(base);
    if (derived) {
        std::cout << "Cast successful" << std::endl;
    }
}