C++ Interview Prep · Deep Dive

Virtual Functions & Polymorphism

The complete guide, taught from scratch. Read top to bottom — each section builds on the last.

⚠️ interview trap 💬 one-liner worth saying verbatim

01The Core Problem: Static vs Dynamic Binding

Every function call must be resolved to an actual function. C++ has two ways:

struct Base {
    void f()         { std::cout << "Base::f\n"; }   // non-virtual: static
    virtual void g() { std::cout << "Base::g\n"; }   // virtual: dynamic
};
struct Derived : Base {
    void f()          { std::cout << "Derived::f\n"; }  // HIDES Base::f
    void g() override { std::cout << "Derived::g\n"; }  // OVERRIDES Base::g
};

Base* p = new Derived();
p->f();   // "Base::f"    — static type of p is Base*
p->g();   // "Derived::g" — dynamic type of *p is Derived

A same-signature non-virtual function in a derived class is called redefining or hiding — not overriding. It substitutes the function only when called through the derived type.

💬 One-liner"Static type drives lookup and overload resolution; dynamic type drives dispatch."

↑ back to top

02Dispatch Requires Indirection: Pointers, References, and Slicing

Dynamic dispatch only happens through a pointer or reference to base. A base object by value can never behave polymorphically:

Derived d;

Base b = d;    // copy-constructs a Base from d's Base subobject: SLICING
b.g();         // "Base::g" — b IS a Base, nothing to dispatch to

Base& r = d;   // no copy; r refers to the actual Derived
r.g();         // "Derived::g"
⚠️ Trap — the mechanism behind slicing

Why does the sliced object dispatch to Base? The copy constructor Base(const Base&) copies only Base's members, and every constructor installs its own class's vtable pointer. The new object's identity is Base, full stop. Slicing isn't just "data got dropped" — the object's dynamic type was rewritten.

Slicing also happens when passing by value:

void byValue(Base b) { b.g(); }   // always "Base::g", whatever you pass
void byRef(Base& b)  { b.g(); }   // dispatches properly
💬 One-liner"Polymorphism requires indirection; by-value slices."

↑ back to top

03The Machinery: vtables and vptrs

vtable (virtual table): a per-class static array of function pointers, one slot per virtual function in the hierarchy, built at compile time.

vptr (virtual pointer): a hidden per-object member (8 bytes on 64-bit) pointing to its class's vtable. Set by the constructor.

For p->g() where g is virtual:

  1. Load the vptr from the object *p.
  2. Load the function pointer from the vtable at g's fixed slot index (known at compile time, e.g. vtable[2]).
  3. Indirect call through it.
⚠️ Trap — "the vtable is indexed, not searched"

There is no runtime lookup, no "check derived, fall back to base." If Derived overrides g, its vtable slot for g holds &Derived::g; if it doesn't override, that same slot holds &Base::g. The decision was baked in when the compiler generated Derived's vtable. Dispatch is O(1) always.

Costs of a virtual call vs a direct call

💬 One-liner"The vtable is indexed, not searched — dispatch is O(1) and the real price is lost inlining."

↑ back to top

04The Three-Phase Call Resolution Model

Every call expr.name(args) resolves in three ordered phases. Most virtual-function traps live in the gaps between them.

  1. Name lookup. Start at the scope of the static type of expr. Find the first scope containing name, collect all declarations of name in that scope, stop. Never look further up.
  2. Overload resolution. Among the functions found in phase 1, pick the best match for the arguments. Compile time, static types only.
  3. Dispatch. If the chosen function is virtual and the call is through pointer/reference: runtime vtable dispatch to the final override. Otherwise: direct call.

Trap A: Hiding kills base overloads

struct Base {
    virtual void log(int)    { std::cout << "int\n"; }
    virtual void log(double) { std::cout << "double\n"; }
};
struct Derived : Base {
    void log(int) override { std::cout << "derived int\n"; }
};

Derived d;
d.log(3.14);   // "derived int" !!
⚠️ Trap A explained

Lookup starts in Derived, finds log(int), stops — Base::log(double) is invisible. 3.14 converts to int. Declaring any log in Derived hides all base overloads of that name. Hiding is per-name, not per-signature.

Fix:

struct Derived : Base {
    using Base::log;          // un-hides all base overloads
    void log(int) override;
};

Through a base handle there's no problem: Base& r = d; r.log(3.14); → lookup in Base sees both overloads → picks log(double) → not overridden → "double".

Trap B: A wrong signature silently fails to override

struct Base { virtual void process(int); };
struct Derived : Base {
    void process(long);   // different signature: NOT an override
};

