Static inheritance resolves polymorphic behavior entirely at compile time via C++ templates. The canonical mechanism is the Curiously Recurring Template Pattern (CRTP):
template <typename Derived>
class Base {
public:
void interface() {
// Downcast is resolved at compile time — zero runtime cost
static_cast<Derived*>(this)->implementation();
}
// Default implementation (optionally overridable)
void implementation() {
std::cout << "Base::implementation\n";
}
};
class Derived : public Base<Derived> {
public:
void implementation() {
std::cout << "Derived::implementation\n";
}
};The compiler, upon instantiating Base<Derived>, has full knowledge of the concrete type. The static_cast<Derived*>(this) is not a runtime operation — it is a reinterpret at the type-system level, with no emitted machine instruction beyond what the call itself requires. The method dispatch is a direct call, identical to a non-virtual member function call.
Because the full type hierarchy is visible to the optimizer at instantiation time, the compiler can:
- Inline
implementation()aggressively. - Perform constant folding and dead branch elimination across the inheritance boundary.
- Apply loop unrolling when CRTP is used in tight numerical loops (e.g., expression templates in linear algebra libraries like Eigen).
| Property | Detail |
|---|---|
| Dispatch mechanism | Direct call / inlined |
| Vtable | None |
| Type resolution | Compile time |
| Binary size | Can increase due to template instantiation |
| Runtime overhead | Zero |
| Open/closed principle | Closed — hierarchy fixed at compile time |
Dynamic inheritance relies on runtime polymorphism through the vtable (virtual function table) mechanism, which is part of the ABI but not mandated by the C++ standard (all major implementations use it).
class Base {
public:
virtual void implementation() {
std::cout << "Base::implementation\n";
}
virtual ~Base() = default; // Essential for correct destruction via base pointer
};
class Derived : public Base {
public:
void implementation() override {
std::cout << "Derived::implementation\n";
}
};When a class declares or inherits a virtual function, the compiler:
- Emits a vtable (a static array of function pointers) per concrete class, stored in the
.rodataor.data.rel.rosegment. - Injects a hidden
vptr(virtual pointer) into every object instance — typically at offset 0, consuming one pointer width (8 bytes on x86-64). This inflates every object'ssizeof. - At a virtual call site, the generated code performs:
; Pseudoassembly for obj->implementation()
mov rax, [rdi] ; load vptr from object (indirect memory read)
call [rax + offset] ; dispatch through vtable slot (second indirect)This is a two-level pointer indirection at runtime. It categorically prevents the compiler from inlining across the call boundary (unless the compiler can perform devirtualization — see §4).
In multiple inheritance, each base subobject may have its own vptr, and this pointer adjustments (thunks) are emitted to correct the receiver address when calling through a non-primary base's vtable. Virtual inheritance (used to solve the diamond problem) adds an additional layer: a vbase offset stored either in the vtable or as a hidden member, making object layout and dispatch even more complex and expensive.
class A { virtual void f(); };
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {}; // Diamond — A's subobject sharedHere, D carries multiple vptrs, and calls through B* or C* to A's interface involve thunk adjustments.
// CRTP object — no overhead
[ Derived data members ]
// Virtual object — vptr injected
[ vptr (8 bytes) | Base data members | Derived data members ]
// Static (CRTP) — after inlining, may reduce to zero instructions
template<typename D> void Base<D>::interface() {
static_cast<D*>(this)->implementation(); // direct call or inlined
}
// Dynamic — always at minimum two loads + indirect call
void call(Base* b) {
b->implementation(); // load vptr, index vtable, call
}The raw virtual dispatch penalty on modern x86-64 hardware involves:
- Two memory indirections: loading the
vptrthen indexing the vtable. If these addresses are cold in the L1/L2 cache, each miss costs ~4ns (L1), ~12ns (L2), or ~40ns (L3) — orders of magnitude more than a direct call. - Branch misprediction: The indirect call goes through the CPU's indirect branch predictor (IBP). If many derived types flow through the same call site (megamorphic dispatch), the IBP fails to predict the target, incurring a ~15–20 cycle pipeline flush on modern microarchitectures (e.g., Intel Golden Cove). This is a primary bottleneck in polymorphic hot loops.
- Inlining barrier: Because the callee is unknown at compile time, the compiler cannot inline across the call site. This forecloses the entire downstream optimization graph: no constant propagation, no vectorization of the callee's body relative to the caller's context, no CSE across the boundary.
Modern compilers (GCC, Clang) implement several devirtualization strategies:
- Speculative devirtualization: The compiler emits a type guard and a direct call for the most likely type, with a fallback to the vtable path. This allows inlining on the fast path.
- Whole-program devirtualization (WPD) via LTO (Link-Time Optimization): With
-flto -fwhole-program-vtables, the linker can prove, across translation units, that a virtual call site is monomorphic and replace it with a direct call entirely. - Final/sealed classes: Marking a class
finalis a direct hint to the compiler that no further derivation exists, enabling unconditional devirtualization at any call site where the static type is known.
class Derived final : public Base { // 'final' enables devirtualization
void implementation() override { ... }
};- Zero-cost abstraction: The abstraction completely disappears after compilation. In benchmarks of numerical kernels (e.g., matrix operations using expression templates), CRTP-based designs match the speed of hand-written monomorphic code.
- Auto-vectorization friendly: Since the compiler sees the full call graph, it can vectorize loops that cross the "inheritance boundary" — impossible with virtual dispatch.
- No object size inflation: Absence of
vptrimproves cache line utilization, particularly critical when storing arrays of polymorphic objects.
- Cold paths: If the polymorphic call is not on a hot path (e.g., factory construction, error handling, UI events), the virtual dispatch overhead is negligible and the design flexibility is worth it.
- Plugin/ABI boundaries: When derived types are loaded at runtime (shared libraries, plugins), the full type is structurally unknowable at compile time — CRTP is impossible. Virtual dispatch (or a C-compatible function pointer table) is mandatory.
- Type erasure:
std::function,std::any, and similar vocabulary types internally use virtual dispatch (or equivalent manual vtable tricks) precisely because the concrete type must be erased.
A middle ground that avoids both CRTP complexity and virtual overhead for closed type sets is:
using Shape = std::variant<Circle, Square, Triangle>;
std::visit([](auto& s) { s.draw(); }, shape); // Resolved via jump table, not vtablestd::visit generates a static jump table (similar to a vtable but with no per-object pointer) and dispatches in O(1). Crucially, each arm of the visit can be individually inlined and optimized. This is strictly more performant than virtual dispatch for closed hierarchies and avoids the template code-bloat of CRTP.
| Dimension | CRTP (Static) | Virtual (Dynamic) | std::variant/visit |
|---|---|---|---|
| Dispatch cost | Zero (inlined) | 2 indirections + IBP | Jump table (O(1), inlinable) |
| Inlining | Full | Blocked (unless devirt.) | Full per-arm |
| Vectorization | Yes | Blocked | Yes |
| Open hierarchy | No | Yes | No |
| Runtime type switching | No | Yes | Yes (within set) |
| Object size | No overhead | +8 bytes (vptr) |
sizeof(largest) + tag |
| Code bloat risk | High (template inst.) | Low | Low |
| ABI/plugin support | No | Yes | No |
The decisive rule for performance-critical systems: use static inheritance (CRTP) or std::variant/visit whenever the full type set is known at compile time and the call is on a hot path. Reserve virtual dispatch for open hierarchies, ABI boundaries, and cold paths — and leverage final, LTO, and PGO (Profile-Guided Optimization) to allow the compiler to recover direct-call performance wherever possible.
Note
Generated by Claude.ai
Model: Sonet 4.6
Prompt: Provide a thorough description of static and dynamic inheritance in a C++ program. This description is intended for a computer science expert. Then, evaluate its applicability in the context of the final program's execution speed.