C++ Programming
Lesson 21 of 24

Lesson 21 · 24 lesson course

21. Polymorphism

Understanding the cost and benefit of polymorphism

Programming glossary 90 terms mentioned in this lesson

Select any term for a clear definition. The return button brings you back to the exact term link you used.

Abstract common interface dispatching one signal to varied forms with distinct responses.

 

 

 

Lesson: Polymorphism in C++ — Correct Method at Runtime

Polymorphism (literally “many forms”) in C++ means:

Allowing the correct method to be called at runtime irrespective of the static (identifier) type.

We achieve this with virtual functions. A base class declares a function virtual; derived classes may override it. When we call that function through a reference or pointer to the base, the language selects the most derived override at runtime. This enables treating many different objects uniformly (common base), while still getting each object’s specialised behaviour.

Programming Insight (AI) — “Did I get dynamic dispatch?”
  • Paste a snippet; ask AI: “Which override runs for each call and why?”
  • Have AI flag calls that slice objects or bypass virtual dispatch.

1) A Minimal Example

#include <iostream>
struct A {
    virtual ~A() = default;
    virtual void add() const { std::cout << "A::add\n"; }
};

struct B : A {
    void add() const override { std::cout << "B::add\n"; }
};

void call_add(const A& ref) {  // static type: A
    ref.add();                 // runtime dispatch → A::add or B::add depending on object
}

int main() {
    A a; B b;
    call_add(a);  // prints A::add
    call_add(b);  // prints B::add (correct method despite A-typed ref)
}

Key points:

Programming Insight (AI) — Override & Destructor Audit
  • Ask AI to add override/final and verify your base destructor is virtual where needed.

2) Why Identifier Type Doesn’t Decide the Call

The static type (e.g., A&) is how we refer to an object; the dynamic type (e.g., “actually a B”) controls which override runs. This is the essence of your “A ref bound to a B object must call B::add()” scenario.

Technically, most compilers use a per-class table (vtable) of virtuals; the base reference/pointer carries a hidden link to that table. The call goes through that link, landing on the most-derived override.


3) Working with Collections of Mixed Objects

#include <memory>
#include <vector>

struct Op { virtual ~Op() = default; virtual int run(int x) const = 0; };
struct Inc : Op { int run(int x) const override { return x + 1; } };
struct Square : Op { int run(int x) const override { return x * x; } };

int main() {
    std::vector<std::unique_ptr<Op>> ops;
    ops.push_back(std::make_unique<Inc>());
    ops.push_back(std::make_unique<Square>());
    int x = 3;
    for (auto& op : ops) x = op->run(x);  // correct method for each element
    // x == (3 + 1)^2 == 16
}

Store polymorphic objects via pointers (usually smart pointers) or references. A container of base objects (std::vector<Op>) would slice.

Programming Insight (AI) — Make My Container Polymorphic
  • Ask AI to convert std::vector<Base> to std::vector<std::unique_ptr<Base>> and update call sites safely.

4) Abstract Bases, Interfaces & Contracts

A pure virtual (abstract) function forces derived classes to provide an implementation.

struct Drawable {
    virtual ~Drawable() = default;
    virtual void draw() const = 0; // pure virtual → interface requirement
};

Keep interfaces small and stable. Consider NVI (Non-Virtual Interface): a public non-virtual that enforces checks, delegating to a protected virtual.


5) Overriding Correctly (const, ref-qualifiers, covariant returns)

  • Signatures must match: constness and reference qualifiers (&, &&) are part of it.
  • Returns may be covariant: a derived override may return a more derived pointer/reference.
struct Base {
    virtual Base* clone() const = 0;
};
struct Derived : Base {
    Derived* clone() const override { return new Derived(*this); } // covariant OK
};
Programming Insight (AI) — Catch Mismatched Overrides
  • Have AI scan for “hidden” functions (e.g., base f(int), derived f(double)); add override or using Base::f as needed.

6) What Doesn’t Dispatch Dynamically

  • Calls on objects by value of the base type (slicing removes the derived part).
  • Calls to non-virtual functions.
  • Calls inside base constructors/destructors: only the current subobject is “active”.
  • Default arguments are bound statically (based on the static type), even for virtuals.
