Polymorphism

Learning Objectives

Distinguish compile-time (overloads, templates) and run-time (virtual dispatch) polymorphism.

Explain static vs dynamic binding and how virtual calls are typically implemented.

Use safe downcasting and avoid object slicing.

Compare polymorphism models across C++, Java, C#, Python and TypeScript.

Prerequisites: Modules 4, 7.

Intuition and Definition

Polymorphism ("many forms") lets one piece of code work with values of different types.

function and operator overloading templates / generics Subtype (inclusion)

virtual functions called through a base pointer/reference

Static binding chooses the function from the *declared* type at compile time; dynamic binding chooses it from the object's *actual* (dynamic) type at run time. In C++ only virtual functions called through a pointer or reference are dynamically bound.

Most compilers implement virtual calls with a vtable (a per-class table of function pointers) and a hidden vptr in each object that points to its class's vtable. This is a common implementation model, not something the C++ standard requires.

Why It Matters

Polymorphism removes type switches from client code. notify(n) works for email, SMS and push notifiers — and for ones written next year.

Syntax and Language Notes

class Notifier {
public:
    virtual void send(const std::string& msg) const = 0;
    virtual ~Notifier() = default;
};
void broadcast(const Notifier& n) { n.send("hi"); }  // dynamic dispatch
// Java (not run): every non-static, non-final, non-private method is virtual
Notifier n = new EmailNotifier();
if (n instanceof EmailNotifier e) { e.setSubject("Hi"); }   // pattern-matching instanceof
Notifier n = new EmailNotifier();
if (n is EmailNotifier e) e.Subject = "Hi";   // is-pattern
var maybe = n as SmsNotifier;                 // null if not an SmsNotifier
# duck typing: anything with a send() method works, no base class required
def broadcast(n):
    n.send("hi")
// structural typing: the compiler checks shape, not declared ancestry
interface Notifier { send(msg: string): void; }
const consoleNotifier = { send: (m: string) => console.log(m) };   // compatible without "implements"

Complete Example

Object slicing — copying a derived object into a base *value* throws away the derived part:

TypeScript's structural typing — and the fact that types vanish at run time:

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

class Notifier {
public:
    virtual void send(const std::string& msg) const = 0;
    virtual std::string channel() const { return "generic"; }
    std::string tag() const { return "[base tag]"; }          // NOT virtual -> static binding
    virtual ~Notifier() = default;
};

class EmailNotifier : public Notifier {
public:
    void send(const std::string& msg) const override { std::cout << "Email: " << msg << "\n"; }
    std::string channel() const override { return "email"; }
    std::string tag() const { return "[email tag]"; }         // hides, does not override
};

class SmsNotifier : public Notifier {
public:
    void send(const std::string& msg) const override { std::cout << "SMS:   " << msg.substr(0, 10) << "\n"; }
    std::string channel() const override { return "sms"; }
};

// compile-time polymorphism: overloads and a template
void describe(int x)                { std::cout << "int " << x << "\n"; }
void describe(const std::string& s) { std::cout << "string " << s << "\n"; }
template <typename T> T twice(T v)  { return v + v; }

