Deep Dive into C++ Virtual Functions, Covariant Returns, and Destructor Overriding

Virtual functions form the foundation of runtime polymorphism in C++. This article explores how virtual functions work, the mechanics of the vtable, and special cases like covariance and destructor overriding.

Virtual Functions

Consider the following code and analyze the output:

#include <iostream>
using namespace std;

class Base
{
public:
    virtual void display()
    {
        cout << "Base" << endl;
    }
};

class Derived : public Base
{
public:
    void display() override
    {
        cout << "Derived" << endl;
    }
};

class SubDerived : public Derived
{
public:
    void display() override
    {
        cout << "SubDerived" << endl;
    }
};

void Process(Base& ref)
{
    ref.display();
}

int main()
{
    Base base;
    Derived derived;
    SubDerived sub;

    Process(base);
    Process(derived);
    Process(sub);

    return 0;
}

The output demonstrates runtime polymorphism - the same function call produces different results based on the actual object type.

Virtual Function Table Mechanism

When a class declares a virtual function, the compiler performs the following:

  1. The virtual function is stored in the constant data region (not within the class itself)
  2. A hidden member called the virtual function table (vtable) is added to the class layout
  3. The vtable is essentially an array of function pointers
  4. All instances of the same class share a single vtable

The class only stores a pointer to the vtable, while the actual function implementations reside in the constant region.

Function Overriding

When a derived class implements a function with identical signature (name, parameters, return type) to a base class virtual function, this constitutes overriding. Overriding is a specialized form of hiding specific to polymorphism.

Key points:

  • The base class function must be marked virtual
  • The derived class function signature must match exactly (except for covariant returns)
  • Even without the virtual keyword in the derived class, overriding still works
  • Each derived class gets its own vtable with the overridden function addresses

Understanding Polymorphic Calls

Why does Process(Base& ref) work with derived types?

This leverages the is-a relationship in inheritance. When a Derived object is passed to a function expecting Base&, the reference binds to the base subobject of the Derived object. Both Base and Derived contain a vptr (virtual table pointer), but they point to different vtables.

During compilation, when the compiler encounters ref.display(), it doesn't know the exact type at compile time. Instead, it generates code that:

  1. Follows the vptr to access the vtable
  2. Looks up the function pointer at the appropriate offset
  3. Invokes the function through that pointer

This mechanism ensures the correct derived class implementation is called at runtime.

Callers: Reference vs Pointer vs Value

References and pointers support polymorphic calls because they preserve the object's identity:

void Process(Base* ptr)  // pointer also works
{
    ptr->display();
}

void Process(Base ref)   // value semantics - NO polymorphism
{
    ref.display();  // always calls Base::display()
}

Passing by value slices off the derived portions, leaving only the Base subobject.

Polymorphism Requirements

To achieve polymorphism:

  1. Base class function must be virtual (the first virtual in the inheritance chain marks the polymorphic starting point)
  2. Derived class must override the virtual function
  3. Call must be through base class pointer or reference
  4. Value semantics do not support polymorphism

Covariant Returns

Normally, overriding requires identical return types. However, covariance permits a special exception: the derived class override can return a pointer or reference to a more derived type.

#include <iostream>
using namespace std;

class Base
{
public:
    virtual Base& execute()
    {
        cout << "Base::execute" << endl;
        return *this;
    }
};

class Derived : public Base
{
public:
    Derived& execute() override
    {
        cout << "Derived::execute" << endl;
        return *this;
    }
};

void Process(Base& ref)
{
    ref.execute();
}

int main()
{
    Base base;
    Derived derived;

    Process(base);
    Process(derived);

    return 0;
}

Another example with unrelated but related class hierarchies:

#include <iostream>
using namespace std;

class Parent {};
class Child : public Parent {};

class Base
{
public:
    virtual const Parent* work()
    {
        cout << "Base::work" << endl;
        return new Parent;
    }
};

class Derived : public Base
{
public:
    const Child* work() override
    {
        cout << "Derived::work" << endl;
        return new Child;
    }
};

The return type must follow the inheritance hierarchy - you cannot reverse the direction. Covariance is rarely used in practice.

Destructor Overriding

Understanding destructor overriding provides deeper insight into how destructors work and why all destructors are internally renamed.

#include <iostream>
using namespace std;

class Base
{
public:
    ~Base()
    {
        cout << "Base destructor" << endl;
    }
};

class Derived : public Base
{
public:
    ~Derived()
    {
        cout << "Derived destructor" << endl;
        delete resource;
    }

    int* resource;
};

int main()
{
    Base* ptr = new Derived;
    delete ptr;  // Memory leak - only Base destructor called

    return 0;
}

This code leaks memory because only the Base destructor executes.

The solution is making the base destructor virtual:

class Base
{
public:
    virtual ~Base()
    {
        cout << "Base destructor" << endl;
    }
};

class Derived : public Base
{
public:
    ~Derived()
    {
        cout << "Derived destructor" << endl;
        delete resource;
    }

    int* resource;
};

Now the correct destructor sequence executes.

Why All Desturctors Become destructor()

The compiler performs a special transformation: all desrtuctors are internally renamed to destructor(). This ensures that base and derived destructors share the same internal name, satisfying the vtable requirements. Without this transformation, different class names would prevent proper vtable slot allocation for destructor entries.

When a derived destructor completes, it automatically invokes base class destructors through the normal inheritance mechanism, ensuring proper cleanup of the entire object hierarchy.

Tags: C++ Virtual Functions Polymorphism OOP vtable

Posted on Thu, 08 Oct 2026 16:28:12 +0000 by Yrogerg