How virtual Works: vtable & vptr Memory Layout
When you add virtual, the compiler slips a hidden business card (_vptr, 8 bytes) into your pocket. Calling speak() reads the card, dials the phone number listed on it (vtable), and reaches the exact Derived method in 2 CPU hops.
Placing __vptr at offset 0 allows dynamic dispatch to proceed with the fastest possible assembly instructions: the CPU can dereference the object pointer directly (mov (%rdi), %rax) without adding a memory offset. Furthermore, under single inheritance, both Base* and Derived* point to the exact same memory address, making pointer upcasts zero-cost no-ops.
The vtable resides in the .rodata (read-only data) segment of the executable binary. Exactly one vtable per polymorphic class is generated by the compiler across the entire program. All 100,000 instances of Derived share the single read-only Derived vtable in memory; only the 8-byte __vptr is replicated per object instance.
Devirtualization is an optimization where the compiler proves that a virtual function call can only ever target one specific override (e.g. when called on a concrete value object, or when a class/method is marked final). The compiler replaces the two-hop indirect vtable jump with a direct call instruction, enabling inlining, constant propagation, and vectorization.
Interviewer's lens
Interviewers drill into virtual mechanics to see if you view polymorphism as magic syntax or understand machine-level memory layout:
- Object Layout: Drawing the hidden 8-byte
__vptrat byte offset 0 and explaining how alignment padding expands struct size (e.g., from 4 bytes to 16 bytes). - The Two-Hop Dispatch: Tracing how
basePtr->fn()compiles down to dereferencing__vptr, indexing the.rodatafunction pointer array, and jumping to machine instructions. - Construction Lifecycles: Proving why virtual constructors are physically impossible and why virtual dispatch is disabled inside constructors and destructors to prevent accessing uninitialized derived fields.
Under the hood & memory layout
Dynamic dispatch is purely mechanical. When a class declares or inherits a virtual function, the compiler coordinates three pieces:
- Where
__vptrlives (In the Object): The compiler injects a hidden 8-byte pointer (__vptr) at offset 0 of every object instance. If an object has member variables, they are shifted down to start at offset 8. - What
.rodatacontains (In the Executable Binary): The compiler generates exactly one shared array of function pointers called thevtable(virtual method table) per polymorphic class. It resides in read-only data memory (.rodata), preceded by runtime type information (RTTI at offset -8). - The 2-Hop CPU Call (At the Call Site): Invoking
basePtr->speak()translates into two machine-level pointer indirections:(*basePtr->__vptr[slot])(basePtr).- Hop 1: The CPU reads
basePtrto load the__vptraddress (mov (%rdi), %rax). - Hop 2: The CPU indexes into the
vtableat a fixed slot offset to load the function pointer and jump to it (call *0x10(%rax)), passingbasePtras the hiddenthispointer.
- Hop 1: The CPU reads
Constructor & Destructor Invariant: An object cannot have a virtual constructor because __vptr is written by the constructor prologue. While Base::Base() runs, __vptr points to Base's vtable; only when Derived::Derived() begins does __vptr advance to Derived's vtable. During ~Base(), __vptr rolls back to Base to protect already-destroyed derived memory.
Placed at offset 0 of the object. Even an empty class with one virtual function jumps from 1 byte (sizeof(Empty) == 1) to 8 bytes (sizeof(VirtualEmpty) == 8).
If a struct contains a single 4-byte int, introducing a virtual method adds the 8-byte vptr and 4 bytes of tail padding to satisfy the 8-byte alignment of the pointer, jumping from 4 bytes to 16 bytes ($300%$ increase!).
An immutable array of code pointers shared by all instances of the same class. Each virtual function occupies one 8-byte slot determined at compile time.
mov (%rdi), %rax (dereference object to fetch vptr) followed by call *0x10(%rax) (fetch and jump to slot). Prevents compiler inlining unless devirtualized.
Core concepts & syntax traps
The Hidden __vptr & Memory Alignment Padding (4B vs 16B)
The Hidden __vptr & Memory Alignment Padding (4B vs 16B)
Adding a virtual function silently alters the physical size and memory alignment of your struct:
- The Hidden Pointer: On 64-bit systems, the compiler injects an 8-byte pointer (
__vptr) at offset 0. - Alignment Requirement: Because the struct now contains an 8-byte pointer, the entire struct's memory alignment requirement rises from 4 bytes to 8 bytes (
alignof = 8). - Tail Padding Explosion:
- A non-virtual struct with one
inttakes 4 bytes ([4B int]). - Adding a virtual destructor inserts the 8-byte
__vptrand appends 4 bytes of trailing padding ([8B vptr] [4B int] [4B padding]). - Total size jumps from 4 bytes to 16 bytesβa 4Γ memory bloat that can degrade CPU cache performance when storing millions of objects.
- A non-virtual struct with one
struct FlatData {
int id; // 4 bytes, alignof = 4, sizeof = 4
};
struct PolyData {
virtual ~PolyData() = default; // introduces 8-byte vptr
int id; // 4 bytes + 4 bytes tail padding!
// Total: 8 (vptr) + 4 (id) + 4 (padding) = 16 bytes, alignof = 8
};Adding a virtual function to small data classes introduces an 8-byte vptr plus alignment padding, significantly increasing cache footprint.
Dynamic Dispatch Lowering: Two Pointer Hops & Instruction Cache Cost
Dynamic Dispatch Lowering: Two Pointer Hops & Instruction Cache Cost
When you call basePtr->speak(), the compiler executes a two-hop pointer chain:
- Hop 1 (Fetch VTable): The CPU reads the first 8 bytes of the object instance to load the
__vptr, discovering where the class'svtablelives in.rodata. - Hop 2 (Fetch Method Address): The CPU reads the function pointer at the compile-time slot offset (e.g.,
vtable[slot]) and executes an indirect branch (call *%rax).
Why This Matters for Performance:
- No Inlining: Because the target function address is determined at runtime, the compiler cannot inline the method body or propagate constants.
- Branch Mispredictions: The CPU branch target buffer (BTB) must guess where the jump leads. If heterogeneous objects alternate in a loop, pipeline stalls occur.
// What you write in C++:
basePtr->speak();
// How the compiler lowers it under the hood:
using SpeakFn = void (*)(const Base*);
void** vtable = *reinterpret_cast<void***>(basePtr); // Hop 1: fetch vptr
SpeakFn fn = reinterpret_cast<SpeakFn>(vtable[slot_index]); // Hop 2: fetch slot
fn(basePtr); // Indirect call passing this pointerDynamic dispatch incurs two pointer dereferences, defeats compiler inlining, and can cause instruction cache misses.
Why Constructors Can NEVER Be Virtual
Why Constructors Can NEVER Be Virtual
Expecting a constructor to be virtual is a physical chicken-and-egg paradox:
- VTable Dispatch Requires a
__vptr: To call a virtual function, the CPU must read the__vptrembedded inside the target object. - The Object Does Not Exist Yet: Before the constructor runs, memory contains only raw uninitialized bytes. The constructor's very first job is to stamp the
__vptrinto the object's memory header. - The Paradox: You cannot use
__vptrto look up the constructor when the constructor is the code responsible for creating the__vptr.
Interview Solution: When asked for a 'virtual constructor', use the Virtual Copy Constructor idiom (clone()) or a Factory Pattern.
// ILLEGAL IN C++:
// virtual Base(); // Compiler Error: 'virtual' is not permitted on constructors
// IDIOMATIC SOLUTION: Prototype Pattern / Virtual Constructor Idiom
class Shape {
public:
virtual ~Shape() = default;
[[nodiscard]] virtual std::unique_ptr<Shape> clone() const = 0; // Virtual copy ctor
};
class Circle : public Shape {
public:
[[nodiscard]] std::unique_ptr<Shape> clone() const override {
return std::make_unique<Circle>(*this);
}
};Constructors cannot be virtual because the vptr is initialized by the constructor itself; use clone() or factory methods instead.
The Constructor & Destructor Virtual Dispatch Trap
The Constructor & Destructor Virtual Dispatch Trap
Calling a virtual function inside a constructor or destructor is a classic C++ trap:
- Construction Sequence: When constructing a
Derivedobject,Base::Base()runs first. At that moment, the compiler sets__vptrto point toBase's vtable. Any virtual call invokesBase's version, neverDerived's. - Why C++ Does This:
Derived's member variables have not been constructed yet. If C++ allowed callingDerived::foo(), it would attempt to read uninitialized memory and crash. - Destruction Sequence: In reverse, when
~Base()runs,Derived::~Derived()has already finished tearing down derived members. The compiler resets__vptrback toBase's vtable to prevent methods from accessing dead memory.
class Base {
public:
Base() { init(); } // DANGER: Calls Base::init(), NOT Derived::init()!
virtual void init() { std::println("Base::init"); }
virtual ~Base() = default;
};
class Derived : public Base {
std::string resource_;
public:
Derived() : resource_("Active") {}
void init() override {
// If called from Base(), resource_ would be UNINITIALIZED GARBAGE!
std::println("Derived::init: {}", resource_);
}
};Never call virtual functions in constructors or destructors; dynamic dispatch is disabled to protect uninitialized derived members.
Pure Virtual Function Call Crash (__cxa_pure_virtual) & Two-Phase Init
Pure Virtual Function Call Crash (__cxa_pure_virtual) & Two-Phase Init
If a base constructor invokes a pure virtual method (= 0), your program will crash at runtime:
- The Pure Virtual Slot: In
Base's vtable, pure virtual slots do not point to real function code. They point to a crash-handler stub named__cxa_pure_virtual(). - The Crash: If
Base::Base()calls a pure virtual method (directly or indirectly via a helper function), the CPU jumps into__cxa_pure_virtual(), printspure virtual method called, and callsstd::terminate(). - The Safe Idiom (Two-Phase Initialization): Keep constructors simple and non-virtual. Perform polymorphic setup inside a separate
postInitialize()method wrapped in a static factory function (create()) after the derived object is fully constructed.
// TWO-PHASE FACTORY IDIOM:
class Service {
protected:
Service() = default; // Direct construction restricted
virtual void postConstruct() = 0; // Safe polymorphic setup
public:
virtual ~Service() = default;
template <typename T, typename... Args>
static std::unique_ptr<T> create(Args&&... args) {
auto obj = std::make_unique<T>(std::forward<Args>(args)...);
obj->postConstruct(); // Object is fully constructed: dynamic dispatch works!
return obj;
}
};Use factory methods with two-phase initialization to execute polymorphic setup safely after constructors finish.
Unlock Live Implementation & Interview Scenarios
The C++23 production code, live coding steps, scenario twist solutions, and common UB traps are part of Brute2Best Pro. Module 1 is completely free.
First anchor question per pattern, Part 1 LLD & Module 1 C++ basics are 100% Free forever.