Hello! Welcome back.
In our last few lessons, we've explored the world of C++ templates, culminating in variadic templates—a powerful mechanism for writing code that works with any number of arguments at compile time. This is the essence of compile-time polymorphism: resolving types and generating specific code before the program even runs.
Today, we will contrast this with C++'s other major form of polymorphism: runtime polymorphism, which relies on inheritance and virtual functions. This lesson directly addresses one of the most fundamental design trade-offs in C++: when to solve a problem at compile time versus at runtime. For a systems programmer, understanding the deep mechanical differences and performance implications of this choice is absolutely critical.
Our goal is to explain the fundamental differences and use cases for compile-time polymorphism (templates) versus runtime polymorphism (virtual functions). We will dissect how each works "under the hood" to understand their respective costs and benefits.
The Problem: How to Call the Right Function?
Let's start with a classic object-oriented scenario. You have a base class pointer that could be pointing to objects of different derived types.
class Animal {
public:
virtual void speak() { /* default sound */ }
};
class Dog : public Animal {
public:
void speak() override { /* woof */ }
};
class Cat : public Animal {
public:
void speak() override { /* meow */ }
};
void make_sound(Animal* animal_ptr) {
animal_ptr->speak(); // Which speak() gets called? Dog's or Cat's?
}
The compiler, looking only at the type Animal*, cannot know the actual type of the object at compile time. The decision must be delayed until the program is running. This is called dynamic dispatch. How does the CPU execute this?
Runtime Polymorphism: The V-Table Mechanism
The "magic" of virtual functions is not magic at all; it's a well-defined mechanical pattern implemented by the compiler. It involves two key components: the virtual pointer (vptr) and the virtual table (vtable).
- V-Table (Virtual Method Table): For each class that has at least one virtual function, the compiler generates a static array of function pointers. This array is the vtable. There is only one vtable per class.
- V-Pointer (Virtual Pointer): When an object of such a class is created, the compiler secretly inserts a hidden pointer into the object's memory layout. This pointer, the
vptr, points to the vtable for that class. Every object instance has its ownvptr.
When you make a virtual function call like animal_ptr->speak(), the generated machine code performs these steps:
- Dereference the
animal_ptrto get to the object's data. - Read the hidden
vptrfrom the object's memory (usually at the very beginning). - Follow the
vptrto the class's vtable in static memory. - Look up the correct function pointer in the vtable using a fixed offset (e.g.,
speak()might be the first entry). - Call the function at that address.