struct B { virtual void f(int x = 1) const { /* ... */ } };
struct D : B { void f(int x = 2) const override { /* ... */ } };
void g(const B& b){ b.f(); } // calls D::f if b is D, but x == 1 (B's default) → static binding

7) Performance & Design Notes

  • Virtual dispatch adds one indirection; usually tiny, but it can block inlining/vectorisation on hot paths.
  • final on classes/methods + whole-program optimisations may devirtualise calls.
  • Prefer composition over deep hierarchies; keep bases minimal and stable to avoid fragile-base problems.
Programming Insight (AI) — Hot Path Alternatives

8) Static Polymorphism (Compile-Time Alternative)

When the set of types is known and performance is critical, prefer templates/CRTP (no vtables, compile-time dispatch).

template <typename T>
void run_all(T& t) { t.run(); }   // chosen at compile time

struct A { void run(){ /*...*/ } };
struct B { void run(){ /*...*/ } };

// Over a heterogeneous list known at compile time, e.g. std::tuple<A,B> + std::apply

Static polymorphism is not “better”; it’s a different tool: faster and simpler binaries, but less flexible at runtime.


9) Type Queries & Downcasts (use sparingly)

  • Prefer virtual interfaces; avoid dynamic_cast when design can express behaviour polymorphically.
  • If you must downcast, check with dynamic_cast<Derived*> and handle nullptr.
if (auto p = dynamic_cast<Derived*>(basePtr)) { /* use p */ }

10) Worked Example — Your “add()” Story in Practice

#include <iostream>
#include <memory>
#include <vector>

struct A {
    virtual ~A() = default;
    virtual void add() const { std::cout << "A::add\n"; }
};

struct B : A {
    void add() const override { std::cout << "B::add\n"; }
};

void call_all(const std::vector<std::unique_ptr<A>>& v){
    for (const auto& p : v) p->add(); // correct version for each element
}

int main() {
    std::vector<std::unique_ptr<A>> v;
    v.push_back(std::make_unique<A>());
    v.push_back(std::make_unique<B>());
    call_all(v);
}

Even though the identifier type is A*, the dynamic type differs per element, and the right add() runs for each.

Programming Insight (AI) — Replace Switches with Polymorphism
  • Give AI a big switch(type) or if/else ladder; get an interface + derived classes or a strategy object design.

11) Common Pitfalls (and fixes)

  • Slicing: avoid std::vector<base>; use references or smart pointers.
  • Missing virtual dtor: deleting via Base* without a virtual destructor is UB.
  • Signature mismatch: always use override; mind const and ref-qualifiers.
  • Virtuals in ctors/dtors: don’t rely on derived overrides there.
  • Default args + virtual: defaults are chosen by static type; pass explicit args to avoid surprises.

12) Mini Exercises

  1. Repair and run: Make a base with virtual ~Base(), virtual void step() const; derive two classes that override. Create a std::vector<std::unique_ptr<Base>> and call step() for all.
  2. Find the slice: Why does void draw(Base b) not dispatch? Fix the API.
  3. Covariant clone: Add virtual Base* clone() const = 0; and implement covariant returns in derived types.
  4. Default-arg gotcha: Reproduce the default-argument issue and fix it by removing defaults or passing explicit values.
Programming Insight (AI) — Check My Answers
  • Paste your solutions; ask AI to verify dispatch behaviour, point out slicing, and suggest better interfaces.

Summary Checklist

  • Declare virtual in bases; override in derived; add a virtual destructor.
  • Call through Base&/Base* (not by value) to get dynamic dispatch; avoid slicing.
  • Keep interfaces small; prefer composition for code reuse; use NVI to protect invariants.
  • Be aware of limits: constructors/destructors and default args behave statically.
  • Use smart pointers for polymorphic ownership; consider templates/CRTP on hot paths.

Takeaway: Polymorphism lets you write uniform code over diverse objects; C++ makes it simple and fast—if you respect the rules above.

Advanced perspective: runtime selection is useful only when every selected behaviour keeps one promise

Virtual dispatch selects the final overrider; the interface must still make that behaviour correct

Runtime polymorphism lets one call site use a stable base interface while the complete object's dynamic type selects a virtual implementation. The static type of the expression tells the compiler which operations may be requested. The dynamic type identifies the complete object present during execution. For a virtual call, the final overrider supplies the implementation selected for that object.

