Home C / C++ Polymorphism in C++ Explained: Static vs Dynamic with Real Examples

Polymorphism in C++ Explained: Static vs Dynamic with Real Examples

In Plain English 🔥
Imagine a universal TV remote. One button says 'Play' — but press it on a DVD player and it spins a disc, press it on a streaming box and it starts a video, press it on a music player and it plays a song. Same button, totally different behavior depending on what device you're holding. That's polymorphism. One interface, many forms of behavior underneath. You don't need a different remote for every device — the remote just figures it out.
⚡ Quick Answer
Imagine a universal TV remote. One button says 'Play' — but press it on a DVD player and it spins a disc, press it on a streaming box and it starts a video, press it on a music player and it plays a song. Same button, totally different behavior depending on what device you're holding. That's polymorphism. One interface, many forms of behavior underneath. You don't need a different remote for every device — the remote just figures it out.

Every serious C++ codebase leans on polymorphism. It's the reason you can write a game engine that handles dozens of enemy types through a single pointer, or a graphics renderer that draws circles, rectangles, and polygons with one unified call. Without it, you'd be writing a separate function for every type you ever invent — and every time you add a new type, you'd be back editing old code. That's a maintenance nightmare waiting to happen.

Polymorphism solves the rigidity problem. It lets you write code that works with types that don't even exist yet. You define a contract — an interface — and any class that honours that contract gets to play. The caller doesn't need to know the specifics. This is what separates beginner C++ from production-grade C++. It's the difference between code that's bolted together and code that's designed to grow.

By the end of this article you'll understand the two flavours of polymorphism — compile-time and runtime — and exactly when to reach for each one. You'll see how virtual functions and vtables actually work under the hood, avoid the destructor trap that silently corrupts memory, and walk away with the mental model that makes C++ object-oriented design finally make sense.

Compile-Time Polymorphism: Function Overloading and Templates

Compile-time polymorphism — also called static polymorphism — is resolved before your program ever runs. The compiler looks at the call site, figures out which version of a function to invoke, and hard-wires that decision into the binary. There's zero runtime cost. Two mechanisms drive it: function overloading and templates.

Function overloading lets you define multiple functions with the same name but different parameter signatures. The compiler picks the right one based on the arguments you pass. Think of a print function that handles integers, floats, and strings — same name, compiler figures out which one fits.

Templates go further. They let you write one function or class that works for any type, and the compiler stamps out a concrete version per type at compile time. This is how std::vector and std::vector both exist without you writing two separate vector implementations.

Use compile-time polymorphism when you know all your types upfront and want maximum performance. Templates are especially powerful for data structures and algorithms where the logic is identical regardless of type.

CompileTimePolymorphism.cpp · CPP
1234567891011121314151617181920212223242526272829303132333435363738394041424344
#include <iostream>
#include <string>

// --- Function Overloading ---
// Same function name, different parameter types.
// The compiler resolves which one to call at compile time.

void displayValue(int number) {
    std::cout << "Integer: " << number << "\n";
}

void displayValue(double decimal) {
    std::cout << "Double: " << decimal << "\n";
}

void displayValue(const std::string& text) {
    std::cout << "String: " << text << "\n";
}

// --- Function Template ---
// One definition, works for any type T that supports operator+.
// The compiler generates a concrete version for each type you use.

template <typename T>
T addTwo(T first, T second) {
    return first + second;  // Works for int, double, std::string — anything with operator+
}

int main() {
    // Overloaded calls — compiler picks the right version based on argument type
    displayValue(42);           // Calls displayValue(int)
    displayValue(3.14);         // Calls displayValue(double)
    displayValue(std::string("Hello, Forge!"));  // Calls displayValue(string)

    std::cout << "\n";

    // Template calls — compiler stamps out addTwo<int> and addTwo<double> separately
    std::cout << "Int sum:    " << addTwo(10, 20)        << "\n";
    std::cout << "Double sum: " << addTwo(1.5, 2.5)      << "\n";
    std::cout << "Concatenated: " << addTwo(std::string("Hello "), std::string("World")) << "\n";

    return 0;
}
▶ Output
Integer: 42
Double: 3.14
String: Hello, Forge!

