The Problem Polymorphism Solves
Imagine a checkout that has to accept UPI, cards and net banking. Without polymorphism, the code that processes a payment ends up as a chain of if statements testing a type code, and every new payment method means editing that chain — and the refund function, and the receipt function, and the reporting function. Miss one and you have a bug that only appears for one payment method.
Polymorphism inverts that. Define what every payment method must be able to do — pay, refund, label — and let each method supply its own version. The checkout then works with "a payment method" and calls pay, and the correct version runs. Adding a new method means writing one new class and changing nothing else. This is the single biggest practical benefit of object-oriented design, and it is worth judging any use of inheritance by whether it delivers this.
C++ actually has two kinds of polymorphism. Compile-time polymorphism is function overloading and templates: the compiler picks the right version while building, so there is no run-time cost at all. Run-time polymorphism is what this lesson is about: the decision is made while the program runs, based on the actual type of the object in hand, and it is implemented with virtual functions.
The mechanism has one absolute requirement, which the previous lesson introduced: it works only through references and pointers. A function taking const PaymentMethod& or a container of std::unique_ptr<PaymentMethod> gets correct dispatch. A function taking PaymentMethod by value slices the object and always calls the base version.
So the shape of a polymorphic design is always the same: an abstract base declaring the operations, several concrete classes implementing them, and all the general-purpose code written against the base.
#include <iostream>
#include <memory>
#include <string>
#include <vector>
class PaymentMethod {
public:
virtual ~PaymentMethod() = default; // always, on a polymorphic base
virtual bool pay(double amount) = 0; // every method must define this
virtual std::string label() const = 0;
};
class Upi : public PaymentMethod {
std::string id_;
public:
explicit Upi(std::string id) : id_(std::move(id)) {}
bool pay(double amount) override {
std::cout << "Collecting " << amount << " via UPI from " << id_ << '\n';
return true;
}
std::string label() const override { return "UPI"; }
};
class Card : public PaymentMethod {
std::string last4_;
public:
explicit Card(std::string last4) : last4_(std::move(last4)) {}
bool pay(double amount) override {
std::cout << "Charging " << amount << " to card ****" << last4_ << '\n';
return true;
}
std::string label() const override { return "Card"; }
};
// Written once. Never edited when a new method is added.
void checkout(PaymentMethod& method, double amount) {
std::cout << "[" << method.label() << "] ";
method.pay(amount);
}
int main() {
std::vector<std::unique_ptr<PaymentMethod>> methods;
methods.push_back(std::make_unique<Upi>("ananya@bank"));
methods.push_back(std::make_unique<Card>("4211"));
for (auto& m : methods) checkout(*m, 1499.0);
} - Notice that
checkoutnever mentionsUpiorCard. That is the test of a good polymorphic design: if adding a payment method requires editing existing functions, the abstraction is not doing its job.
How Virtual Dispatch Works, and What It Costs
It is worth knowing roughly what the compiler does, because it explains both the cost and several of the rules.
For a normal function call, the compiler knows at build time exactly which code to run and emits a direct jump. For a virtual call it cannot know — the pointer might refer to a Upi or a Card, and that is decided while the program runs. So every compiler in practice gives each polymorphic class a hidden table of function pointers, one entry per virtual function, and gives every object a hidden pointer to its class's table. A virtual call becomes: follow the object's hidden pointer to the table, read the right entry, jump there.
That is one extra memory read compared with a normal call. On its own it is cheap. The cost that actually matters is a second-order one: because the target is not known at compile time, the compiler cannot inline a virtual call, and inlining is where a lot of C++'s speed comes from. A tiny virtual getter called inside a tight loop is meaningfully slower than a tiny non-virtual one.
There is also a space cost. Every object of a polymorphic class carries that hidden pointer, typically eight bytes. For a class with a few strings and vectors this is noise; for a small class of which you plan to store millions, it is not.
This is why C++ makes functions non-virtual by default, unlike Java where every method is virtual unless marked otherwise. C++'s position is that you should pay for dispatch only where you use it. The practical guidance follows: mark a function virtual when you genuinely intend derived classes to change its behaviour, and leave it alone otherwise. And remember that virtual is inherited — once a function is virtual in the base, it is virtual in every derived class whether or not you repeat the keyword. Write override instead; it documents the intent and gets checked.
#include <iostream>
struct Base {
virtual ~Base() = default;
virtual int virtualValue() const { return 1; } // dispatched at run time
int plainValue() const { return 1; } // resolved at compile time
};
struct Derived : Base {
int virtualValue() const override { return 2; }
int plainValue() const { return 2; } // HIDES, does not override
};
int main() {
Derived d;
Base& b = d;
std::cout << b.virtualValue() << '\n'; // 2 — the real type decides
std::cout << b.plainValue() << '\n'; // 1 — the declared type decides
std::cout << sizeof(Base) << '\n'; // 8 on a typical 64-bit build:
// the hidden table pointer
} - That last line is the whole difference in one example.
virtualValueasks the object what it is;plainValueasks the variable's declared type. If a polymorphic call is giving you the base's answer, the first thing to check is whether the function is actuallyvirtualin the base.
Abstract Classes and Interfaces
Writing = 0 after a virtual function makes it pure virtual: the base declares that the function exists but refuses to provide an implementation. A class with at least one pure virtual function is abstract and cannot be instantiated. PaymentMethod p; does not compile, which is correct — there is no such thing as a payment method that is not some particular payment method.
This is more useful than it first sounds, because it turns a design intention into a compiler-enforced rule. Any class inheriting from PaymentMethod must implement pay and label or it too is abstract and cannot be instantiated. You cannot forget one. Compare that with a base class that provides an empty default implementation: a derived class that forgets to override it compiles fine and silently does nothing at run time.
When a class consists only of pure virtual functions and has no data, it is usually called an interface. Interfaces are the safest thing to inherit from, they combine well through multiple inheritance, and they express a capability rather than a category — Printable, Serialisable, Comparable. If you are going to use inheritance at all, this is the form to prefer.
An abstract class may still provide ordinary implemented functions and data. That is how you share common behaviour: put the parts that are the same for every derived class in the base as normal functions, and leave only the genuinely varying parts pure virtual. A PaymentMethod base could implement logAttempt once for everybody while leaving pay abstract.
One detail that surprises people: a pure virtual function is allowed to have a body. It is unusual, but it lets derived classes call Base::function() for shared setup while still being required to write their own override. You will meet this occasionally in library code.
#include <iostream>
#include <memory>
#include <string>
#include <vector>
// An interface: pure virtual functions, no data
class Exportable {
public:
virtual ~Exportable() = default;
virtual std::string toCsv() const = 0;
};
// An abstract class that also shares real behaviour
class Document : public Exportable {
std::string title_;
public:
explicit Document(std::string t) : title_(std::move(t)) {}
const std::string& title() const { return title_; } // shared, not virtual
virtual int pageCount() const = 0; // must be supplied
};
class Invoice : public Document {
double amount_;
public:
Invoice(std::string t, double a) : Document(std::move(t)), amount_(a) {}
int pageCount() const override { return 1; }
std::string toCsv() const override {
return title() + "," + std::to_string(amount_);
}
};
class Report : public Document {
int pages_;
public:
Report(std::string t, int p) : Document(std::move(t)), pages_(p) {}
int pageCount() const override { return pages_; }
std::string toCsv() const override {
return title() + "," + std::to_string(pages_) + " pages";
}
};
int main() {
// Document d("x"); // error: abstract, cannot be instantiated
std::vector<std::unique_ptr<Document>> docs;
docs.push_back(std::make_unique<Invoice>("INV-001", 1499.0));
docs.push_back(std::make_unique<Report>("Term 1", 12));
for (const auto& d : docs) {
std::cout << d->toCsv() << " (" << d->pageCount() << " pages)\n";
}
} - Keep interfaces small. An interface with fifteen pure virtual functions forces every implementer to write fifteen functions, most of which they do not care about, and they end up writing empty stubs — which defeats the point. Several small interfaces beat one large one.
The Five Ways Polymorphism Goes Wrong
Polymorphism has a small, well-known set of failure modes. All of them compile cleanly, which is why they are worth memorising as a checklist.
The most important is the missing virtual destructor. Delete a derived object through a base pointer without one and the derived part is never destroyed. Add virtual ~Base() = default; to every class with a virtual function and the problem cannot occur.
Second is object slicing, covered in the previous lesson: any by-value copy of a derived object into a base variable, parameter or container throws away the derived part and disables dispatch. Take parameters by const Base& and store std::unique_ptr<Base>.
Third is a signature mismatch that silently creates a new function instead of overriding — usually a forgotten const. override turns it into a compile error, which is why it should be on every override you write.
Fourth is calling a virtual function from a constructor or destructor, where dispatch deliberately resolves to the class currently being built or torn down rather than the eventual derived type. If you need derived behaviour during setup, do it in a separate initialise step after construction.
Fifth is reaching for dynamic_cast. It safely converts a base pointer to a derived pointer, returning nullptr if the object is not actually of that type — and it works only on polymorphic types, meaning ones with at least one virtual function. It is legitimate occasionally. But needing to ask "what type is this really" usually means a behaviour that should have been a virtual function on the base has been written as a type test outside it. Try adding the virtual function first.
- Base destructor not
virtual— the derived part leaks ondelete - Object sliced by a by-value copy — dispatch silently reverts to the base
- Signature mismatch, usually a missing
const— writeoverrideand let the compiler check - Virtual call inside a constructor or destructor — resolves to the current class, not the derived one
- Frequent
dynamic_cast— a sign that a type test is standing in for a missing virtual function
#include <iostream>
#include <memory>
struct Shape {
virtual ~Shape() = default;
virtual double area() const = 0;
};
struct Circle : Shape {
double r = 2.0;
double area() const override { return 3.14159 * r * r; }
double diameter() const { return 2 * r; } // Circle-specific
};
struct Square : Shape {
double side = 3.0;
double area() const override { return side * side; }
};
int main() {
std::unique_ptr<Shape> s = std::make_unique<Circle>();
// dynamic_cast: safe, checked, returns nullptr on a mismatch
if (auto* c = dynamic_cast<Circle*>(s.get())) {
std::cout << "diameter " << c->diameter() << '\n';
}
std::unique_ptr<Shape> t = std::make_unique<Square>();
if (dynamic_cast<Circle*>(t.get()) == nullptr) {
std::cout << "not a circle\n";
}
std::cout << s->area() << ' ' << t->area() << '\n'; // 12.5664 9
} 