int main() {
    std::vector<std::unique_ptr<Notifier>> ns;
    ns.p
…
Email: Service due in 500 km
SMS:   Service du
virtual channel(): email
non-virtual tag(): [base tag]
tag() on EmailNotifier: [email tag]
found email notifier: email
not email: sms
int 42
string oil
42 abab
#include <iostream>
#include <string>

struct Warning {
    std::string text = "generic warning";
    virtual std::string show() const { return "Warning: " + text; }
    virtual ~Warning() = default;
};
struct CriticalWarning : Warning {
    int code = 503;
    std::string show() const override { return "CRITICAL " + std::to_string(code) + ": " + text; }
};

void byValue(Warning w)            { std::cout << "by value:     " << w.show() << "\n"; }
void byReference(const Warning& w) { std::cout << "by reference: " << w.show() << "\n"; }

int main() {
    CriticalWarning c;
    c.text = "brake pressure low";
    byValue(c);        // sliced: only the Warning part is copied
    byReference(c);    // polymorphic
}
by value:     Warning: brake pressure low
by reference: CRITICAL 503: brake pressure low
interface Notifier { send(msg: string): string; }

class EmailNotifier implements Notifier {
  send(msg: string) { return `email: ${msg}`; }
}
const inline = { send: (msg: string) => `inline: ${msg}` };   // never declared "implements"

function broadcast(n: Notifier) { console.log(n.send("hello")); }
broadcast(new EmailNotifier());
broadcast(inline);                       // accepted: same shape
console.log(new EmailNotifier() instanceof EmailNotifier);    // classes exist at run time
console.log(typeof (inline as unknown)); // interfaces do not: just "object"
email: hello
inline: hello
true
object

Step-by-Step Walkthrough

Each element of ns is a unique_ptr<Notifier>; n->send() looks up the function in the object's vtable and calls the right override.

channel() is virtual, so calling it through asBase gives email. tag() is not virtual, so the call is bound at compile time to Notifier::tag() from the reference's static type.

dynamic_cast<const EmailNotifier*> returns nullptr when the object is not an EmailNotifier — a checked downcast that needs RTTI.

Overloads are chosen by argument types at compile time; twice is instantiated twice, once per type.

Slicing: byValue(c) copies only the Warning subobject into a Warning parameter; its vptr is the Warning vtable, so the base show() runs and code is lost.

flowchart LR
  P[asBase reference] --> O[EmailNotifier object]
  O --> V[vptr]
  V --> T[EmailNotifier vtable]
  T --> F1[EmailNotifier::send]
  T --> F2[EmailNotifier::channel]

Common Mistakes

Passing polymorphic objects by value (slicing). Use references or (smart) pointers.

Expecting non-virtual functions to dispatch dynamically.

Downcasting everywhere — repeated dynamic_cast/instanceof chains usually mean a virtual function is missing.

Overloading vs overriding confusion: a derived function with a different parameter list overloads/hides instead of overriding; override catches it.

Assuming TypeScript interfaces exist at run time — instanceof Notifier is impossible for an interface.

Trade-offs and Alternatives

Extensible with new types?

Extensible with new operations?

Yes — add a class No — every class must change Indirect call, usually heap objects std::variant + std::visit No — closed set Yes — add a visitor

Value semantics, no heap; compile-time exhaustiveness Templates / concepts Compile-time only; code size

This is the classic expression problem; the Visitor pattern (Module 49) is the OO workaround.

Real-World Use

Notification services, plugin hosts, renderer back ends (OpenGL/Vulkan/Metal), and diagnostic-service handlers (one class per UDS service) dispatch polymorphically. Compile-time polymorphism dominates high-performance libraries (STL algorithms, Eigen).

Practice

Q1. (Easy) Name the two compile-time forms of polymorphism in C++.

Answer: Overloading (functions and operators) and templates.

Q2. (Medium) Why does std::vector<Notifier> not work for polymorphic notifiers?

Answer: Notifier is abstract and cannot be stored by value; even for a concrete base, elements would be sliced. Store std::unique_ptr<Notifier> (or use std::variant for a closed set).

Q3. (Advanced) Rewrite the notifier example as a closed set with std::variant and std::visit.

#include <iostream>
#include <string>
#include <variant>
#include <vector>
struct Email { std::string address; };
struct Sms   { std::string number; };
using Notifier = std::variant<Email, Sms>;
template <class... F> struct overloaded : F... { using F::operator()...; };
int main() {
    std::vector<Notifier> ns{Email{"ops@example.com"}, Sms{"+15550100"}};
    for (const auto& n : ns)
        std::visit(overloaded{
            [](const Email& e) { std::cout << "email to " << e.address << "\n"; },
            [](const Sms& s)   { std::cout << "sms to " << s.number << "\n"; },
        }, n);
}
email to ops@example.com
sms to +15550100

Interview Question

Q1. "What is object slicing, and how do you prevent it?"

Answer: Slicing happens when a derived object is copied or assigned into an object of a base class *by value*: only the base subobject is copied and the copy's dynamic type is the base, so overrides and derived data are lost. Prevent it by passing and storing polymorphic objects through references or smart pointers, making polymorphic bases non-copyable (Base(const Base&) = delete; or protected copy operations), and using a virtual clone() when a polymorphic copy is genuinely needed (Module 12).

Quick Quiz

Q1. Which calls are dynamically bound in C++?

Answer: Calls to virtual functions through a pointer or reference.

Q2. What does dynamic_cast to a pointer return when the cast fails?

Answer: nullptr (for references it throws std::bad_cast).

Q3. In TypeScript, can you check x instanceof SomeInterface?

Answer: No — interfaces are erased; check for the members you need or use a class.

Summary

Compile-time polymorphism: overloads and templates. Run-time: virtual functions via references/pointers.

Vtables/vptrs are the usual implementation; non-virtual calls are statically bound.

Avoid slicing; downcast rarely and safely.

Related: Module 15 (overloading), Module 17 (templates), Module 49 (Visitor), Module 55 (object layout).