Int sum: 30
Double sum: 4
Concatenated: Hello World
⚠️
Pro Tip:Prefer templates over overloading when the logic is identical across types. If you find yourself copy-pasting the same function body just to change `int` to `double`, a template is the cleaner solution — and it automatically handles any future type you throw at it.

Runtime Polymorphism: Virtual Functions and the vtable

Runtime polymorphism is where C++ gets genuinely powerful — and genuinely dangerous if you don't understand the machinery. The core mechanism is the virtual keyword, and it works through something called a vtable (virtual dispatch table).

When you mark a function virtual in a base class, the compiler attaches a hidden pointer — the vptr — to every object of that class. That pointer points to a vtable: a lookup table of function pointers specific to the object's actual type. When you call a virtual function through a base-class pointer, the runtime consults that vtable and calls the right version. No if/else chains, no type-checking — it just works.

This is what lets you write Shape s = new Circle() and have s->draw() call Circle::draw() rather than Shape::draw(). The pointer type is Shape, but the object behind it knows it's a Circle.

The critical rule: if a class has any virtual functions, its destructor must also be virtual. Skip this and you'll get partial destruction — the derived class's destructor never fires, leaking resources. We'll revisit this in the gotchas section.

Use runtime polymorphism when you're dealing with a collection of related objects that share behaviour but differ in specifics — enemies in a game, UI widgets, file format parsers.

RuntimePolymorphism.cpp · CPP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
#include <iostream>
#include <vector>
#include <memory>   // for std::unique_ptr — no raw new/delete in modern C++
#include <cmath>    // for M_PI

// Base class defines the CONTRACT.
// Any shape must be able to report its area and describe itself.
class Shape {
public:
    // 'virtual' tells the compiler: resolve this call at runtime, not compile time.
    virtual double area() const = 0;          // Pure virtual — Shape is now abstract
    virtual void   describe() const = 0;      // Subclasses MUST override this

    // CRITICAL: virtual destructor ensures derived destructors are called correctly
    // when deleting through a base-class pointer.
    virtual ~Shape() {
        std::cout << "[Shape destructor called]\n";
    }
};

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

    // 'override' keyword — lets the compiler catch typos in the function signature
    double area() const override {
        return M_PI * radius_ * radius_;
    }

    void describe() const override {
        std::cout << "Circle with radius " << radius_
                  << " | Area: " << area() << "\n";
    }

    ~Circle() override {
        std::cout << "[Circle destructor called]\n";
    }

private:
    double radius_;
};

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

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

    void describe() const override {
        std::cout << "Rectangle " << width_ << "x" << height_
                  << " | Area: " << area() << "\n";
    }

    ~Rectangle() override {
        std::cout << "[Rectangle destructor called]\n";
    }

private:
    double width_;
    double height_;
};

class Triangle : public Shape {
public:
    Triangle(double base, double height) : base_(base), height_(height) {}

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

    void describe() const override {
        std::cout << "Triangle base=" << base_ << " height=" << height_
                  << " | Area: " << area() << "\n";
    }

    ~Triangle() override {
        std::cout << "[Triangle destructor called]\n";
    }

private:
    double base_;
    double height_;
};

// This function works for ANY shape — present or future.
// It doesn't know or care whether it's a Circle, Rectangle, or something you haven't written yet.
void printShapeInfo(const Shape& shape) {
    shape.describe();  // vtable dispatch happens here — correct method always called
}

