ByteWise

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.

💡🗣️ Conversational

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.
💡🔗 Ties Back to Section 0

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
ℹ️🗣️ What Just Happened

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
}
💡🗣️ Why C++ Needs std::unique_ptr Here (a Genuinely New Idea, Worth Pausing On)

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() and execute(urgent=True)). This is resolved at compile time, not runtime, and doesn't require inheritance at all.
💡🗣️ Conversational

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:

WhatPythonC++Why different
Enabling polymorphism at allAutomatic — Python always resolves methods at runtime by defaultRequires the virtual keyword on the base class methodIn 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 collectionorders = [MarketOrder(...), LimitOrder(...)] — just worksNeeds 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 methodorder.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.
ℹ️This Completes the Four Pillars of OOP

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.

⚠️🧠 Check Yourself Before Moving On

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 approachClass/polymorphism approach
Who holds the knowledge?One central function, holding all the logic for every typeEach class holds only its own knowledge
How is the sound decided?By checkingif 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 elifJust write a new Duck class — nothing existing is touched
ℹ️The One Sentence That Matters Most

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: animal currently holds the Dog() object → animal.make_sound() runs Dog's version → prints "Woof"
  • Second time through the loop: animal currently holds the Cat() 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.

💡🗣️ One More Time, in the Simplest Words Possible

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 versionTrading version
Dog, Cat classesMarketOrder, LimitOrder, StopLossOrder classes
make_sound() methodexecute() method
animals = [Dog(), Cat()]orders = [MarketOrder(...), LimitOrder(...), StopLossOrder(...)]
animal.make_sound() in a looporder.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: order holds the MarketOrder object → order.execute() runs MarketOrder's own version → prints "Market order executed..."
  • Second pass: order holds the LimitOrder object → order.execute() runs LimitOrder'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.

ℹ️The Full Picture, Built From Zero

Same method name → different real behavior → decided automatically by which object you're actually holding → calling code never needs to check or change.