Calling this the "correct method" hides an important design obligation. C++ can select Circle::area() for a circle. It cannot decide whether that function honours the meaning, units, valid inputs and failure behaviour promised by Shape::area(). Dispatch solves runtime selection. A common behavioural contract makes the selected result usable.

QuestionAnswered byWhat remains to be proved
Which operations are available at the call site?The expression's static type.That the interface contains the operation the caller genuinely needs.
Which override runs?The dynamic type and final-overrider rules for a virtual call.That every override honours the same contract.
How long does the object remain usable?The ownership and lifetime design.That every reference or pointer remains valid across the call.
Is the design fast enough?Measurement of the representative program.Which cost comes from dispatch, allocation, locality or the operation itself.

Trace static type, dynamic type, final overrider and owner together

#include <iostream>
#include <memory>
#include <numbers>
#include <vector>

class Shape {
public:
    virtual ~Shape() = default;
    virtual double area() const = 0;
};

class Rectangle final : public Shape {
public:
    Rectangle(double width, double height) : width_{width}, height_{height} {}

    double area() const override {
        return width_ * height_;
    }

private:
    double width_;
    double height_;
};

class Circle final : public Shape {
public:
    explicit Circle(double radius) : radius_{radius} {}

    double area() const override {
        return std::numbers::pi * radius_ * radius_;
    }

private:
    double radius_;
};

int main() {
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.push_back(std::make_unique<Rectangle>(3.0, 4.0));
    shapes.push_back(std::make_unique<Circle>(2.0));

    for (const auto& shape : shapes) {
        std::cout << shape->area() << '\n';
    }
}

The vector owns two separately allocated complete objects through unique_ptr<Shape>. The first pointer owns a Rectangle; the second owns a Circle. Inside the loop, the expression shape is a reference to a unique_ptr<Shape>, and shape-> reaches each object through the base interface.

For the first call, the dynamic type is Rectangle and the final overrider is Rectangle::area(), giving 3 times 4, or 12. For the second, the dynamic type is Circle and Circle::area() gives 4 times pi. Both calls have the same source spelling. Their selected implementations differ because their complete objects differ.

IterationStatic interfaceDynamic typeFinal overriderMathematical result
FirstShapeRectangleRectangle::area() const12
SecondShapeCircleCircle::area() const4 * std::numbers::pi

The override specifier asks the compiler to verify that each derived declaration actually overrides a base virtual. A missing const, different parameter or incompatible qualifier then becomes a diagnostic instead of silently introducing a separate function. final on these derived classes states that no further class may derive from them. Neither keyword proves the area formula; tests still must.

Ownership is not an optional footnote to dispatch

A reference or pointer preserves access to the complete derived object rather than creating a sliced base value. It does not say who owns that object. In this example, each unique_ptr<Shape> owns exactly one allocation, the vector owns those smart pointers, and destruction follows the vector's lifetime. That ownership chain is part of why the loop is safe.

The base destructor is virtual because deletion occurs through a base-typed owning pointer. When a unique_ptr<Shape> releases a Circle, destruction must reach the complete circle before its Shape base part. The smart pointer automates the delete expression; the virtual destructor makes base-directed deletion correct for the dynamic type. One mechanism cannot substitute for the other.

RepresentationDynamic identityOwnership statementPrincipal concern
const Shape&Preserved.Non-owning borrow.The complete object must outlive the reference.
Shape*Preserved when it designates a derived object.Not established by the pointer type alone.Nullability, lifetime and deletion policy must be stated.
std::unique_ptr<Shape>Preserved.Exclusive ownership.The base needs the appropriate destruction interface.
Shape by valueOnly the base value exists in the destination.The destination owns that base value.Slicing removes derived state and behaviour.

This abstract Shape cannot itself be stored as concrete values because its pure virtual operation leaves it abstract. Even for a concrete base, a vector of base values would contain base objects, not a heterogeneous set of complete derived objects. Smart pointers are not selected merely because "polymorphism uses pointers"; they are selected here because the collection owns objects whose concrete sizes and types differ at runtime.

Know exactly when virtual dispatch is suppressed or limited