int main() {
    // Store different shape types behind a common base-class pointer.
    // unique_ptr handles memory automatically — no manual delete.
    std::vector<std::unique_ptr<Shape>> canvas;

    canvas.push_back(std::make_unique<Circle>(5.0));
    canvas.push_back(std::make_unique<Rectangle>(4.0, 6.0));
    canvas.push_back(std::make_unique<Triangle>(3.0, 8.0));
    canvas.push_back(std::make_unique<Circle>(2.5));

    std::cout << "=== Shape Report ===\n";
    for (const auto& shape : canvas) {
        printShapeInfo(*shape);  // Runtime dispatch — correct describe() for each type
    }

    std::cout << "\nTotal shapes: " << canvas.size() << "\n";
    std::cout << "\n=== Cleanup (destructors) ===\n";
    // unique_ptrs go out of scope here — destructors fire in reverse order

    return 0;
}
▶ Output
=== Shape Report ===
Circle with radius 5 | Area: 78.5398
Rectangle 4x6 | Area: 24
Triangle base=3 height=8 | Area: 12
Circle with radius 2.5 | Area: 19.635

Total shapes: 4

=== Cleanup (destructors) ===
[Circle destructor called]
[Shape destructor called]
[Triangle destructor called]
[Shape destructor called]
[Rectangle destructor called]
[Shape destructor called]
[Circle destructor called]
[Shape destructor called]
🔥
How the vtable actually works:When you mark a function virtual, the compiler builds a per-class table of function pointers (the vtable) and plants a hidden vptr inside every object. A virtual call does two extra memory reads: one to fetch the vptr, one to index into the vtable. That's it — the overhead is tiny, but it does exist. In performance-critical tight loops (game physics, signal processing), this can matter. Everywhere else, it's irrelevant.

Abstract Classes vs Interfaces: Designing a Real Extensible System

An abstract class in C++ is any class with at least one pure virtual function (= 0). You can't instantiate it directly — it exists purely as a contract for derived classes to fulfill. This is C++'s version of an interface, though with more flexibility since abstract classes can also carry shared state and non-virtual implementation.

The real power shows up when you're designing a system that needs to be extended without modifying existing code — this is the Open/Closed Principle in action. You define the abstract base once. New types slot in by subclassing and implementing the contract. Your existing calling code never changes.

Here's a practical example: a payment processing system. You have a PaymentProcessor abstract class. Stripe, PayPal, and a future Bitcoin processor all implement it. Your checkout logic calls processor->charge(amount) — it doesn't know or care which processor is behind the pointer. Adding a new payment method means writing one new class, not touching the checkout code at all.

This is the design pattern that makes large codebases manageable. It's why frameworks ship with abstract base classes everywhere — they're defining extension points for you to fill in.

PaymentProcessorSystem.cpp · CPP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
#include <iostream>
#include <string>
#include <memory>
#include <stdexcept>

// Abstract base class — the CONTRACT every payment processor must honour.
// Cannot be instantiated directly. Pure virtual functions enforce implementation.
class PaymentProcessor {
public:
    // Every processor must implement these three operations
    virtual bool charge(const std::string& customerId, double amountUsd) = 0;
    virtual bool refund(const std::string& transactionId)                = 0;
    virtual std::string processorName() const                            = 0;

    // Concrete shared behaviour — all processors log the same way
    // Non-pure virtual: has a default implementation, but CAN be overridden
    virtual void logTransaction(const std::string& message) const {
        std::cout << "[" << processorName() << " LOG] " << message << "\n";
    }

    virtual ~PaymentProcessor() = default;  // Always virtual in polymorphic base classes
};

// --- Stripe Implementation ---
class StripeProcessor : public PaymentProcessor {
public:
    bool charge(const std::string& customerId, double amountUsd) override {
        // In reality this would call the Stripe REST API
        logTransaction("Charging $" + std::to_string(amountUsd) + " to customer " + customerId);
        std::cout << "  [Stripe] Payment authorised via card network\n";
        return true;  // Simulate success
    }

    bool refund(const std::string& transactionId) override {
        logTransaction("Refunding transaction " + transactionId);
        std::cout << "  [Stripe] Refund queued (3-5 business days)\n";
        return true;
    }

    std::string processorName() const override { return "Stripe"; }
};

