Polymorphism in C++ Explained: Static vs Dynamic with Real Examples
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.
#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; }
Double: 3.14
String: Hello, Forge!
Int sum: 30
Double sum: 4
Concatenated: Hello World
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.
#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; }
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]
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.
#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; }
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)
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.
#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; }
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
| Feature / Aspect | Compile-Time (Static) Polymorphism | Runtime (Dynamic) Polymorphism |
|---|---|---|
| Resolved when? | During compilation | During program execution |
| Mechanism | Function overloading, templates | Virtual functions, vtable |
| Performance | Zero runtime overhead — inlined by compiler | Small vtable lookup cost per virtual call |
| Flexibility | Types must be known at compile time | Works with types unknown until runtime |
| Use case | Algorithms, containers, math operations | Plugin systems, game entities, UI widgets |
| Error detection | Compile-time type errors caught early | Type errors may surface at runtime |
| Code bloat risk | Templates can cause binary bloat if overused | Minimal — one vtable per class |
| Keyword used | template, overloading (no keyword) | virtual, override, = 0 |
| Can use abstract base? | No — templates work with any conforming type | Yes — 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
virtualkeyword in the base class, a matchingoverridein the derived class, and avirtualdestructor 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
virtualin any class that has virtual functions, even if it's justvirtual ~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&orShape*), never by value - ✕Mistake 3: Omitting the
overridekeyword 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 useoverrideon 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.
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.