This mechanism is a standard design pattern. In fact, you could implement it yourself in C using structs and function pointers, which gives you a first-principles understanding of what the C++ compiler does for you automatically.
C++ Inheritance Explained: V-Tables, Virtual Destructors & Edge Cases
The video 'C++ Inheritance Explained' from the DeepDiveDev channel provides an excellent, low-level explanation of this entire mechanism. It inspects object memory layouts, shows the vptr, explains the vtable, and even rebuilds the entire system in plain C. This is a must-watch to internalize how runtime polymorphism truly works.
Watch from the beginning to 14:31. Pay close attention to: The distinction between static and dynamic binding. How the vptr increases object size and where it's located in memory. The manual C implementation of the vtable mechanism (this will connect well with your C background). The two main performance costs: indirection (pointer chasing) and the prevention of inlining.
As the video explained, the costs of dynamic dispatch are significant:
- Memory Overhead: Each object carries an extra
vptr(typically 8 bytes on a 64-bit system). - Runtime Overhead (Indirection): Each call involves at least two extra memory reads (one to get the
vptr, one to get the function address from the vtable). This can lead to cache misses if the vtable isn't in the CPU's cache. - Optimization Barrier (No Inlining): This is often the biggest cost. Because the compiler doesn't know the exact function to be called, it cannot perform its most powerful optimization: inlining. It must generate a full function call, which involves setting up a stack frame, jumping, and returning.
Compile-Time Polymorphism: Static Dispatch with Templates
Now, let's contrast this with the template-based polymorphism we've been studying.
template<typename T>
void make_sound_static(T& animal) {
animal.speak(); // Resolved at COMPILE time
}
Dog my_dog;
make_sound_static(my_dog); // Compiler generates a version of make_sound_static for Dog
Cat my_cat;
make_sound_static(my_cat); // Compiler generates a separate version for Cat
Here, the process is entirely different:
- When the compiler sees
make_sound_static(my_dog), it knows the typeTisDog. - It instantiates a specific version of the function:
void make_sound_static(Dog& animal) { animal.speak(); }. - The call
animal.speak()is resolved directly toDog::speak. This is static binding. - Because the call target is known, the compiler is free to inline the
Dog::speak()function, potentially replacing the call with the function's body directly. Novptr, novtable, no runtime lookup.
This is fundamentally more efficient at the machine level. To see just how different the generated code is, we'll examine an article that benchmarks virtual calls against a form of static polymorphism called the Curiously Recurring Template Pattern (CRTP).
The cost of dynamic (virtual calls) vs. static (CRTP) dispatch in C++
The article 'The cost of dynamic (virtual calls) vs. static (CRTP) dispatch in C++' by Eli Bendersky does a fantastic job of what you're interested in: analyzing the generated assembly and performance counters to prove the performance difference.
Read from the beginning down to the 'Performance numbers' section. Focus on these key areas: Hypothesis: Understand the theoretical reasons why virtual calls are slower. Generated Code for Virtual Calls: Look at the disassembly. Note the instructions for the double indirection: mov (%rbp),%rax (get vptr) followed by callq *(%rax) (call function via vtable). Generated Code for CRTP: Observe that the tick method is completely inlined into the main loop. The inner loop is just a few simple instructions. Performance Numbers: Compare the instruction counts and execution times. The static dispatch version is not just a little faster; it's an order of magnitude faster in this tight loop because it enables further optimizations.
A Head-to-Head Comparison: Choosing the Right Tool
It's clear that static polymorphism is faster. So why would anyone use virtual functions? Because performance isn't the only factor. The choice depends on the problem you're solving.
| Feature | Runtime Polymorphism (Virtual Functions) | Compile-Time Polymorphism (Templates) |
|---|---|---|
| Binding | Dynamic Binding (at runtime) | Static Binding (at compile time) |
| Mechanism | vptr in objects, vtable per class |
Template instantiation |
| Flexibility | High. Can handle types unknown at compile time (e.g., from plugins/DLLs). Can store heterogeneous types in one container (std::vector<std::unique_ptr<Animal>>). |
Low. All types must be known at compile time. Cannot easily mix unrelated types in a standard container. |
| Performance | Lower. Overhead from indirection and inability to inline. | Higher. Direct calls, enabling inlining and other optimizations. |
| Memory | Per-object vptr overhead. Single copy of function code. |
No per-object overhead. Potential for "code bloat" from many instantiations. |
| Coupling | Tight. Requires a common base class and an inheritance hierarchy. | Loose. Works with any type that satisfies the syntactic requirements ("duck typing" or C++20 concepts). |
| Error Reporting | Errors like invalid casts can be runtime errors. | Errors like a type missing a method are compile-time errors. |
The following video explores this trade-off from a modern C++ perspective, arguing that many traditional uses of virtual functions can now be replaced with template-based designs for better performance and type safety.
Using Modern C++ to Eliminate Virtual Functions - Jonathan Gopel - CppCon 2022
The talk 'Using Modern C++ to Eliminate Virtual Functions' by Jonathan Gopel from CppCon 2022 provides a practical guide on when and how to prefer template-based solutions.
Watch the selected clips that cover the core comparison: 00:00 - 01:11: Understand the three main use cases for virtual functions that the talk will address. 01:11 - 03:25: Hear the motivation for eliminating virtual functions (performance, moving errors to compile time). 03:56 - 07:12: See how an interface defined with a virtual base class can be replaced by a C++20 concept. 22:50 - 27:48: Contrast storing multiple types using std::vector<std::unique_ptr<Base>> (virtual) with a 'tuple of vectors' pattern (template). 29:50 - 32:22: Acknowledge the downsides of the template approach (code bloat, complexity).
Test your understanding!
You are designing a system that processes different kinds of network packets (TCPPacket, UDPPacket, ICMPPacket). All packet types share a common process() method. Consider two requirements:
-
Requirement A: The system must be able to support new packet protocols that are loaded at runtime from shared libraries (
.sofiles) without recompiling the main application. -
Requirement B: The system processes an extremely high volume of packets, and all possible packet types are known at compile time. Performance is the absolute top priority, and even nanoseconds of overhead per packet are critical.
Which form of polymorphism would you choose for each requirement, and why?
Show answer
-
For Requirement A, you must use runtime polymorphism (virtual functions).
The key is "loaded at runtime from shared libraries." Since the compiler of the main application has no knowledge of the types defined in a future plugin, you cannot use templates. You would define a commonPacketbase class with avirtual void process(), and the main application would work withPacket*pointers. The shared libraries would provide concrete implementations (e.g.,NewProtocolPacket) that inherit fromPacket. -
For Requirement B, you should choose compile-time polymorphism (templates or
std::variant).
The key is "performance is the absolute top priority" and "all possible packet types are known at compile time." Using virtual functions would introduce unacceptable overhead (vtable lookup, no inlining). A template-based approach allows the compiler to generate specialized, highly-optimized code for each packet type. A common pattern for this is usingstd::variant<TCPPacket, UDPPacket, ICMPPacket>along withstd::visitto apply a processing function, which effectively uses a form of static dispatch without an inheritance hierarchy.
Conclusion
You have now seen the two faces of polymorphism in C++. They are not competing features, but powerful tools designed for different scenarios.
Key Takeaways:
- Runtime Polymorphism (
virtualfunctions) achieves flexibility through dynamic binding. It uses avptrandvtableto look up the correct function at runtime. Its main cost is performance overhead and a reliance on inheritance. It's essential when you need to handle types that are unknown at compile time. - Compile-Time Polymorphism (templates) achieves high performance through static binding. The compiler generates specialized code for each type, enabling optimizations like inlining. Its main cost is potential code bloat and the requirement that all types be known at compile time. It's the foundation of generic programming in C++, like the STL.
Choosing between them is a core architectural decision. As a systems developer aiming for high performance, you will often lean towards compile-time solutions, but you must recognize when the runtime flexibility offered by virtual functions is necessary.
What's Next?
We've now established the theoretical and practical differences between these two approaches. In the next module, we'll begin our exploration of the Standard Template Library (STL) internals. We'll start with std::vector, a perfect case study of compile-time polymorphism in action. You'll see how templates are used to create a generic, high-performance container, and we'll analyze its internal memory management strategy, which is crucial for writing efficient and correct code.