// --- PayPal Implementation ---
class PayPalProcessor : public PaymentProcessor {
public:
    bool charge(const std::string& customerId, double amountUsd) override {
        logTransaction("Charging $" + std::to_string(amountUsd) + " via PayPal wallet for " + customerId);
        std::cout << "  [PayPal] Deducted from PayPal balance\n";
        return true;
    }

    bool refund(const std::string& transactionId) override {
        logTransaction("Refunding PayPal transaction " + transactionId);
        std::cout << "  [PayPal] Refund issued instantly to PayPal balance\n";
        return true;
    }

    std::string processorName() const override { return "PayPal"; }

    // Overrides the shared logging to add PayPal-specific formatting
    void logTransaction(const std::string& message) const override {
        std::cout << "[PayPal AUDIT TRAIL] " << message << "\n";
    }
};

// --- Checkout logic — knows NOTHING about Stripe or PayPal ---
// This function works for any current or future PaymentProcessor.
// Adding a new processor (e.g. CryptoProcessor) never touches this function.
void runCheckout(PaymentProcessor& processor, const std::string& customerId, double cartTotal) {
    std::cout << "\n--- Checkout for customer: " << customerId << " ---\n";
    std::cout << "Cart total: $" << cartTotal << "\n";

    bool success = processor.charge(customerId, cartTotal);  // vtable dispatch here

    if (success) {
        std::cout << "Payment successful!\n";
    } else {
        std::cout << "Payment failed. Please try another method.\n";
    }
}

int main() {
    StripeProcessor stripe;
    PayPalProcessor paypal;

    // Same checkout function, completely different processors — polymorphism in action
    runCheckout(stripe,  "cust_001", 149.99);
    runCheckout(paypal,  "cust_002", 39.50);

    // Adding a new processor later? Just create the class, call runCheckout.
    // Zero changes to runCheckout, zero changes to main's structure.

    std::cout << "\n--- Processing a refund ---\n";
    stripe.refund("txn_stripe_8821");

    return 0;
}
▶ Output
--- Checkout for customer: cust_001 ---
Cart total: $149.99
[Stripe LOG] Charging $149.990000 to customer cust_001
[Stripe] Payment authorised via card network
Payment successful!

--- Checkout for customer: cust_002 ---
Cart total: $39.5
[PayPal AUDIT TRAIL] Charging $39.500000 via PayPal wallet for cust_002
[PayPal] Deducted from PayPal balance
Payment successful!

--- Processing a refund ---
[Stripe LOG] Refunding transaction txn_stripe_8821
[Stripe] Refund queued (3-5 business days)
⚠️
Watch Out:If you forget to implement a pure virtual function in a derived class, the derived class becomes abstract too — and the compiler will refuse to instantiate it with a cryptic 'cannot instantiate abstract type' error. Always check you've implemented every `= 0` function. The `override` keyword is your safety net: it causes a compile error if the signature doesn't match any virtual function in the base class.

Gotchas: The Two Mistakes That Actually Bite People

Polymorphism in C++ is powerful but it has sharp edges. Two mistakes in particular show up constantly in code reviews and debugging sessions. Both are silent — no crash on the obvious line, just wrong behaviour or a memory leak that takes hours to track down.

The first is slicing. It happens when you assign a derived object to a base object by value. The derived parts get 'sliced off' — copied away — leaving only the base portion. The virtual dispatch mechanism works through pointers and references, not values. The moment you copy by value, you lose polymorphic behaviour entirely.

The second is the missing virtual destructor. If your base class destructor isn't virtual and you delete a derived object through a base pointer, only the base destructor fires. Any resources owned by the derived class — heap memory, file handles, network sockets — are never released. Valgrind will scream at you. Your users will file memory leak bugs.

Both mistakes compile without warnings. Both cause real problems in production. Knowing them ahead of time is half the battle.

PolymorphismGotchas.cpp · CPP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
#include <iostream>
#include <memory>

// =============================================
// GOTCHA 1: Object Slicing
// =============================================

