This note covers polymorphism in C++ OOP. Polymorphism is one of the core features of object-oriented programming: the same interface with different implementations. In C++ it lets us call a derived class's function through a base class pointer or reference.
It comes in two forms:
- Static polymorphism, also called compile-time polymorphism, achieved through function overloading and templates.
- Dynamic polymorphism, also called runtime polymorphism, achieved through virtual functions and inheritance.
Put simply, polymorphism is the ability of a message to take several forms.
Dynamic polymorphism
Dynamic polymorphism is achieved through virtual functions and inheritance. We already introduced virtual functions in C++ #4 Functions; here we review them in detail and look more closely at what the vtable actually does.
Virtual functions
A virtual function is a member function that lets the call target be decided at runtime according to the object's actual type. It is marked with virtual in the base class and overridden with override in the derived class, producing dynamic binding. For example:
C++#include <iostream>
class Base {
public:
virtual void func() {
std::cout << "Base::func()" << std::endl;
}
virtual ~Base() = default;
};
class Derived : public Base {
public:
void func() override {
std::cout << "Derived::func()" << std::endl;
}
};
In Base we define a virtual function func(). In the derived class Derived we override it with the override keyword.
Now if we print the result in main():
C++int main() {
Base* obj = new Derived();
obj->func(); // prints Derived::func()
delete obj;
return 0;
}
Note that although obj is a Base pointer, the way it was created means it actually points at a Derived, so calling func() uses the override in Derived.
The essential point: virtual functions provide dynamic binding, so the function is chosen at runtime according to the object's actual type rather than decided at compile time. Deciding at compile time is called static binding, which is what a non-virtual function gets.
One more thing about override: it is not optional decoration. If the derived signature is wrong β a missing const, a mismatched parameter type β then without override the compiler silently concludes you are defining a new function. The program compiles and at runtime calls the base version instead. With override that case is a compile error. Write override on every override.
The vtable
A vtable (virtual function table) is a data structure generated for every class containing virtual functions. It holds the addresses of the class's virtual functions. Each object points at its class's vtable through a hidden pointer called the vptr. When a virtual function is called, the program looks the address up in the vtable through the vptr and jumps there.
The memory layout looks roughly like this:
Plain TextDerived object Derived's vtable (one per class, not per object)
+------------------+ +---------------------------+
| vptr | ----> | [0] &Derived::func |
+------------------+ | [1] &Derived::~Derived |
| Base members | +---------------------------+
+------------------+
| Derived members |
+------------------+
Following the example above:
- We create a
Derivedobject and hold it through aBasepointer,obj. - The compiler sees that
obj->func()calls a virtual function, so instead of emitting a direct call it emits "load the vptr, index the table, jump". - At runtime,
obj's vptr points atDerived's vtable. - The address of
Derived::func()is found there and the derived implementation runs.
Several practical consequences follow:
- Virtual functions cost something. Each call adds an indirection, and more importantly the compiler usually cannot inline it β the lost inlining is often the larger cost.
- Objects get bigger. A class holding a single
intgoes fromsizeof4 to 16 once it has a virtual function: 8 bytes of vptr, 4 of int, 4 of padding. In tightly packed arrays β particles, components β that is not negligible. - The vtable is per class, not per object. Each object only carries one extra pointer.
- Mark classes
finalwhen they are not meant to be inherited from. It gives the compiler a chance to devirtualize the call back into a direct one.
Virtual destructors
Pay particular attention here: if a base class will be used to delete derived objects through a pointer, the base destructor must be declared virtual, or resources leak. For example:
C++#include <iostream>
class Base {
public:
virtual ~Base() { // drop the virtual and the delete below is UB
std::cout << "~Base()" << std::endl;
}
};
class Derived : public Base {
public:
Derived() { data = new int[100]; }
~Derived() override {
delete[] data;
std::cout << "~Derived()" << std::endl;
}
private:
int* data;
};
int main() {
Base* obj = new Derived();
delete obj;
return 0;
}
Note that Derived, inheriting from Base, holds a private array data. Its constructor allocates the space and its destructor releases it. So in main(), obj as a Base pointer must be able to reach Derived's destructor when it is deleted β which is why Base's destructor has to be virtual.
To say it again: it must be virtual. Without it the standard calls this undefined behaviour, and in practice ~Derived() simply never runs, leaking those hundred ints forever.
Destructors also behave differently from ordinary virtual functions: calling a virtual destructor runs the derived destructor first and then the base destructor, rather than only the derived one. This ensures resources are released layer by layer as the object is destroyed.
So the output of the main() above is:
Plain Text~Derived()
~Base()
Constructors
In C++, constructors cannot be virtual. As mentioned in earlier notes:
- Construction order: the base constructor runs first, then the derived constructor.
- This is because the base part must be complete before the derived class can extend it.
Calling a virtual function inside a constructor does not reach the derived override; it calls the version defined in the current class, keeping compile-time binding:
C++struct Base {
Base() { init(); } // this calls Base::init
virtual void init() { std::cout << "Base\n"; }
};
struct Derived : Base {
void init() override { std::cout << "Derived\n"; }
};
Derived d; // prints "Base", not "Derived"
The behaviour is justified: while the base constructor runs, the derived object is not yet fully constructed and its members are still uninitialized memory. Calling the derived init() there would very likely read garbage. The vtable pointer is only rewritten to the derived vtable once the base constructor finishes and the derived constructor begins.
The same applies in destructors, in reverse: by the time the base destructor runs, the derived part has already been destroyed and the vptr has reverted to the base vtable.
The conclusion: do not call virtual functions from constructors or destructors. When you need "do something type-specific once construction has finished", separate it into an init() the caller invokes afterwards, or wrap both steps behind a factory function.
Static polymorphism
Static polymorphism binds at compile time, so there is no vtable, no indirection, and nothing preventing inlining. It takes three common forms.
Function overloading
Same name, different parameter lists; the compiler picks one at compile time from the argument types:
C++void draw(const Circle& c);
void draw(const Square& s);
void draw(const Circle& c, float scale);
draw(circle); // picks the first
draw(circle, 2.0f); // picks the third
Overload resolution looks only at parameters, never at the return type, so two functions differing only in return type cannot overload. Watch out for surprising matches caused by implicit conversions too β one of the reasons C++ #5 Objects and Classes recommends explicit on single-argument constructors.
Templates
Templates are the main form of static polymorphism. They express "any type supporting this set of operations works here", without requiring those types to share a base class:
C++template <typename T>
T maxOf(const T& a, const T& b) {
return (a < b) ? b : a;
}
maxOf(3, 7); // T = int
maxOf(3.5, 2.1); // T = double
maxOf(std::string("a"), std::string("b"));
The compiler generates one copy of the code per T used. Which means:
- Zero runtime overhead, and the call can be inlined.
- No inheritance relationship is needed between the types, only support for the operations used β here,
<. This implicit interface is C++'s form of duck typing. - The costs are code bloat (one instantiation per type), longer compiles, and famously unreadable template error messages. C++20 concepts exist largely to fix the last one.
Nearly all of the standard library algorithms are built this way: std::sort does not require elements to derive from some Comparable, only that < works.
CRTP
The Curiously Recurring Template Pattern β a derived class passes itself as the template argument to its base, so the base knows the concrete derived type at compile time:
C++template <typename Derived>
class Shape {
public:
void draw() {
static_cast<Derived*>(this)->drawImpl(); // resolved at compile time, inlinable
}
};
class Circle : public Shape<Circle> {
public:
void drawImpl() { std::cout << "Circle\n"; }
};
This achieves the same "base calls into derived implementation" as a virtual function, without a vptr or a vtable lookup. The cost is losing runtime uniformity: Shape<Circle> and Shape<Square> are two unrelated types and cannot go into the same std::vector.
Choosing between them
| Dynamic (virtual) | Static (template / CRTP) | |
|---|---|---|
| Binding | Runtime | Compile time |
| Overhead | vptr plus an indirect call, hard to inline | None, inlinable |
| Object size | One extra pointer per object | Unchanged |
| Set of types | Open; add derived classes later | Closed; fixed at compile time |
| Same container | Yes, through base pointers | No |
| Code emitted | One copy | One per instantiation |
The criterion is direct: if the set of types is only known at runtime, use virtual functions β plugins, the assorted objects in a scene, state machines. If the set is known at compile time and the code sits on a hot path, use templates β maths libraries, containers, the fixed inner pipeline of a renderer.
A game engine typically uses both: GameObject in the scene graph is virtual, because arbitrary types get added at runtime; while the per-frame maths over tens of thousands of particles is templated, because one extra indirection multiplied by tens of thousands stops being small.
C++17's std::variant plus std::visit offers a third route: a closed set of types, no heap allocation, no vtable, and it still goes in a container. See C++ #11 C++17 New Features.
