Polymorphism means "many forms." In C++, it lets one piece of calling code work with many different types of objects, where each type decides for itself how to respond. The payoff is fewer if/else chains, fewer type tags, and code that handles new cases without rewriting the call site. This chapter sets up the vocabulary and the motivating problem; the next two chapters dig into the two flavors of polymorphism C++ provides.
The word comes from Greek roots that translate to "many shapes." In code, it shows up wherever a single name (a function call, an operator, a method) does different work depending on what it's acting on.
A concrete picture before any theory. An E-Commerce checkout has to charge the customer. The charge function should not care whether the customer is paying with a credit card, a digital wallet, or UPI. It says "charge this order" and the right thing happens.
The code works. It also has a problem that grows every time the business adds a payment method. Each new option means another branch in checkout. Add gift cards, store credit, BNPL, crypto, and the if/else ladder becomes a wall. Every team that calls checkout is also locked into knowing every payment method by name, which leaks the decision into every caller.
Polymorphism replaces that ladder with a single line: payment.charge(amount). The variable payment could be a credit card, a wallet, or anything else, and the right charge runs without checkout ever asking what type it is.
The caller stays simple. The decision lives with the types, not with the caller. That is the entire point of polymorphism.
if/else on a Type Tag Is the Wrong ToolThe payment example is one shape of the same anti-pattern. Three more E-Commerce examples where polymorphism is the better fit.
Notifications. An order moves through several states (placed, shipped, delivered) and each state sends a different notification: an SMS, an email, a push, or a printed shipping label. A naive design pushes a type tag through a single function:
It runs. It also has three problems that compound over time.
| Problem | What it looks like |
|---|---|
| Every new notification kind edits one shared function | Adding WhatsApp means touching sendNotification, the enum, and any other dispatcher. |
The switch doesn't know when it's incomplete | Drop a new enum value and forget to add a case, and you get silent fallthrough or a compile warning at best. |
| Each branch can't carry its own state | An email needs an SMTP server, an SMS needs a phone number. The function signature has to accept all of them or fall back to globals. |
Discounts per product type. A Product base with DigitalProduct, PhysicalProduct, and SubscriptionProduct derivatives each apply a discount differently. A digital product can discount aggressively because delivery is nearly free. A physical product has to cover shipping, so it discounts less. A subscription product can offer a big first-cycle promo. Same idea, same shape:
Printing receipts. An order summary has to print itself in different formats: a console line, an HTML email body, or a JSON blob for the mobile app. Same data, three formats, one function full of branches.
Three different problems, one shared structure. In each case:
charge, notify, discount, print) that varies per type.The if/else ladder works for two cases. It bends for three. It breaks at five. And it forces every caller to know the full enum, which means adding a new case requires touching every dispatcher in the codebase.
Branching on a type tag isn't slow by itself; the real cost is maintenance. A polymorphic call dispatches in a constant number of cycles regardless of how many subtypes exist. A switch over an enum grows code size with each added case and, worse, scatters the dispatch logic across every function that takes the tag.
The fix has the same shape every time: pull each type's behavior into its own class, give them a common interface, and let the language do the dispatch.
The Inheritance section covered inheritance: a derived class shares fields and methods with its base, a derived object can be passed where a base reference or pointer is expected, and a base method can be replaced by redeclaring it in the derived class. The last point came with a warning: replacing a non-virtual method only changes what happens when you call through the derived type directly. The base version still runs through a base pointer or reference.
That warning is the door to polymorphism. The whole point of inheriting from Product is so that other code (a shopping cart, a recommendation engine, a search filter) can hold Product* or Product& and not care which subtype is underneath. If those callers always get the base version of every method, then inheritance is a code-reuse tool and nothing more.
Polymorphism makes inheritance pay off. It is the rule that says "when you call a method on a base reference, the actual object's version runs." Without it, the catalog loop from the inheritance section doesn't work the way the names suggest. With it, the loop becomes the centerpiece of an extensible design.
The loop talks to Product*. Two different applyDiscount implementations run. The loop doesn't check types, doesn't switch on a tag, and doesn't know that DigitalProduct and PhysicalProduct exist. That separation is the point. The virtual keyword in the base and the override keyword in the derived classes wire it up.
Inheritance provides the shared shape; polymorphism provides behavior that varies by actual type without the caller noticing.
C++ ships two distinct forms of polymorphism, and most of the confusion beginners hit comes from mixing them up. A high-level map first, then a chapter each for the details.
| Flavor | Other names | Decided at | Mechanism in C++ |
|---|---|---|---|
| Compile-time polymorphism | Static polymorphism | Compile time | Function overloading, operator overloading, templates |
| Runtime polymorphism | Dynamic polymorphism, late binding | Runtime | Virtual functions through a base pointer or reference |
The two flavors solve different problems and pay for themselves in different ways. Both are polymorphism in the broad sense (one name, many behaviors), but the moment when "which behavior" gets decided is different.
Compile-time polymorphism is when the compiler can figure out, while reading your source code, which function or method should run. There is no runtime cost beyond a normal function call, because the call is fully resolved before the program runs. Overloaded functions are the simplest example. The compiler sees add(2, 3) versus add(2.5, 3.5) and picks the right overload based on argument types.
All three calls look the same at the call site, with different argument types. The compiler reads the types and picks one of three concrete functions. Each call is a direct, fully-resolved jump to a known function in the binary. There is no table lookup, no indirection. The "polymorphism" lives entirely in the source code; the machine code sees three unrelated calls.
Runtime polymorphism fits when the compiler can't make the decision. It can't know which subclass of Product is sitting behind a Product* until the program is actually running and that pointer has been assigned. The mechanism that lets the right method still run is a runtime lookup using virtual functions. The cost is a small indirection per call; the benefit is the ability to add new subclasses without touching the calling code.
The loop calls send on a Notification*. The actual object behind each pointer determines which version of send runs. The decision happens at runtime, on every call, because the compiler can't tell from the source which concrete type the loop is iterating over right now. That is runtime polymorphism.
The diagram captures the decision criterion: who has enough information to choose, the compiler or the running program? When the compiler can choose, you get compile-time polymorphism with zero runtime overhead. When only the program knows (because it depends on data, user input, or what's been pushed into a container), the runtime lookup costs a little and gives the ability to plug in new types later without recompiling the caller.
A quick map of which features go in which bucket:
| Feature | Flavor |
|---|---|
| Function overloading | Compile-time |
| Operator overloading | Compile-time |
| Templates (function and class) | Compile-time |
| Virtual functions | Runtime |
| Pure virtual functions and abstract classes | Runtime |
dynamic_cast and typeid | Runtime |
A common example of compile-time polymorphism: std::cout << x works for int, double, std::string, and a dozen other types because the standard library has overloaded operator<< for each one. The compiler picks the right overload based on what's on the right-hand side. No virtual functions involved.
Three different argument types, three different overloads of operator<<, all resolved before the program runs. Contrast with a runtime example, a processPayment that handles any payment method through a single base type:
Compare this to the if/else ladder from earlier in the chapter. Same output. Three big differences in the code that produced it:
| Concern | if/else ladder | Polymorphic version |
|---|---|---|
| Adding a new payment type | Edit checkout plus every dispatcher | Add a new subclass, done |
| The caller's knowledge | Must know every method by string tag | Only knows Payment |
| Where the behavior lives | Inside a switch statement | Inside the class that owns it |
The processOrder function does not name CreditCardPayment, WalletPayment, or UpiPayment. Nothing in the call site needs to change when adding GiftCardPayment or BnplPayment. That decoupling is what runtime polymorphism buys.
Each virtual call costs one indirect lookup on top of a normal call: read the hidden vptr from the object, index into the vtable, jump through the function pointer. A few extra cycles. The compiler can sometimes prove the type and "devirtualize" the call back to a direct one.
By the end of this section, you should be able to read code that uses a base pointer to a derived object and predict which method runs, why, and what it costs. You should also be able to look at an if/else ladder on a type tag and see the polymorphic refactor that hides inside it.
A longer worked example that compares the two designs end to end. The setup: an order placed on an E-Commerce site sends out several notifications when its state changes. An SMS goes to the customer's phone. An email goes to their inbox with the receipt. A push notification fires on the mobile app. A printed label gets queued at the warehouse.
The procedural version uses a tag and a switch.
The code works for four channels. The structure scales badly. Every channel-specific behavior has to fit through the Notice struct, so the moment SMS needs a phone number and email needs an SMTP server, the struct grows fields that only one branch uses. Every new channel forces an edit to deliver. The polymorphic version moves the per-channel logic into the per-channel class.
Each Notice subclass carries its own state (a phone number, an email address, a device id). The loop doesn't know about any of that; it calls deliver. Adding a WhatsAppNotice later means writing one class and pushing it onto outbox. No existing file needs to change. That property, open for extension, closed for modification, is the practical upshot of polymorphism in design terms.
The two versions produce equivalent output for this small example, but they age very differently as the system grows.
| Dimension | switch version | Polymorphic version |
|---|---|---|
| Lines that change to add a channel | deliver, enum, struct | One new class |
| Per-channel state | Forced into a shared struct or globals | Lives inside the class |
| Testing one channel in isolation | Hard, deliver knows them all | Easy, the class stands alone |
| Reading the dispatch logic | One long switch, all in one spot | Distributed across classes |
The last row cuts both ways. A switch puts every branch in one place, which is convenient when you want to see all options at once. The polymorphic design spreads the branches across files, which can make the system harder to skim. Most real codebases land on polymorphism because the cost of editing one shared dispatcher every time anything changes outweighs the benefit of having the cases collected in one file.
A reasonable question at this point: function overloading was a chapter in the Functions section, virtual functions are a chapter in this section, templates are a section of their own. Why does polymorphism need any treatment beyond the sum of those parts?
Two reasons.
First, the concept is bigger than any single feature. Polymorphism is the principle that one name produces many behaviors. C++ provides multiple features that implement that principle, and the trade-offs between them matter for system design. A function overload, a template, and a virtual function all express "this name has many forms", but they decide which form at different times and pay different costs. Picking the right one is a design skill, not a language skill.
Second, the runtime flavor has a set of rules that don't fit neatly into "here's the virtual keyword." There are pure virtual functions, virtual destructors, the vtable, the vptr, object slicing, function hiding, RTTI, dynamic casts. Each is a small concept on its own. Together they form a coherent model, and that model is the lingua franca of object-oriented C++. A single chapter on virtual would skip every interesting question.
This chapter is the orientation. The rest of the section is the detailed map.
The model is more expensive to learn than the runtime cost. Once the model is in place, predicting how a polymorphic program behaves is mechanical. The internals lessons that follow make the virtual call mechanism concrete.
10 quizzes