class Animal {
public:
    virtual std::string sound() const { return "..."; }
    virtual ~Animal() = default;
};

class Dog : public Animal {
public:
    std::string sound() const override { return "Woof!"; }
};

void demonstrateSlicing() {
    Dog myDog;

    // CORRECT: pass by reference — virtual dispatch works, sound() returns "Woof!"
    Animal& animalRef = myDog;
    std::cout << "By reference: " << animalRef.sound() << "\n";  // "Woof!"

    // WRONG: copy by value — Dog is sliced down to Animal, losing override
    Animal slicedAnimal = myDog;  // Only the Animal portion is copied!
    std::cout << "By value (sliced): " << slicedAnimal.sound() << "\n";  // "..." — Bug!
}

// =============================================
// GOTCHA 2: Missing Virtual Destructor
// =============================================

class ResourceBase_WRONG {
public:
    // No virtual destructor — DANGEROUS if used polymorphically
    ~ResourceBase_WRONG() {
        std::cout << "ResourceBase_WRONG destructor\n";
    }
    virtual void use() { std::cout << "Base use\n"; }
};

class ResourceDerived_WRONG : public ResourceBase_WRONG {
public:
    ResourceDerived_WRONG() {
        heapBuffer_ = new int[1000];  // Allocate some resource
        std::cout << "ResourceDerived_WRONG: buffer allocated\n";
    }
    ~ResourceDerived_WRONG() {
        delete[] heapBuffer_;  // THIS NEVER FIRES if deleted via base pointer without virtual dtor
        std::cout << "ResourceDerived_WRONG destructor — buffer freed\n";
    }
private:
    int* heapBuffer_;
};

class ResourceBase_CORRECT {
public:
    virtual ~ResourceBase_CORRECT() {  // virtual destructor — ALWAYS do this in polymorphic bases
        std::cout << "ResourceBase_CORRECT destructor\n";
    }
    virtual void use() { std::cout << "Base use\n"; }
};

class ResourceDerived_CORRECT : public ResourceBase_CORRECT {
public:
    ResourceDerived_CORRECT() {
        heapBuffer_ = new int[1000];
        std::cout << "ResourceDerived_CORRECT: buffer allocated\n";
    }
    ~ResourceDerived_CORRECT() override {
        delete[] heapBuffer_;  // This DOES fire — derived destructor is called correctly
        std::cout << "ResourceDerived_CORRECT destructor — buffer freed\n";
    }
private:
    int* heapBuffer_;
};

void demonstrateDestructorBug() {
    std::cout << "\n--- BUG: Missing virtual destructor ---\n";
    ResourceBase_WRONG* bad = new ResourceDerived_WRONG();
    delete bad;  // Only ~ResourceBase_WRONG fires! Buffer leaks silently.

    std::cout << "\n--- FIX: Virtual destructor ---\n";
    ResourceBase_CORRECT* good = new ResourceDerived_CORRECT();
    delete good;  // Both destructors fire in the right order: derived first, then base.
}

int main() {
    std::cout << "=== GOTCHA 1: Slicing ===\n";
    demonstrateSlicing();

    std::cout << "\n=== GOTCHA 2: Destructor ===\n";
    demonstrateDestructorBug();

    return 0;
}
▶ Output
=== GOTCHA 1: Slicing ===
By reference: Woof!
By value (sliced): ...

=== GOTCHA 2: Destructor ===
--- BUG: Missing virtual destructor ---
ResourceDerived_WRONG: buffer allocated
ResourceBase_WRONG destructor

