Object-Oriented Programming

Polymorphism

Master C++ polymorphism — compile-time (overloading, templates) vs runtime (virtual functions, vtable), pure virtual interfaces, dynamic_cast, and the visitor pattern.

Polymorphism

Polymorphism means "many forms" — one interface, multiple implementations. C++ supports two kinds: compile-time polymorphism (function overloading and templates, resolved by the compiler) and runtime polymorphism (virtual functions, resolved at execution time through a vtable).


Compile-Time Polymorphism

cpp
#include <iostream>
#include <cmath>
using namespace std;

// Function overloading — same name, different parameter types
double area(double r) {
    return 3.14159265 * r * r;         // Circle
}

double area(double w, double h) {
    return w * h;                       // Rectangle
}

double area(double a, double b, double c) {
    // Heron's formula for triangle
    double s = (a + b + c) / 2;
    return sqrt(s * (s-a) * (s-b) * (s-c));
}

// Template polymorphism
template <typename T>
T maxOf(T a, T b) {
    return (a > b) ? a : b;
}

int main() {
    cout << area(5.0)           << "\n";   // 78.5398 (circle)
    cout << area(4.0, 6.0)      << "\n";   // 24 (rectangle)
    cout << area(3.0, 4.0, 5.0) << "\n";   // 6 (triangle)

    cout << maxOf(10, 20)       << "\n";   // 20 (int)
    cout << maxOf(3.14, 2.71)   << "\n";   // 3.14 (double)
    cout << maxOf('a', 'z')     << "\n";   // z (char)

    return 0;
}

Runtime Polymorphism — The Vtable

When a class has virtual functions, the compiler creates a vtable (virtual dispatch table) — an array of function pointers. Each object stores a hidden vptr (vtable pointer). When you call a virtual function through a pointer or reference, the CPU looks up the vtable at runtime:

Base* p = new Derived();
p->foo();   // Compiler generates: (*p->vptr[index_of_foo])(p)
cpp
#include <iostream>
using namespace std;

class Animal {
public:
    virtual void speak() const { cout << "...\n"; }
    virtual ~Animal() {}
};

class Dog : public Animal {
public:
    void speak() const override { cout << "Woof!\n"; }
};

class Cat : public Animal {
public:
    void speak() const override { cout << "Meow!\n"; }
};

class Duck : public Animal {
public:
    void speak() const override { cout << "Quack!\n"; }
};

void makeSpeak(const Animal& a) {
    a.speak();   // Runtime dispatch — calls the right derived version
}

int main() {
    Dog dog; Cat cat; Duck duck;

    makeSpeak(dog);    // Woof!
    makeSpeak(cat);    // Meow!
    makeSpeak(duck);   // Quack!

    // Polymorphism via pointer array
    Animal* animals[] = { new Dog, new Cat, new Duck };
    for (Animal* a : animals) {
        a->speak();   // Vtable dispatch picks the right implementation
        delete a;     // Virtual destructor ensures correct cleanup
    }
    // Woof! Meow! Quack!

    return 0;
}

Interface Pattern with Pure Virtual

In C++, an interface is simply an abstract class with only pure virtual functions and a virtual destructor:

cpp
#include <iostream>
#include <string>
#include <vector>
#include <memory>
using namespace std;

// Interface — no data, no implementation
class Serialisable {
public:
    virtual string serialise()                     = 0;
    virtual void   deserialise(const string& data) = 0;
    virtual ~Serialisable() {}
};

class Printable {
public:
    virtual void print() const = 0;
    virtual ~Printable() {}
};

// Class that implements multiple interfaces
class Config : public Serialisable, public Printable {
    string host;
    int    port;

public:
    Config(const string& h, int p) : host(h), port(p) {}

    string serialise() override {
        return host + ":" + to_string(port);
    }

    void deserialise(const string& data) override {
        size_t colon = data.find(':');
        host = data.substr(0, colon);
        port = stoi(data.substr(colon + 1));
    }

    void print() const override {
        cout << "Config: " << host << ":" << port << "\n";
    }
};

int main() {
    Config cfg("api.example.com", 443);
    cfg.print();                              // Config: api.example.com:443

    string serialised = cfg.serialise();
    cout << "Serialised: " << serialised << "\n";  // api.example.com:443

    Config cfg2("", 0);
    cfg2.deserialise("localhost:8080");
    cfg2.print();                             // Config: localhost:8080

    return 0;
}

dynamic_cast — Safe Downcasting

dynamic_cast checks at runtime whether a base pointer actually points to a specific derived type:

cpp
#include <iostream>
#include <vector>
#include <memory>
using namespace std;

class Vehicle {
public:
    virtual string type() const = 0;
    virtual ~Vehicle() {}
};

class Car  : public Vehicle {
public:
    string type()  const override { return "Car"; }
    void   honk()  const { cout << "Beep!\n"; }
};

class Truck : public Vehicle {
public:
    string type()    const override { return "Truck"; }
    void   loadCargo() const { cout << "Loading cargo...\n"; }
};