Base* p = new Derived();
p->process(42);           // "base" — vtable slot still holds Base::process
⚠️ Trap B explained

Derived::process(long) is an unrelated function sharing the name. Lookup for p->process(42) starts in Base (static type of p), the derived function is never even visible, and the vtable slot for process(int) was never replaced. Classic silent typo: long vs int, missing const, & vs &&. The fix is the next section.

↑ back to top

05override and final

struct Derived : Base {
    void process(long) override;  // COMPILE ERROR: does not override anything
    void process(int)  override;  // OK, verified
    void done() final;            // no class below may override done()
};

struct Leaf final : Derived { };  // no class may derive from Leaf

↑ back to top

06Virtual Destructors

struct Base {
    virtual void f() {}
    ~Base() {}                 // NOT virtual — bug incoming
};
struct Derived : Base {
    std::string* data = new std::string(1000, 'x');
    ~Derived() { delete data; }
};

Base* p = new Derived();
delete p;                      // undefined behavior

delete p with a non-virtual destructor statically binds to ~Base(). ~Derived() never runs; data leaks.

⚠️ Two traps in one

Strictly this is undefined behavior (UB) per the standard — "leak" is the typical symptom, but say "UB" in the interview.

And: having a vtable doesn't save you — only functions declared virtual get vtable slots. The destructor call here never consults the vtable.

Rule: any class with virtual functions gets virtual ~Base() = default;. Since the vptr already exists, it's free — no size or speed cost you weren't already paying.

(Related rule: a base class should have either a public virtual destructor, or a protected non-virtual one — the latter for interface classes never deleted through the base.)

↑ back to top

07Virtual Calls in Constructors and Destructors

struct Base {
    Base() { init(); }               // calls Base::init, NOT the override
    virtual void init() { std::cout << "base init\n"; }
    virtual ~Base() = default;
};
struct Derived : Base {
    void init() override { std::cout << "derived init\n"; }
};

Derived d;   // prints "base init"

During Base::Base(), the object's dynamic type is Base: the vptr points at Base's vtable, and the Derived part doesn't exist yet. Dispatching to Derived::init() would touch uninitialized members, so the language dispatches to the constructor's own class. Destructors: same rule in reverse — by the time ~Base() runs, the Derived part is already destroyed.

💬 One-liner"The vptr always reflects the constructor or destructor currently running."

↑ back to top

08Access Control vs Dispatch — and the NVI Idiom

⚠️ The rule almost everyone gets wrong

Access is checked at compile time against the static type. Dispatch happens at runtime. They don't talk to each other.

struct Base {
    virtual void run() { std::cout << "base\n"; }
    virtual ~Base() = default;
};
struct Derived : Base {
private:
    void run() override { std::cout << "derived\n"; }
};

Derived d;
Base& r = d;
r.run();    // COMPILES, prints "derived" — access checked against Base (public)
d.run();    // COMPILE ERROR — access checked against Derived (private)

Yes: through the base handle you just called a private function, legally. The same function is callable or not depending on which handle you hold.

This enables the non-virtual interface (NVI) idiom (a.k.a. template method pattern): the base class exposes a public non-virtual function that defines the fixed skeleton, and customization points are private virtuals:

class Widget {
public:
    void draw() { validate(); doDraw(); clip(); }   // fixed contract
private:
    virtual void doDraw() = 0;                      // override me, but only I call you
};

Derived classes override doDraw; users can only enter through draw(). The base class keeps control of pre/post-conditions.

↑ back to top

09Pure Virtual Functions & Abstract Classes

struct Shape {
    virtual double area() const = 0;   // pure virtual
    virtual ~Shape() = default;
};

↑ back to top

10Performance & Devirtualization

Devirtualization = the compiler proving a virtual call has exactly one possible target, turning it into a direct (inlinable) call.

struct Base { virtual int f() { return 1; } virtual ~Base() = default; };
struct Derived final : Base { int f() override { return 2; } };

int a(Derived& d) { return d.f(); }    // devirtualized — because of `final`
int b(Base& base) { return base.f(); }  // stays virtual — could be anything

int c() {
    Derived d;
    Base& r = d;
    return r.f();                       // devirtualized — compiler sees d's construction
}

Data layout: the hidden cost of polymorphic collections ⚠️

std::vector<std::unique_ptr<Shape>> with 10M elements in a hot loop is slow beyond dispatch itself. The vector's pointers are contiguous but the objects are scattered across the heap → a cache miss per element, and the hardware prefetcher (which excels at streaming contiguous memory) can't predict pointer targets.