--- FIX: Virtual destructor ---
ResourceDerived_CORRECT: buffer allocated
ResourceDerived_CORRECT destructor — buffer freed
ResourceBase_CORRECT destructor
⚠️
The Golden Rule:If a class has any virtual functions, give it a virtual destructor — even if it's empty. Make it a reflex. The Core C++ Guidelines say it plainly: 'A base class destructor should be either public and virtual, or protected and non-virtual.' There's no middle ground when you're deleting through base-class pointers.
Feature / AspectCompile-Time (Static) PolymorphismRuntime (Dynamic) Polymorphism
Resolved when?During compilationDuring program execution
MechanismFunction overloading, templatesVirtual functions, vtable
PerformanceZero runtime overhead — inlined by compilerSmall vtable lookup cost per virtual call
FlexibilityTypes must be known at compile timeWorks with types unknown until runtime
Use caseAlgorithms, containers, math operationsPlugin systems, game entities, UI widgets
Error detectionCompile-time type errors caught earlyType errors may surface at runtime
Code bloat riskTemplates can cause binary bloat if overusedMinimal — one vtable per class
Keyword usedtemplate, overloading (no keyword)virtual, override, = 0
Can use abstract base?No — templates work with any conforming typeYes — pure virtual enforces contracts

🎯 Key Takeaways

  • Compile-time polymorphism (overloading + templates) is resolved by the compiler with zero runtime cost — use it when all types are known upfront and performance matters
  • Runtime polymorphism needs three things to work correctly: the virtual keyword in the base class, a matching override in the derived class, and a virtual destructor in the base — miss any one and you get subtle bugs
  • Object slicing is a silent killer — polymorphic behaviour only works through pointers and references, never through value copies; make this a reflex whenever you're working with inheritance hierarchies
  • Abstract classes with pure virtual functions (= 0) are C++'s way of defining interfaces — they enforce contracts on derived classes and let you write calling code that works for types that don't exist yet

⚠ Common Mistakes to Avoid

  • Mistake 1: Forgetting the virtual destructor in a polymorphic base class — Symptom: derived class destructor never fires when deleting through a base pointer; resources leak silently with no crash or compiler warning — Fix: always declare the destructor virtual in any class that has virtual functions, even if it's just virtual ~Base() = default;
  • Mistake 2: Passing derived objects by value to functions expecting a base type (object slicing) — Symptom: virtual function calls return base-class behaviour even though you passed a derived object; the override is completely ignored — Fix: always pass polymorphic objects by pointer or reference (const Shape& or Shape*), never by value
  • Mistake 3: Omitting the override keyword on derived class method signatures — Symptom: a typo or mismatched const-qualifier silently creates a brand-new function instead of overriding the base; the vtable dispatch calls the base version and the bug is nearly invisible — Fix: always use override on every function intended to override a virtual; the compiler then flags any signature mismatch as an error

Interview Questions on This Topic

  • QWhat is the difference between compile-time and runtime polymorphism in C++? Can you give a real-world example of when you'd choose one over the other?
  • QWhat is a vtable and a vptr? How does C++ use them to implement virtual dispatch, and what is the memory cost of having a virtual function in a class?
  • QWhat happens if you delete a derived class object through a base class pointer when the base class destructor is not virtual? How does making it virtual fix the problem, and why does the standard recommend `protected non-virtual` as an alternative?

Frequently Asked Questions

What is polymorphism in C++ and why do we need it?

Polymorphism lets you write code that works with many different types through a single interface. You need it because it lets you add new types to a system without modifying existing code — a Circle, Rectangle, and any future shape you invent can all be handled by the same draw() call. Without it, every new type forces you to edit old code, which breaks existing tests and introduces bugs.

When should I use virtual functions vs function templates in C++?

Use virtual functions (runtime polymorphism) when you're dealing with objects whose exact types aren't known until runtime — think loading plugins, processing user input, or iterating a mixed container of derived types. Use templates (compile-time polymorphism) when you know all your types at compile time and want zero runtime overhead — data structures, mathematical algorithms, and generic utilities are classic template territory.

What does 'pure virtual' mean, and what is an abstract class?

A pure virtual function is declared with = 0 — it has no implementation in the base class and forces every derived class to provide one. Any class that contains at least one pure virtual function becomes an abstract class, which means you cannot create instances of it directly. Abstract classes serve as contracts or interfaces, ensuring all derived classes implement a consistent set of behaviours.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousInheritance in C++Next →Operator Overloading in C++
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged