Section 5: Polymorphism
Start With an Everyday Picture: the "Execute" Button at a Trading Firm
Imagine a trading dashboard with one big "Execute" button. When a trader clicks it, the system doesn't ask "wait, is this a Market Order, a Limit Order, or a Stop-Loss Order? Let me run different code depending on which one." It just says: whatever this order is, call execute() on it — and each order type quietly does its own correct thing.
The dashboard code doesn't need an if/else chain checking the type. It just treats every order the same way (as "an Order that can execute itself"), and the right specific behavior happens automatically depending on what it actually is underneath.
That's polymorphism: treating different objects through the same shared interface, and having each one respond in its own correct way — without the calling code needing to know or check which exact type it's dealing with.
Polymorphism means: "I can call the same method name on different objects, and each one does its own version of the right thing — I don't need to know or care which exact type I'm holding."
Technical: Polymorphism is the ability to treat objects of different classes uniformly through a common interface (a shared base class or interface), where the specific method implementation that actually runs is determined by the object's real (runtime) type, not by the type of the reference/variable used to call it.
Why This Needs Inheritance/Interfaces First (and Why It's Last in Our Order)
This is why we did Encapsulation → Abstraction → Inheritance → Polymorphism in that order: polymorphism is impossible without a shared contract already existing. You can't say "treat these different objects the same way" unless they already share something — a common base class or interface — that guarantees they all understand the same method call. That shared contract is exactly what Inheritance and Type 2 Abstraction (interfaces) gave us in the last two sections.
Why Bother — What Breaks Without It?
Without polymorphism, code that processes orders would look like this:
def process_order(order):
if isinstance(order, MarketOrder):
order.execute_market()
elif isinstance(order, LimitOrder):
order.execute_limit()
elif isinstance(order, StopLossOrder):
order.execute_stoploss()
# ... and this keeps growing forever
The problem: every time you add a new order type (say, TrailingStopOrder), you must go back and edit this if/else chain — which is exactly the "reopening old, tested code" problem from Section 0. This function will never be "done"; it grows forever and risks breaking every time you touch it.
With polymorphism:
def process_order(order):
order.execute() # done. Works for EVERY order type, forever.
This function never needs to change again, no matter how many new order types you add later. That's Open/Closed in its purest form — new order types are added by writing new classes, and this function is never touched.
The Code — Seeing Polymorphism in Action
Python:
class Order:
def __init__(self, ticker, quantity):
self.ticker = ticker
self.quantity = quantity
def validate(self):
if self.quantity <= 0:
raise ValueError("Quantity must be positive")
def execute(self):
print("Generic order executed")
class MarketOrder(Order):
def execute(self):
self.validate()
print(f"Market order executed for {self.quantity} shares of {self.ticker}")
class LimitOrder(Order):
def __init__(self, ticker, quantity, limit_price):
super().__init__(ticker, quantity)
self.limit_price = limit_price
def execute(self):
self.validate()
print(f"Limit order executed for {self.quantity} shares of {self.ticker} at {self.limit_price}")
class StopLossOrder(Order):
def __init__(self, ticker, quantity, stop_price):
super().__init__(ticker, quantity)
self.stop_price = stop_price
def execute(self):
self.validate()
print(f"Stop-loss order executed for {self.quantity} shares of {self.ticker} at {self.stop_price}")
# THIS is polymorphism:
orders = [
MarketOrder("AAPL", 10),
LimitOrder("TSLA", 5, 250.0),
StopLossOrder("GOOG", 8, 140.0),
]
for order in orders:
order.execute() # same line of code — three completely different behaviors happen
Output:
Market order executed for 10 shares of AAPL
Limit order executed for 5 shares of TSLA at 250.0
Stop-loss order executed for 8 shares of GOOG at 140.0
The for loop has no idea what specific type each order is. It just calls .execute() and trusts that whatever object it's holding knows how to handle that call correctly. Three different classes, one uniform line of code.
C++:
#include <vector>
#include <memory>
std::vector<std::unique_ptr<Order>> orders;
orders.push_back(std::make_unique<MarketOrder>("AAPL", 10));
orders.push_back(std::make_unique<LimitOrder>("TSLA", 5, 250.0));
orders.push_back(std::make_unique<StopLossOrder>("GOOG", 8, 140.0));
for (auto& order : orders) {
order->execute(); // same call, three different real behaviors
}
In C++, if you just wrote std::vector<Order> orders;, you'd hit something called object slicing — each MarketOrder/LimitOrder would get "cut down" to fit into a plain Order-sized box, losing everything that made it special. To store different actual subclasses in one collection and keep their real identity, C++ requires you to store pointers to them (unique_ptr<Order> is a modern, safe pointer) rather than the objects directly. Python doesn't have this problem — variables in Python are always references under the hood, so this issue simply doesn't come up.
Second Example — Bank Account Example
Python:
class Account:
def __init__(self, balance=0):
self._balance = balance
def apply_monthly_fee(self):
print("No fee for generic account")
class SavingsAccount(Account):
def apply_monthly_fee(self):
print("No fee — savings accounts are fee-free")
class CurrentAccount(Account):
def apply_monthly_fee(self):
self._balance -= 10
print(f"$10 fee applied. New balance: {self._balance}")
accounts = [SavingsAccount(1000), CurrentAccount(1000)]
for acc in accounts:
acc.apply_monthly_fee() # each account type handles its own fee logic
Two Things People Confuse With "True" Polymorphism (Worth Naming Precisely)
Technical — Method Overriding (what we just did) vs. Method Overloading (a different, unrelated idea despite the similar name):
- Overriding (this section): a subclass provides its own version of a method the parent already declared — resolved at runtime, based on the object's real type. This is what "polymorphism" means in the LLD/OOP sense.
- Overloading: defining multiple methods with the same name but different parameters in the same class (e.g.,
execute()andexecute(urgent=True)). This is resolved at compile time, not runtime, and doesn't require inheritance at all.
Overriding is "different classes, same method name, different behavior chosen automatically based on what the object really is." Overloading is "same class, same method name, but different input shapes — you're really just picking which version to call based on what you pass in." They sound similar because of the word "different," but they solve completely different problems. In LLD interviews, when people say "polymorphism," they almost always mean overriding — the interview problems in your PDF (Chess, Parking Lot, Payment Gateway) all lean on this kind.
Line-by-line syntax differences:
| What | Python | C++ | Why different |
|---|---|---|---|
| Enabling polymorphism at all | Automatic — Python always resolves methods at runtime by default | Requires the virtual keyword on the base class method | In C++, if you forget virtual on execute() in Order, calling execute() through an Order* pointer will always run Order's version, even if the object is really a MarketOrder — silently wrong behavior. Python has no such trap; it always checks the real object type. |
| Storing mixed subclass objects in one collection | orders = [MarketOrder(...), LimitOrder(...)] — just works | Needs pointers: std::vector<std::unique_ptr<Order>> | C++ needs pointers to avoid "object slicing" (cutting subclass objects down to base-class size). Python references don't have this problem. |
| Calling the polymorphic method | order.execute() | order->execute() (via pointer) | Same underlying mechanism (a "virtual dispatch table" behind the scenes) — different call syntax because C++ is going through a pointer. |
Encapsulation → Abstraction → Inheritance → Polymorphism. Everything from here (SOLID, then design patterns) is built directly on top of these four ideas — every pattern is really just a clever combination of them applied to a specific recurring problem.
In your own words — what's the difference between overriding and overloading, and why did the process_order if/else chain violate Open/Closed?
Addendum: Clarifying Confusion — if/else vs. Class-based Polymorphism
The confusion: why does using classes with a shared method name actually work differently from just using if/else to check a type?
Same Task, Two Approaches — "Make a Dog and a Cat Speak"
Approach 1: if/else (one function decides everything)
def make_sound(animal_type):
if animal_type == "dog":
print("Woof")
elif animal_type == "cat":
print("Meow")
make_sound("dog") # you pass in a STRING, "dog"
make_sound("cat") # you pass in a STRING, "cat"
What's really happening: there is no Dog object, no Cat object — just plain text ("dog", "cat"). One single function holds all the knowledge of what every animal sounds like, and every time you call it, it must re-check "which animal is this?" using an if/else, before it can decide what to print.
Approach 2: Classes + polymorphism (each object decides for itself)
class Dog:
def make_sound(self):
print("Woof")
class Cat:
def make_sound(self):
print("Meow")
d = Dog() # an actual Dog OBJECT — not a string
c = Cat() # an actual Cat OBJECT — not a string
d.make_sound() # no if/else anywhere — Dog just knows it says Woof
c.make_sound() # no if/else anywhere — Cat just knows it says Meow
What's really happening: d and c are real objects, each built from its own class. The knowledge "Dog says Woof" lives inside the Dog class itself, not in some outside function. Calling d.make_sound() involves no checking, no if/else, no lookup — it directly runs Dog's own version of make_sound(), because that's the only version Dog has.
The Actual Difference
| if/else approach | Class/polymorphism approach | |
|---|---|---|
| Who holds the knowledge? | One central function, holding all the logic for every type | Each class holds only its own knowledge |
| How is the sound decided? | By checking — if animal_type == "dog" | By not needing to check at all — the object already knows |
| Adding a new animal (e.g. Duck)? | Must open the function and add another elif | Just write a new Duck class — nothing existing is touched |
In the if/else version, the outside code is responsible for figuring out what to do. In the class/polymorphism version, each object is responsible for knowing what to do about itself. That shift — from "outside code checks and decides" to "the object itself already knows" — is polymorphism.
From First Principle
Rebuilding Polymorphism — Step 1
Forget classes, objects, inheritance, everything. Just this one idea:
A function/method is just a named set of instructions that does something when you call it.
def say_hello():
print("Hello")
say_hello() # runs the instructions inside — prints "Hello"
That's it. Calling say_hello() runs whatever is written inside it.
Now — same name, different instructions:
def say_hello():
print("Hello")
def say_hello(): # redefining the SAME name
print("Hi there")
say_hello() # what do you think prints now?
Only one say_hello can exist at a time in plain functions like this — the second definition replaces the first. So this would print "Hi there", not "Hello". The first one is gone.
This is the exact problem polymorphism solves: what if I want multiple things, each with their own version of "say_hello", to exist at the same time, without one replacing the other?
Rebuilding Polymorphism — Step 2
Classes let the same method name exist multiple times, safely.
In Step 1, you saw that two plain functions named say_hello can't coexist — the second one wipes out the first.
A class fixes this by giving each version of the method its own separate "home."
class Dog:
def make_sound(self):
print("Woof")
class Cat:
def make_sound(self):
print("Meow")
Notice: both classes have a method called make_sound. This is not a conflict, unlike Step 1's say_hello problem — because Dog's make_sound lives inside Dog, and Cat's make_sound lives inside Cat. They're in two separate boxes. One doesn't overwrite the other.
Now create one real object from each class:
d = Dog()
c = Cat()
d is a real Dog object. c is a real Cat object. Each one carries its own class's version of make_sound with it.
d.make_sound() # prints "Woof" — runs Dog's own version
c.make_sound() # prints "Meow" — runs Cat's own version
The key idea to hold onto: d.make_sound() and c.make_sound() use the exact same method name (make_sound), but they run completely different code, because d and c are different objects, each carrying their own class's version with them.
This is different from Step 1, where there was only ever one say_hello in existence at a time. Here, there are genuinely two separate make_sound methods, coexisting, and which one runs depends entirely on which object you called it on.
Rebuilding Polymorphism — Step 3
The actual "polymorphism" moment: not knowing (or caring) which type you have.
In Step 2, you called d.make_sound() and c.make_sound() separately, and you — the programmer — knew exactly which was which (d is a Dog, c is a Cat).
Now here's the real shift: what if you put both objects into one list, and processed them without checking what type each one is?
animals = [Dog(), Cat()]
for animal in animals:
animal.make_sound()
Look very closely at that loop. Inside it, the variable is just called animal — a generic name. The loop code has no if/else, no type-check, nothing that asks "is this a Dog or a Cat?" It just says: whatever you are, call your own make_sound().
Trace it by hand:
- First time through the loop:
animalcurrently holds theDog()object →animal.make_sound()runs Dog's version → prints "Woof" - Second time through the loop:
animalcurrently holds theCat()object →animal.make_sound()runs Cat's version → prints "Meow"
The exact same line of code — animal.make_sound() — produced two different outputs, because each time, animal was secretly holding a different real object. The loop itself never needed to know which one it had. It just trusted that whatever object was there, calling .make_sound() on it would do the right thing for that object.
This is the whole definition of polymorphism, now that you've built up to it:
Polymorphism = writing code that calls a method by name, without checking what exact type the object is — and trusting that each object will run its own correct version.
You write animal.make_sound() once. You never write it again, no matter how many animal types you add later (Cow, Duck, Horse...). Every new animal type just needs its own make_sound() method, and this same loop will automatically call the right one — because it was never checking types to begin with.
Rebuilding Polymorphism — Step 4: Connecting to the Trading Example
Everything you just learned with Dog/Cat maps exactly onto the Order example from earlier — just swap the words.
| Dog/Cat version | Trading version |
|---|---|
Dog, Cat classes | MarketOrder, LimitOrder, StopLossOrder classes |
make_sound() method | execute() method |
animals = [Dog(), Cat()] | orders = [MarketOrder(...), LimitOrder(...), StopLossOrder(...)] |
animal.make_sound() in a loop | order.execute() in a loop |
| "Woof" / "Meow" — different sounds | "Market order executed..." / "Limit order executed..." — different behaviors |
The code, side by side with the Dog/Cat version:
# Dog/Cat version
animals = [Dog(), Cat()]
for animal in animals:
animal.make_sound()
# Trading version — SAME PATTERN
orders = [MarketOrder("AAPL", 10), LimitOrder("TSLA", 5, 250.0)]
for order in orders:
order.execute()
Trace it exactly like before:
- First pass:
orderholds theMarketOrderobject →order.execute()runsMarketOrder's own version → prints "Market order executed..." - Second pass:
orderholds theLimitOrderobject →order.execute()runsLimitOrder's own version → prints "Limit order executed..."
No if/else anywhere in this loop. No isinstance() check. No "is this a MarketOrder or a LimitOrder?" The loop just says "call execute()," and each object already knows what that means for itself — exactly like Dog knowing it says "Woof" and Cat knowing it says "Meow."
Why this matters in a real interview (tying back to Section 0)
If you add a new order type tomorrow — say TrailingStopOrder — you just write a new class with its own execute() method. This loop never changes. It already works for the new type, automatically, the same way it would automatically work if you added a Duck class to the animals list.
That's the entire payoff: one small piece of code (the loop) that never needs editing again, no matter how much the system grows.
Same method name → different real behavior → decided automatically by which object you're actually holding → calling code never needs to check or change.