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:
- The virtual function is stored in the constant data region (not within the class itself)
- A hidden member called the virtual function table (vtable) is added to the class layout
- The vtable is essentially an array of function pointers
- 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:
- Follows the vptr to access the vtable
- Looks up the function pointer at the appropriate offset
- 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:
- Base class function must be virtual (the first virtual in the inheritance chain marks the polymorphic starting point)
- Derived class must override the virtual function
- Call must be through base class pointer or reference
- 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.