int main() {
    vector<unique_ptr<Vehicle>> fleet;
    fleet.push_back(make_unique<Car>());
    fleet.push_back(make_unique<Truck>());
    fleet.push_back(make_unique<Car>());

    for (auto& v : fleet) {
        cout << v->type() << ": ";

        // dynamic_cast returns nullptr if the cast fails
        if (Car* car = dynamic_cast<Car*>(v.get())) {
            car->honk();                // Beep!
        } else if (Truck* truck = dynamic_cast<Truck*>(v.get())) {
            truck->loadCargo();         // Loading cargo...
        }
    }
    // Car: Beep!
    // Truck: Loading cargo...
    // Car: Beep!

    return 0;
}

Note: dynamic_cast requires the base class to have at least one virtual function (RTTI must be enabled). Prefer designing with virtual functions over dynamic_cast — frequent downcasting is a sign of poor design.


The Visitor Pattern

The Visitor pattern achieves double dispatch — calling the right method based on both the visitor type and the element type:

cpp
#include <iostream>
#include <vector>
#include <memory>
using namespace std;

class Circle; class Rectangle; class Triangle;  // Forward declarations

class ShapeVisitor {
public:
    virtual void visit(Circle&    c) = 0;
    virtual void visit(Rectangle& r) = 0;
    virtual void visit(Triangle&  t) = 0;
    virtual ~ShapeVisitor() {}
};

class Shape {
public:
    virtual void accept(ShapeVisitor& v) = 0;
    virtual ~Shape() {}
};

class Circle : public Shape {
public:
    double radius;
    Circle(double r) : radius(r) {}
    void accept(ShapeVisitor& v) override { v.visit(*this); }
};

class Rectangle : public Shape {
public:
    double width, height;
    Rectangle(double w, double h) : width(w), height(h) {}
    void accept(ShapeVisitor& v) override { v.visit(*this); }
};

class Triangle : public Shape {
public:
    double base, height;
    Triangle(double b, double h) : base(b), height(h) {}
    void accept(ShapeVisitor& v) override { v.visit(*this); }
};

class AreaCalculator : public ShapeVisitor {
public:
    double total = 0;
    void visit(Circle& c)    override { total += 3.14159 * c.radius * c.radius; }
    void visit(Rectangle& r) override { total += r.width * r.height; }
    void visit(Triangle& t)  override { total += 0.5 * t.base * t.height; }
};

int main() {
    vector<unique_ptr<Shape>> shapes;
    shapes.push_back(make_unique<Circle>(5.0));
    shapes.push_back(make_unique<Rectangle>(4.0, 6.0));
    shapes.push_back(make_unique<Triangle>(3.0, 8.0));

    AreaCalculator calc;
    for (auto& s : shapes) s->accept(calc);
    cout << "Total area: " << calc.total << "\n";  // 78.5398 + 24 + 12 = 114.54

    return 0;
}

Practical Example: Expression Evaluator

cpp
#include <iostream>
#include <memory>
using namespace std;

// Abstract Expression node
class Expr {
public:
    virtual double eval() const = 0;
    virtual string str()  const = 0;
    virtual ~Expr() {}
};

class Number : public Expr {
    double val;
public:
    Number(double v) : val(v) {}
    double eval() const override { return val; }
    string str()  const override { return to_string(val); }
};

class BinaryOp : public Expr {
    char   op;
    unique_ptr<Expr> left, right;
public:
    BinaryOp(char op, unique_ptr<Expr> l, unique_ptr<Expr> r)
        : op(op), left(move(l)), right(move(r)) {}

    double eval() const override {
        switch (op) {
            case '+': return left->eval() + right->eval();
            case '-': return left->eval() - right->eval();
            case '*': return left->eval() * right->eval();
            case '/': return left->eval() / right->eval();
        }
        return 0;
    }

    string str() const override {
        return "(" + left->str() + " " + op + " " + right->str() + ")";
    }
};

int main() {
    // Build AST for: (3 + 4) * (10 - 2)
    auto expr = make_unique<BinaryOp>('*',
        make_unique<BinaryOp>('+', make_unique<Number>(3), make_unique<Number>(4)),
        make_unique<BinaryOp>('-', make_unique<Number>(10), make_unique<Number>(2))
    );

    cout << expr->str()  << "\n";   // ((3 + 4) * (10 - 2))
    cout << expr->eval() << "\n";   // 56

    return 0;
}

Summary

  • Compile-time polymorphism — function overloading and templates — is resolved by the compiler; zero runtime cost
  • Runtime polymorphism — virtual functions — uses a vtable to dispatch calls at runtime; small per-call overhead
  • Mark overriding functions with override — the compiler catches mistakes where you thought you were overriding but weren't
  • Always add a virtual destructor to base classes — without it, delete base_ptr only calls the base destructor
  • dynamic_cast safely downcasts — returns nullptr (pointer) or throws bad_cast (reference) on failure
  • Abstract classes (with pure virtual functions) define interfaces — they cannot be instantiated
  • Design towards virtual interfaces rather than dynamic_cast chains — the latter indicates missing virtual methods

Next, we'll cover operator overloading — giving custom types the ability to use C++ operators naturally.