Call contextSelection ruleWhy it matters
Virtual call through a base reference or pointerSelects the final overrider for the dynamic type.This is the ordinary runtime-polymorphic case.
Qualified call such as object.Shape::operation()Qualification suppresses virtual dispatch for that call.The named base implementation is requested deliberately.
Call during base construction or destructionDoes not dispatch to a more-derived part outside the currently active construction or destruction stage.A derived implementation must not be expected before its part exists or after it has been destroyed.
Non-virtual member callUses static lookup and overload resolution.A similarly named derived function does not make the call virtual.
Default argument on a virtual functionThe default expression is selected from the static type, while the virtual body may be selected dynamically.One call can combine a base default with a derived override.

The default-argument case is especially instructive. Dynamic dispatch does not retroactively make every part of the call dynamic. If callers omit an argument through a base interface, the base declaration supplies the default even when a derived body executes. A stable virtual interface should avoid making correctness depend on different defaults in different overrides.

Abstract means incomplete for direct objects, not absent behaviour

A pure virtual function makes the class abstract, so the program cannot create a direct object of that class. It requires a final overrider in any concrete derived object that is instantiated. The base still supplies state, non-virtual operations and, in some designs, even a definition for a pure virtual function that can be called through qualification. "Pure" describes the interface requirement; it does not mean the base contains nothing.

Keep the base contract small enough that every implementation can honour it without type tests or invented failure modes. A large interface often forces derived classes to implement operations that make no sense for them. Empty stubs and exceptions added solely because the base demanded the function are evidence that the abstraction may be too broad.

Correction to the earlier clone interface: a raw pointer returned from clone() can use covariant pointer return types, but it leaves ownership and deletion as an easy-to-miss convention. std::unique_ptr<Derived> is not covariant with std::unique_ptr<Base>; those are different class-template specialisations. A modern owning virtual interface can return std::unique_ptr<Base> from the base and every override. The derived implementation may construct a derived object and return it through that owning base pointer. A clear ownership contract is worth more than preserving raw-pointer covariance.

Downcasts should be evidence, not the default extension mechanism

dynamic_cast can test or recover a derived type through a polymorphic base. A pointer cast reports failure with nullptr; a reference cast reports failure by throwing std::bad_cast. The facility is legitimate when an operation genuinely applies to one optional capability or when crossing an external interface.

A repeated chain of downcasts often says something else: callers know all concrete types and the base lacks the behaviour they actually need. Adding a virtual operation, separating capabilities into smaller interfaces, using a visitor or choosing a closed-set representation may express the design more honestly. The cast is not automatically wrong. It is a request to explain why runtime type discovery belongs at that caller.

Implementation folklore is not the language contract

Implementations commonly realise virtual dispatch with virtual tables and hidden per-object information. The language specifies observable call behaviour rather than requiring one exact layout. Teach the common mechanism when it helps explain cost or debugging, but do not turn an implementation model into a portable memory-layout promise.

A virtual call may add an indirect branch and can inhibit inlining where the compiler cannot determine the target. In a heterogeneous owning collection, separate allocations and pointer chasing may affect locality as much as or more than the dispatch itself. The virtual function's own work may dominate both. Measure the complete hot path with representative objects and an optimised build before redesigning the architecture around a presumed single-call cost.

Templates, variants, function objects and type erasure offer other forms of variation. Static polymorphism can expose concrete types to optimisation but may increase code generation, compile time and coupling. A variant suits a closed set of alternatives and makes visitation explicit. Virtual dispatch suits an open family behind a stable runtime interface. Select from the variation requirement first; performance evidence then refines the choice.

Programming Insight (AI): require a four-column dispatch trace

For every generated polymorphic call, ask for the expression's static type, the complete object's dynamic type, the selected final overrider and the owner that keeps the object alive. Add the base precondition and postcondition, then run the same contract tests against every concrete implementation through the base interface.

Ask separately whether the call occurs during construction, destruction or with a qualified name, and whether any default argument is selected statically. If the model draws a vtable but omits lifetime, contract or exceptional call context, it has explained an implementation sketch rather than the working program.

Transfer task: make one call site earn its uniformity

Design an abstract Effect with apply(double input) const. The base contract says that a finite input must produce a finite result or report failure explicitly; it must not modify the effect. Create three proposed implementations: a fixed addition, a multiplication and a division by a configured value. Decide what construction must reject, what the virtual operation promises and how failure is represented without weakening the base for one derived type.