Layout hierarchy, best to worst for cache behavior:

#LayoutExampleWins when
1SoA — structure of arrays: one contiguous array per fieldvector<float> radii;Hot loops touch one or two fields across many objects. Every byte fetched is useful; loops vectorize (SIMD — single instruction, multiple data) trivially.
2AoS — array of structures: whole objects contiguousvector<Circle>You touch all fields of an object together. The natural layout.
3Array of pointers ("pointer soup"): contiguous pointers, scattered objectsvector<unique_ptr<Shape>>Never wins on cache; only when you truly need runtime polymorphism per element.

Fixes for the Shape problem, in order of impact:

💬 One-liner"Virtual dispatch per element costs less than what it prevents — inlining, vectorization, prefetching. If the hot loop is homogeneous per type, restructure so the compiler can see it."

↑ back to top

11Multiple Inheritance (MI) Mechanics

struct A { virtual void fa() {} int x; };
struct B { virtual void fb() {} int y; };
struct C : A, B { };

Layout — two vptrs:

C object: ┌──────────────┐ offset 0 │ A's vptr │ │ A::x │ ├──────────────┤ offset 16 (typical) │ B's vptr │ │ B::y │ └──────────────┘

Why can't they share one vptr? Code compiled against B knows nothing about C and expects a vptr at offset 0 from wherever the B* points. Each base subobject must be a valid object at its own address. (Single inheritance extends the base in place — one shared vptr — which is why this never came up before.)

Pointer adjustment ⚠️

C c;
A* pa = &c;   // same address (A is at offset 0)
B* pb = &c;   // compiler ADDS 16 — pb != (void*)&c

A derived-to-base conversion is not always a no-op — it can change the address. Consequences:

Virtual inheritance (struct C : virtual A) solves the diamond problem (one shared A subobject when two bases both inherit A) at the cost of runtime-located offsets. Know it exists and what problem it solves; don't volunteer implementation details.

💬 One-liner"Each base must be a valid object at its own address — so multiple bases mean multiple vptrs, and pointer conversions become address adjustments."

↑ back to top

12The Complete Memory Picture

Sections 3 and 11 in one image. Two hierarchies:

struct Base { virtual void f(); virtual void g(); int x; };
struct D1 : Base { void f() override; int a; };
struct D2 : Base { void g() override; double b; };

struct A { virtual void fa(); int x; };
struct B { virtual void fb(); int y; };
struct C : A, B { void fb() override; int z; };

How to read it: the left column is per-object bytes (offsets and stored values, on the stack or heap at runtime); the right column is per-class vtables (fixed addresses in the executable's read-only data). An arrow means "the value stored in this vptr is that vtable's address." Addresses are illustrative.

Single inheritance — Base, D1 : Base, D2 : Base Base b — 16 bytes 0 vptr = 0x5000 +8 int x = 42 0x5000 — Base vtable [0] f → &Base::f [1] g → &Base::g D1 d1 — 16 bytes (overrides f, adds a) 0 vptr = 0x5100 +8 int x = 42 (from Base) +12 int a = 9 (D1's own) 0x5100 — D1 vtable [0] f → &D1::f (override) [1] g → &Base::g (inherited) D2 d2 — 24 bytes (overrides g, adds b) 0 vptr = 0x5200 +8 int x = 42 (from Base) +16 double b = 2.5 (D2's own) 0x5200 — D2 vtable [0] f → &Base::f (inherited) [1] g → &D2::g (override) Multiple inheritance — C : A, B (overrides fb, adds z) C c — 40 bytes 0 vptr = 0x6000 +8 int x = 42 (A part) +16 vptr = 0x6020 +24 int y = 7 (B part) +32 int z = 5 (C's own) C* pc = &c A* pa = &c B* pb = &c+16 vtable (A in C) at 0x6000 [0] fa → &A::fa vtable (B in C) at 0x6020 [0] fb → thunk → C::fb

What the diagram encodes — the summary of this whole guide's mechanics:

↑ back to top

13dynamic_cast and RTTI (brief)

RTTI = run-time type information (the type data hanging off the vtable).

Base* p = ...;
if (auto* d = dynamic_cast<Derived*>(p)) { /* p really points to a Derived */ }
// pointer form: returns nullptr on failure
// reference form: dynamic_cast<Derived&>(r) throws std::bad_cast on failure

↑ back to top

14Cheat Sheet — One-Liners & Rules

One-liners

Rules

Trap checklist — what interviewers actually ask

↑ back to top