Place mixed effects in an owning collection and trace one input through them. For each call, record static type, dynamic type, final overrider, result and owner. Then compare a virtual hierarchy with a closed variant of the same three effects. Which design permits new effect types without modifying the caller? Which makes the complete set visible to the compiler? Do not choose by repeating "virtual is flexible" or "variant is faster". State the extension model and the measurement that would settle any material performance difference.

Reveal answer

The base must promise one result shape which every effect can honour. I will use a variant so failure remains a value the caller is required to inspect:

#include <cmath>
#include <memory>
#include <stdexcept>
#include <variant>
#include <vector>

enum class EffectError {
    non_finite_input,
    non_finite_result
};

using EffectResult = std::variant<double, EffectError>;

class Effect {
public:
    virtual ~Effect() = default;

    [[nodiscard]] virtual EffectResult
    apply(double input) const = 0;
};

EffectResult checked_result(double value)
{
    if (!std::isfinite(value)) {
        return EffectError::non_finite_result;
    }
    return value;
}

class FixedAddition final : public Effect {
public:
    explicit FixedAddition(double amount) : amount_{amount}
    {
        if (!std::isfinite(amount_)) {
            throw std::invalid_argument{"addition must be finite"};
        }
    }

    EffectResult apply(double input) const override
    {
        if (!std::isfinite(input)) {
            return EffectError::non_finite_input;
        }
        return checked_result(input + amount_);
    }

private:
    double amount_;
};

class Multiplication final : public Effect {
public:
    explicit Multiplication(double factor) : factor_{factor}
    {
        if (!std::isfinite(factor_)) {
            throw std::invalid_argument{"factor must be finite"};
        }
    }

    EffectResult apply(double input) const override
    {
        if (!std::isfinite(input)) {
            return EffectError::non_finite_input;
        }
        return checked_result(input * factor_);
    }

private:
    double factor_;
};

class Division final : public Effect {
public:
    explicit Division(double divisor) : divisor_{divisor}
    {
        if (!std::isfinite(divisor_) || divisor_ == 0.0) {
            throw std::invalid_argument{"divisor must be finite and non-zero"};
        }
    }

    EffectResult apply(double input) const override
    {
        if (!std::isfinite(input)) {
            return EffectError::non_finite_input;
        }
        return checked_result(input / divisor_);
    }

private:
    double divisor_;
};

Construction rejects non-finite configuration values, and division also rejects positive or negative zero. Once an object exists, apply does not alter it. A finite input either produces a finite double or returns non_finite_result. Non-finite input is also reported explicitly. Division does not need a weaker base contract because its zero-divisor state was removed during construction.

Consider this owning pipeline:

std::vector<std::unique_ptr<Effect>> effects;
effects.push_back(std::make_unique<FixedAddition>(2.0));
effects.push_back(std::make_unique<Multiplication>(3.0));
effects.push_back(std::make_unique<Division>(4.0));

Starting with 8.0 and feeding each successful result to the next effect gives this trace:

CallStatic receiver typeDynamic typeFinal overriderResultOwner
1Effect*FixedAdditionFixedAddition::apply10.0The first unique_ptr in effects.
2Effect*MultiplicationMultiplication::apply30.0The second unique_ptr in effects.
3Effect*DivisionDivision::apply7.5The third unique_ptr in effects.

The caller should stop the pipeline at the first EffectError; treating an error as another number would destroy the base promise.

The virtual hierarchy describes an open family. A separately compiled fourth effect can derive from Effect, and this loop need not change provided the new type honours the same contract. A variant containing non-polymorphic value forms of addition, multiplication and division describes a closed family. Its complete alternative set is visible to the compiler, and a visitor can be checked against that set, but adding a fourth effect changes the variant type and normally the visitor.

Neither representation wins from its label. Benchmark the complete representative pipeline in an optimised build, using the same inputs, error checks, effect order and observable results. Include allocation and data-layout choices rather than timing an isolated call from one design and a whole loop from the other. Report total throughput or latency for the real workload, plus code size if that is a material constraint. Only that comparison can show whether dispatch is important here.

Runtime polymorphism earns its place when one stable contract lets a caller remain ignorant of several genuine implementations. The dispatch mechanism chooses the body. The type design, ownership model and tests decide whether that body belongs there.