Back to Home
OOP Pillar 4 · Polymorphism

Polymorphism —
One Interface,
Many Forms.

You call shape.area() on a circle, a rectangle and a triangle — and each one answers in its own way. You call len() on a string, a list and your own class — and it just works. That is polymorphism: the same call, different behavior, chosen by the object receiving it.

This interactive learning platform teaches all four flavors Python offers — method overriding, duck typing, operator overloading, and single dispatch — with runnable code, deep-dive walkthroughs, six exercises, a live lab, a Python playground, a progress tracker, and 10 live app projects.

📅 October 20, 2026 ⏱ 55 min read 🎓 Intermediate 🐍 Python 3.11+
Back to Home Start the Course Open Playground 10 Live Projects

Progress Tracker

Tick off each milestone as you complete it. Your progress is saved in this browser — you can leave and come back anytime.

🎯 Your Journey Through Polymorphism

0%

    What Is Polymorphism?

    🌀 Same Message, Different Behavior

    The word comes from Greek: poly (many) and morph (form). In code it means one call site can work with many kinds of objects, and each object decides what the call actually does.

    It is the pillar that makes the other three pay off. Encapsulation protects an object's data, abstraction defines the contract, inheritance shares code — and polymorphism lets callers use the contract without caring who is behind it.

    Every Python example below is interactive: copy it, or press Run to execute it in your browser with Pyodide. You also have a full Playground at your disposal, plus 10 live projects that show polymorphism in production-style code.

    5 programs with walkthroughs 6 exercises with solutions Interactive polymorphism lab Free-form Python playground Progress tracker 10 live app projects

    The Problem It Solves

    Without polymorphism, code asks "what are you?" before every action — an if / elif ladder on type, repeated in every function that touches the objects. Each new type means hunting down every ladder and editing it.

    def area(shape):
        if shape.kind == "circle":
            return 3.14159 * shape.r ** 2
        elif shape.kind == "rectangle":
            return shape.w * shape.h

    Smell: the same if kind == ... ladder appears in several functions. Behavior that belongs to the object has leaked out into the callers.

    Goal: write the loop once — for s in shapes: s.area() — and add new shapes by writing new classes, touching nothing else.

    Key Ideas Before the Code

    Skim this glossary now and come back whenever a word in the walkthroughs feels unfamiliar.

    Glossary

    TermPlain-English meaningWhere you'll see it
    PolymorphismOne call, many behaviors — chosen by the receiving object.Everywhere on this page
    Method overridingA subclass supplies its own version of an inherited method.Circle.area
    Dynamic dispatchPython picks which method to run by looking at the object's actual type at runtime.s.area() in a loop
    MROMethod Resolution Order — the class search path Python follows to find an attribute.C.__mro__
    Duck typing"If it has the method I call, it's good enough." No shared base class required.make_noise(thing)
    EAFP / LBYL"Easier to Ask Forgiveness than Permission" (try it, catch errors) vs "Look Before You Leap" (check first).safe_noise
    ProtocolA typing tool that describes a duck-typed interface without inheritance.Speaker
    Dunder methodA "double underscore" hook such as __add__ or __len__ that Python calls for you.Money, Playlist
    Operator overloadingMaking +, ==, < work for your own classes via dunder methods.price + tax
    NotImplementedA sentinel value an operator method returns to say "I don't know how — ask the other side." It is not an exception.__add__
    Single dispatchChoosing a function implementation by the type of its first argument.@singledispatch
    Liskov SubstitutionA subclass must be usable anywhere its parent is, without surprising the caller.Best-practices section

    A 30-Second Experiment: Watch Python Choose

    Three classes in a chain. Only A and C define hello. Predict what each object prints before pressing Run.

    class A:
        def hello(self):
            return "A.hello"
    
    class B(A):
        pass
    
    class C(B):
        def hello(self):
            return "C.hello"
    
    for obj in (A(), B(), C()):
        print(type(obj).__name__, "→", obj.hello())
    
    print([cls.__name__ for cls in C.__mro__])
    print(B.hello is A.hello, C.hello is A.hello)
    Expected output
    A → A.hello B → A.hello C → C.hello ['C', 'B', 'A', 'object'] True False
    📝 How to read this
    1. Python searches the MRO in order. For B().hello() it checks B (nothing), then A (found). For C().hello() it stops at C — the override wins.
    2. The loop never changes. obj.hello() is one expression that ran two different functions. That is polymorphism in its smallest form.
    3. The last line proves it with identity checks: B.hello is A.hello is True (inherited, same function object), while C.hello is A.hello is False (overridden).

    Error Message Decoder

    You will meet these while experimenting. Each points at a specific polymorphism mistake.

    MessageWhat it meansFix
    AttributeError: 'Rock' object has no attribute 'speak'Duck typing failed: the object lacks the method the caller needs.Pass a conforming object, or catch the error where it makes sense.
    NotImplementedErrorA base-class placeholder ran because a subclass forgot to override it.Override the method in the subclass.
    TypeError: unsupported operand type(s) for +Neither operand knew how to add the other (both returned NotImplemented).Add the right dunder method, or convert the operand.
    TypeError: '<' not supported between instances…You tried to sort or compare objects with no __lt__.Define __lt__ (and use @total_ordering).
    TypeError: unhashable typeYou defined __eq__ but not __hash__, then used the object in a set or dict key.Define __hash__ consistent with __eq__.
    Output shows <__main__.X object at 0x…>No __repr__ / __str__ defined.Add __repr__.

    How to study the programs: (1) read the code once without running it; (2) predict the output; (3) press Run and compare; (4) read the deep dive; (5) do the "Try it yourself" experiments; (6) press Mark as learned at the top of the program. Your progress is saved.

    The Four Flavors of Polymorphism in Python

    Python does not have one polymorphism feature — it has four tools that solve slightly different problems. Knowing which one fits is half the skill.

    Which Tool, When?

    FlavorHow it worksUse it when…Program
    🧬 Method overridingSubclasses replace an inherited method; Python dispatches on the real type.Types share a family and some shared code.Program 1
    🦆 Duck typingAny object with the right methods works. No base class needed.Unrelated types should plug into the same function.Program 2
    ➕ Operator overloadingDunder methods hook your class into +, ==, len(), for, …Your objects behave like numbers, collections or values.Programs 3–4
    🎯 Single dispatch@singledispatch picks a function by the first argument's type.You can't add methods (built-ins, third-party types) but need per-type behavior.Program 4

    Python has no classic overloading. In Java you can write area(int) and area(String) side by side. In Python, defining a function twice just rebinds the name — the second silently replaces the first. Default arguments, duck typing, or singledispatch do that job instead.

    Rule of thumb: start with duck typing (simplest). Reach for inheritance when types genuinely share code. Add a Protocol or ABC when you want the contract written down and checked.

    Five Programs, Explained Line by Line

    Program 1 — Method Overriding: A Family of Shapes

    The classic form. A base class defines what a shape can do; each subclass overrides how. Notice that describe() is written once, in the base class, yet prints a different area for each shape.

    import math
    
    class Shape:
        def __init__(self, name):
            self.name = name
    
        def area(self):
            raise NotImplementedError("Subclasses must implement area()")
    
        def describe(self):
            return f"{self.name}: area = {self.area():.2f}"
    
    class Circle(Shape):
        def __init__(self, radius):
            super().__init__("Circle")
            self.radius = radius
    
        def area(self):
            return math.pi * self.radius ** 2
    
    class Rectangle(Shape):
        def __init__(self, width, height, name="Rectangle"):
            super().__init__(name)
            self.width = width
            self.height = height
    
        def area(self):
            return self.width * self.height
    
    class Square(Rectangle):
        def __init__(self, side):
            super().__init__(side, side, name="Square")
    
    class Blob(Shape):
        pass
    
    shapes = [Circle(2), Rectangle(3, 4), Square(5)]
    for shape in shapes:
        print(shape.describe())
    
    print(f"Total area: {sum(s.area() for s in shapes):.2f}")
    
    try:
        Blob("Blob").describe()
    except NotImplementedError as err:
        print("NotImplementedError:", err)
    Expected output
    Circle: area = 12.57 Rectangle: area = 12.00 Square: area = 25.00 Total area: 49.57 NotImplementedError: Subclasses must implement area()

    🔍 Deep dive — how describe() finds the right area()

    Follow one call: Square(5).describe(). The key is that Python restarts its search from the object's real class every time it sees self.something.

    #What Python doesResult
    1Looks for describe on SquareNot there.
    2Moves up the MRO to RectangleNot there either.
    3Moves up to ShapeFound — runs Shape.describe with self = the square.
    4Inside it, self.area() — search restarts at SquareNot on Square; found on Rectangle.
    5Rectangle.area runs: 5 * 525 → formatted as 25.00.

    Why this matters: base-class code can call methods that don't exist yet in any subclass. Shape.describe was written before Square existed — and still works for it. This is the heart of the Template Method idea.

    📖 Line-by-line walkthrough

    1. class Shape: with __init__(self, name) — stores a label every shape needs. Shared state lives once, in the parent.
    2. area() raises NotImplementedError — a placeholder that says "subclasses must override me". It is a plain class (no ABC), so you can still create a Shape; the error only fires when area() is called. For enforcement at creation time, use an abstract base class.
    3. describe() — the shared, polymorphic method. {self.area():.2f} calls whichever area the object has and formats it to two decimals.
    4. Circle.__init__ and super().__init__("Circle") — the subclass sets its own data (radius) and delegates the shared part to the parent. Skipping super() would leave self.name unset.
    5. Circle.area = math.pi * r ** 2 — the override. Same name and signature as the parent's, different behavior.
    6. Rectangle(width, height, name="Rectangle") — the extra name parameter has a default so a subclass can relabel it. This is what lets Square reuse the class cleanly.
    7. Square(Rectangle) — overrides nothing except the constructor: it calls super().__init__(side, side, name="Square"). A square is a rectangle with equal sides, so it inherits area() as-is. Overriding is optional.
    8. class Blob(Shape): pass — a subclass that forgot to override. It exists to demonstrate the failure path.
    9. shapes = [Circle(2), Rectangle(3, 4), Square(5)] — a list holding three different classes. That works because they all promise the same interface.
    10. The for loop — one line, shape.describe(), three behaviors. No isinstance, no if.
    11. sum(s.area() for s in shapes) — a generator expression calling area() polymorphically: 12.57 + 12 + 25 = 49.57.
    12. The final try / except — Blob("Blob").describe() reaches Shape.area, which raises. We catch NotImplementedError and print its message.

    ⚠️ Common mistakes

    Mixing up NotImplementedError and NotImplemented. The first is an exception you raise in placeholder methods. The second is a sentinel value you return from operator methods. Different jobs, confusingly similar names.

    Changing the signature in an override. If Circle.area(self, precision) demands an extra argument, every caller that treats shapes uniformly — s.area() — breaks. Overrides must stay call-compatible with the parent.

    Forgetting super().__init__(). The object builds fine, then describe() fails with AttributeError: 'Circle' object has no attribute 'name'.

    🧪 Try it yourself

    Change one thing at a time:

    • Add print(Square.__mro__) and match it to the table above.
    • Override describe() in Circle to append the radius, and call super().describe() inside it. What gets reused?
    • Change Blob to inherit from ABC with @abstractmethod on area. Which line now fails — and when?

    Program 2 — Duck Typing: Unrelated Classes, One Function

    Duck, Dog and Robot share no base class. make_noise() doesn't care — it only needs .speak() to exist. "If it walks like a duck and quacks like a duck, it's a duck."

    from typing import Protocol, runtime_checkable
    
    class Duck:
        def speak(self):
            return "Quack"
    
    class Dog:
        def speak(self):
            return "Woof"
    
    class Robot:
        def speak(self):
            return "Beep boop"
    
    class Rock:
        pass
    
    def make_noise(thing):
        return thing.speak()
    
    for thing in [Duck(), Dog(), Robot()]:
        print(type(thing).__name__, "→", make_noise(thing))
    
    try:
        make_noise(Rock())
    except AttributeError as err:
        print("AttributeError:", err)
    
    def safe_noise(thing):
        try:
            return thing.speak()
        except AttributeError:
            return "(silence)"
    
    print(safe_noise(Rock()))
    
    @runtime_checkable
    class Speaker(Protocol):
        def speak(self) -> str:
            ...
    
    print(isinstance(Duck(), Speaker), isinstance(Rock(), Speaker))
    Expected output
    Duck → Quack Dog → Woof Robot → Beep boop AttributeError: 'Rock' object has no attribute 'speak' (silence) True False

    🔍 Deep dive — nominal vs structural typing

    Inheritance-basedDuck typing
    Question asked"Are you a Shape?""Can you .speak()?"
    Needs a shared parentYesNo
    Checked whenAt class definition (ABC) or call timeOnly when the method is actually called
    Great forFamilies with shared codePlugging unrelated or third-party types together
    RiskDeep hierarchies get rigidA missing method fails late, at runtime

    You already use this daily. print(x, file=f) works with any object that has a write() method — a real file, io.StringIO, or your own class.

    📖 Line-by-line walkthrough

    1. Duck, Dog, Robot — three independent classes (each inherits only from object) with the same method name. Their only link is a promise made by convention.
    2. class Rock: pass — deliberately has no speak. It is the control case.
    3. make_noise(thing) — just return thing.speak(). No type annotation, no isinstance. The function trusts the caller.
    4. The first loop — type(thing).__name__ prints each class name; make_noise works unchanged for all three.
    5. make_noise(Rock()) inside try — the failure surfaces as AttributeError: 'Rock' object has no attribute 'speak'.
    6. safe_noise() — the EAFP style: try the call and handle the failure, instead of checking first.
    7. @runtime_checkable + class Speaker(Protocol) — a Protocol writes the duck-typing contract down: "anything with speak() -> str".
    8. isinstance(Duck(), Speaker) — normally isinstance checks inheritance, but @runtime_checkable makes it check "does this object have a speak attribute?"

    ⚠️ Common mistakes

    Wrapping too much in try / except AttributeError. If the speak() method itself has a typo that raises AttributeError, a broad handler hides your bug.

    Assuming runtime_checkable checks signatures. It only checks that the name exists, not its arguments or return type.

    Same name, different meaning. A Gun.shoot() and a Camera.shoot() both "work" with a function that calls .shoot() — duck typing can't tell you the semantics match.

    🧪 Try it yourself

    Experiments:

    • Give Rock a speak that returns "...". Does anything else need to change?
    • Add a Cat class with speak returning "Meow" and add it to the list. How many lines elsewhere did you edit?
    • Make speak a property on one class (not a method). What error appears when make_noise calls it?

    Program 3 — Operator Overloading: A Money Type

    Python turns a + b into a.__add__(b). By defining dunder methods, your own class gets natural syntax — and works with built-ins like sorted(), max() and sum().

    from functools import total_ordering
    
    @total_ordering
    class Money:
        def __init__(self, amount, currency="PHP"):
            self.amount = round(amount, 2)
            self.currency = currency
    
        def _check(self, other):
            if other.currency != self.currency:
                raise ValueError("Currency mismatch")
    
        def __add__(self, other):
            if not isinstance(other, Money):
                return NotImplemented
            self._check(other)
            return Money(self.amount + other.amount, self.currency)
    
        def __mul__(self, factor):
            if not isinstance(factor, (int, float)):
                return NotImplemented
            return Money(self.amount * factor, self.currency)
    
        __rmul__ = __mul__
    
        def __eq__(self, other):
            if not isinstance(other, Money):
                return NotImplemented
            return (self.amount, self.currency) == (other.amount, other.currency)
    
        def __lt__(self, other):
            if not isinstance(other, Money):
                return NotImplemented
            self._check(other)
            return self.amount < other.amount
    
        def __hash__(self):
            return hash((self.amount, self.currency))
    
        def __repr__(self):
            return f"Money({self.amount}, {self.currency!r})"
    
        def __str__(self):
            return f"{self.currency} {self.amount:,.2f}"
    
    price = Money(120.50)
    tax = Money(14.46)
    print(price + tax)
    print(price * 3)
    print(2 * price)
    print(sorted([Money(30), Money(5), Money(12.5)]))
    print(max(Money(30), Money(5)))
    print(Money(10) == Money(10.0))
    print(sum([Money(1), Money(2)], Money(0)))
    
    try:
        price + 5
    except TypeError as err:
        print("TypeError:", err)
    
    try:
        Money(1) + Money(1, "USD")
    except ValueError as err:
        print("ValueError:", err)
    Expected output
    PHP 134.96 PHP 361.50 PHP 241.00 [Money(5, 'PHP'), Money(12.5, 'PHP'), Money(30, 'PHP')] PHP 30.00 True PHP 3.00 TypeError: unsupported operand type(s) for +: 'Money' and 'int' ValueError: Currency mismatch

    🔍 Deep dive — how Python translates each expression

    You writePython actually triesOutcome
    price + taxprice.__add__(tax)Both are Money → new Money.
    price * 3price.__mul__(3)3 is an int → scaled Money.
    2 * price① int.__mul__(2, price) → NotImplemented
    ② price.__rmul__(2)
    The int gives up, Python tries the reflected method on the right operand.
    price + 5① price.__add__(5) → NotImplemented
    ② int.__radd__(5, price) → NotImplemented
    Both sides declined → Python raises TypeError.
    sorted([...])repeated a < b → __lt__@total_ordering derives <=, >, >= from __eq__ + __lt__.
    sum(list, Money(0))Money(0) + m1 + m2 …Default start is the int 0; we supply a Money(0) start.

    Why return NotImplemented instead of raising TypeError? Returning it is a polite "not my job" that lets Python give the other operand a chance (the reflected method). If you raise TypeError yourself, you block that second chance.

    📖 Line-by-line walkthrough

    1. @total_ordering — a decorator from functools. You write __eq__ and __lt__; it fills in __le__, __gt__, __ge__.
    2. __init__ rounds to 2 decimals — keeps display tidy. (Real money code should use decimal.Decimal; floats keep this lesson simple.)
    3. _check(self, other) — a private helper shared by __add__ and __lt__: mixing currencies raises a clear ValueError.
    4. __add__ — first isinstance(other, Money); if not, return NotImplemented. Then check currency and return a new Money.
    5. __mul__ — accepts int or float only. Money * Money makes no sense, so it declines.
    6. __rmul__ = __mul__ — multiplication is commutative here, so the reflected version reuses the same function. That makes 2 * price work.
    7. __eq__ — compares (amount, currency) tuples. Money(10) == Money(10.0) is True.
    8. __lt__ — the one ordering primitive @total_ordering needs.
    9. __hash__ — defining __eq__ silently sets __hash__ to None, making objects unhashable. We restore it using the same fields as __eq__.
    10. __repr__ vs __str__ — repr is for developers (shown in the sorted list); str is for users (printed with print()).
    11. The demo lines — 120.50 + 14.46 = 134.96; price * 3 = 361.50; 2 * price = 241.00; sorted and max use __lt__; sum with a Money(0) start gives 3.00.
    12. The two error cases — price + 5 ends in a standard TypeError that Python itself builds; Money(1) + Money(1, "USD") hits our own ValueError.

    ⚠️ Common mistakes

    Raising TypeError inside __add__. It breaks the reflected-operand protocol. Return NotImplemented and let Python decide.

    Defining __eq__ without __hash__. Using the object in a set then fails with TypeError: unhashable type.

    Mutating in __add__. If a + b changes a, surprises follow. Return a new object.

    Surprising operators. A + that sends an email would be legal but unreadable. Overload an operator only when its meaning is obvious.

    🧪 Try it yourself

    Experiments:

    • Remove __rmul__ = __mul__ and run print(2 * price). Read the error.
    • Remove @total_ordering, keep __lt__, then try Money(1) >= Money(2).
    • Add __sub__ that raises ValueError if the result would be negative.
    • Add __bool__ so Money(0) is falsy. Then test if Money(0):.

    Program 4 — Built-in Polymorphism and Single Dispatch

    Polymorphism is everywhere in Python's built-ins: len(), +, *, for, in. This program shows how, then adds your own class to the club and uses @singledispatch for per-type behavior you can't add as methods.

    from functools import singledispatch
    
    print(len("poetic"), len([1, 2, 3]), len({"a": 1}), len(range(10)))
    print(1 + 2, "a" + "b", [1] + [2], (1,) + (2,))
    print(3 * 2, "ab" * 3, [0] * 3)
    
    class Playlist:
        def __init__(self, *songs):
            self._songs = list(songs)
    
        def __len__(self):
            return len(self._songs)
    
        def __iter__(self):
            return iter(self._songs)
    
    mix = Playlist("Sunrise", "Tides", "Harbor")
    print(len(mix), list(mix), "Tides" in mix, sorted(mix))
    
    @singledispatch
    def describe(value):
        return f"Something else: {value!r}"
    
    @describe.register
    def _(value: int):
        kind = "even" if value % 2 == 0 else "odd"
        return f"Integer {value} ({kind})"
    
    @describe.register
    def _(value: str):
        return f"Text with {len(value)} characters"
    
    @describe.register(list)
    def _(value):
        return f"List of {len(value)} items"
    
    for item in [7, "poetic", [1, 2, 3], 3.5, None, True]:
        print(describe(item))
    Expected output
    6 3 1 10 3 ab [1, 2] (1, 2) 6 ababab [0, 0, 0] 3 ['Sunrise', 'Tides', 'Harbor'] True ['Harbor', 'Sunrise', 'Tides'] Integer 7 (odd) Text with 6 characters List of 3 items Something else: 3.5 Something else: None Integer True (odd)

    🔍 Deep dive — protocols behind the built-ins

    Built-in syntaxCalls your methodFallback if missing
    len(x)x.__len__()TypeError: object has no len()
    for item in xx.__iter__()Old-style __getitem__(0, 1, 2…), else TypeError
    item in xx.__contains__(item)Iterates with __iter__ and compares each item
    sorted(x)Iterates, then compares items with <TypeError if items can't be compared
    x[i]x.__getitem__(i)TypeError: not subscriptable
    str(x) / print(x)x.__str__() → falls back to __repr__Default <object at 0x…>

    The idea: built-in functions don't know your class. They call agreed-upon dunder hooks. Implement the hook, and your class joins the same polymorphic club as str, list and dict.

    📖 Line-by-line walkthrough

    1. len(...) on four types — a string, list, dict and range all answer len() differently (6, 3, 1, 10).
    2. + on numbers, strings, lists, tuples — addition, concatenation, list joining, tuple joining.
    3. * on number, string, list — multiply, repeat, repeat.
    4. Playlist.__init__(self, *songs) — *songs collects any number of positional arguments into a tuple; stored as a list.
    5. __len__ — returns the count. That alone makes len(mix) work and also makes an empty playlist falsy.
    6. __iter__ — returns an iterator. This one method powers list(mix), for, in and sorted().
    7. print(len(mix), list(mix), "Tides" in mix, sorted(mix)) — four built-ins, one class, zero extra code.
    8. @singledispatch def describe(value) — the base function is the fallback used for any unregistered type.
    9. @describe.register with a type annotation — registers the function for the annotated type.
    10. @describe.register(list) — the explicit form, passing the type as an argument.
    11. The final loop — each value is dispatched by its type.
    12. The last item, True — prints Integer True (odd)! bool is a subclass of int, so with no bool registration Python dispatches to the closest ancestor that is registered.

    ⚠️ Common mistakes

    Forgetting bool is an int. Register bool separately if you need to treat it differently.

    Expecting dispatch on every argument. singledispatch looks only at the first argument.

    Trying "overloading" by defining a function twice. The second def silently replaces the first — no error, no dispatch.

    🧪 Try it yourself

    Experiments:

    • Add __getitem__ to Playlist and try mix[0] and mix[1:].
    • Add __contains__ that compares case-insensitively; test "tides" in mix.
    • Register a float version of describe and a bool version. Check the output for True again.
    • Delete __len__ and run len(mix). Read the error.

    Program 5 — Refactoring: From isinstance Ladder to Polymorphism

    The practical payoff. First, a fee calculator written as an if / elif ladder — which breaks the moment a new payment method (Maya) shows up. Then the same feature rebuilt with polymorphism, where adding Maya means one new class and zero edits elsewhere.

    class Card:
        pass
    
    class GCash:
        pass
    
    class Cash:
        pass
    
    def fee_ladder(method, amount):
        if isinstance(method, Card):
            return round(amount * 0.029 + 15, 2)
        elif isinstance(method, GCash):
            return round(amount * 0.02, 2)
        elif isinstance(method, Cash):
            return 0.0
        raise TypeError(f"No fee rule for {type(method).__name__}")
    
    class Maya:
        pass
    
    try:
        fee_ladder(Maya(), 1000)
    except TypeError as err:
        print("Ladder failed:", err)
    
    print("-" * 44)
    
    class PaymentMethod:
        def fee(self, amount):
            raise NotImplementedError
    
        def checkout(self, amount):
            fee = self.fee(amount)
            name = type(self).__name__
            return f"{name:<14} total {amount + fee:>9,.2f} (fee {fee:,.2f})"
    
    class CardPayment(PaymentMethod):
        def fee(self, amount):
            return round(amount * 0.029 + 15, 2)
    
    class GCashPayment(PaymentMethod):
        def fee(self, amount):
            return round(amount * 0.02, 2)
    
    class CashPayment(PaymentMethod):
        def fee(self, amount):
            return 0.0
    
    class MayaPayment(PaymentMethod):
        def fee(self, amount):
            return round(amount * 0.015, 2)
    
    for method in [CardPayment(), GCashPayment(), CashPayment(), MayaPayment()]:
        print(method.checkout(1000))
    Expected output
    Ladder failed: No fee rule for Maya -------------------------------------------- CardPayment total 1,044.00 (fee 44.00) GCashPayment total 1,020.00 (fee 20.00) CashPayment total 1,000.00 (fee 0.00) MayaPayment total 1,015.00 (fee 15.00)

    🔍 Deep dive — what changes when a new type arrives

    Task: support MayaLadder versionPolymorphic version
    Files / functions to editfee_ladder — and every other ladder (receipts, refunds, reports…)None. Write MayaPayment.
    Forget one spot?Silent wrong answer or late TypeErrorImpossible — the class carries its own behavior
    Test existing methods again?Yes — you touched shared codeNo — existing classes unchanged
    Behavior lives…Away from the data, in callersWith the data, in the class

    This is the Open/Closed Principle: the system is open to extension (new classes) but closed to modification (no edits to working code).

    📖 Line-by-line walkthrough

    1. Card, GCash, Cash (empty classes) — stand-ins that only exist to be tested with isinstance. They carry no behavior, which is the whole problem.
    2. fee_ladder — asks "what are you?" with isinstance three times, then raises TypeError for anything unknown.
    3. class Maya: pass and the failing call — a brand-new method arrives. The ladder has no branch for it.
    4. print("-" * 44) — a divider. Here * repeats a string.
    5. PaymentMethod base class — declares fee() (placeholder) and implements checkout() once.
    6. The format spec {name:<14} and {amount + fee:>9,.2f} — aligns the columns of output.
    7. CardPayment, GCashPayment, CashPayment — each overrides only fee().
    8. MayaPayment — the "later" addition at 1.5%. Nothing above it changed.
    9. The final loop — method.checkout(1000) for four classes: totals 1,044.00, 1,020.00, 1,000.00 and 1,015.00.

    ⚠️ When isinstance is still fine

    At the boundary. Validating untrusted input (parsing JSON, checking a function argument) is a legitimate use of isinstance. The smell is a ladder that decides behavior for objects you control.

    Over-engineering. Two types and one ladder used in one place? A plain if may be clearer. Polymorphism pays off as types and operations multiply.

    🧪 Try it yourself

    Experiments:

    • Add BankTransfer with a flat 25.0 fee. How many existing lines did you touch?
    • Add a second operation, refund(amount), to PaymentMethod. In the ladder version you'd add another ladder — what do you do here?
    • Use abc.ABC + @abstractmethod on fee so a forgotten override fails at creation time.

    Hands-On Exercises

    Reading code teaches recognition; writing it teaches understanding. Try each exercise on your own first, then open the solution. Every solution can be run right here.

    Exercise 1 — Add a Triangle

    Warm-up

    Task: extend the shape family with Triangle(base, height) and find the shape with the biggest area using max() with a key function — without any isinstance.

    Hint: copy the pattern of Circle: store the dimensions in __init__, override area(). The area is 0.5 * base * height.

    Show solution & explanation
    import math
    
    class Shape:
        def area(self):
            raise NotImplementedError
    
    class Circle(Shape):
        def __init__(self, radius):
            self.radius = radius
    
        def area(self):
            return math.pi * self.radius ** 2
    
    class Triangle(Shape):
        def __init__(self, base, height):
            self.base = base
            self.height = height
    
        def area(self):
            return 0.5 * self.base * self.height
    
    shapes = [Circle(1), Triangle(6, 5), Circle(2)]
    for s in shapes:
        print(f"{type(s).__name__:<9} {s.area():6.2f}")
    
    biggest = max(shapes, key=lambda s: s.area())
    print("Biggest:", type(biggest).__name__)
    Expected output
    Circle 3.14 Triangle 15.00 Circle 12.57 Biggest: Triangle

    max(shapes, key=lambda s: s.area()) calls area() on each shape polymorphically and returns the object with the largest result. Adding Triangle required no change to the loop or to max.

    Exercise 2 — A Vector2D Type

    Easy

    Task: write Vector2D(x, y) that supports +, -, multiplication by a number (both v * 2 and 2 * v), abs(v) for its length, ==, hashing, and a readable repr.

    Hint: abs(v) calls __abs__. Use math.hypot(x, y) for the length. Return NotImplemented for unsupported operand types.

    Show solution & explanation
    import math
    
    class Vector2D:
        def __init__(self, x, y):
            self.x = x
            self.y = y
    
        def __add__(self, other):
            if not isinstance(other, Vector2D):
                return NotImplemented
            return Vector2D(self.x + other.x, self.y + other.y)
    
        def __sub__(self, other):
            if not isinstance(other, Vector2D):
                return NotImplemented
            return Vector2D(self.x - other.x, self.y - other.y)
    
        def __mul__(self, k):
            if not isinstance(k, (int, float)):
                return NotImplemented
            return Vector2D(self.x * k, self.y * k)
    
        __rmul__ = __mul__
    
        def __abs__(self):
            return math.hypot(self.x, self.y)
    
        def __eq__(self, other):
            if not isinstance(other, Vector2D):
                return NotImplemented
            return (self.x, self.y) == (other.x, other.y)
    
        def __hash__(self):
            return hash((self.x, self.y))
    
        def __repr__(self):
            return f"Vector2D({self.x}, {self.y})"
    
    a = Vector2D(3, 4)
    b = Vector2D(1, 2)
    print(a + b)
    print(a - b)
    print(a * 2)
    print(2 * a)
    print(abs(a))
    print(a == Vector2D(3, 4))
    print({a, Vector2D(3, 4)})
    Expected output
    Vector2D(4, 6) Vector2D(2, 2) Vector2D(6, 8) Vector2D(6, 8) 5.0 True {Vector2D(3, 4)}

    The vector (3, 4) has length 5.0 — the classic 3-4-5 triangle. The set {a, Vector2D(3, 4)} contains just one element because the two vectors are equal and hash the same.

    Exercise 3 — A Duck-Typed Logger

    Easy

    Task: write log(writer, message) that works with anything having a write(text) method. Test it with your own ConsoleWriter, your own in-memory ListWriter, and Python's built-in io.StringIO — three unrelated classes.

    Hint: log should contain no isinstance and no mention of any writer class. Remember to end each message with "\n".

    Show solution & explanation
    import io
    
    class ConsoleWriter:
        def write(self, text):
            print(text, end="")
    
    class ListWriter:
        def __init__(self):
            self.lines = []
    
        def write(self, text):
            self.lines.append(text)
    
    def log(writer, message):
        writer.write(message + "\n")
    
    memory = ListWriter()
    buffer = io.StringIO()
    
    for writer in (ConsoleWriter(), memory, buffer):
        log(writer, "server started")
    
    print(memory.lines)
    print(repr(buffer.getvalue()))
    Expected output
    server started ['server started\n'] 'server started\n'

    io.StringIO was written by the Python core team years before your log function existed — yet it plugs in, because it honors the same informal interface.

    Exercise 4 — Kill the Ladder

    Medium

    Task: refactor the shipping calculator below into polymorphic classes. Then add a Poster that costs a flat 5.0 to ship — without editing any existing class.

    def shipping_cost(item):
        if item["kind"] == "book":
            return 3.0
        elif item["kind"] == "laptop":
            return 12.0 + item["weight"] * 1.5
        elif item["kind"] == "ebook":
            return 0.0
        raise ValueError("unknown kind")
    
    cart = [
        {"kind": "book"},
        {"kind": "laptop", "weight": 2},
        {"kind": "ebook"},
    ]
    print([shipping_cost(item) for item in cart])
    print(sum(shipping_cost(item) for item in cart))

    Hint: create an Item base class with shipping_cost(). Give each kind its own subclass; the laptop needs a weight attribute.

    Show solution & explanation
    class Item:
        def shipping_cost(self):
            raise NotImplementedError
    
    class Book(Item):
        def shipping_cost(self):
            return 3.0
    
    class Laptop(Item):
        def __init__(self, weight):
            self.weight = weight
    
        def shipping_cost(self):
            return 12.0 + self.weight * 1.5
    
    class EBook(Item):
        def shipping_cost(self):
            return 0.0
    
    class Poster(Item):
        def shipping_cost(self):
            return 5.0
    
    cart = [Book(), Laptop(2), EBook(), Poster()]
    print([item.shipping_cost() for item in cart])
    print(sum(item.shipping_cost() for item in cart))
    Expected output
    [3.0, 15.0, 0.0, 5.0] 23.0

    Adding Poster touched no existing code: 3.0 + 15.0 + 0.0 + 5.0 = 23.0.

    Exercise 5 — A Recursive to_text

    Medium

    Task: using @singledispatch, write to_text(value) that turns nested data into readable text: booleans become yes / no, lists become [a, b], dicts become {key: value}, and everything else uses str(). It must handle nesting.

    Hint: register bool separately (it is a subclass of int!). Inside the list and dict versions, call to_text again on each element.

    Show solution & explanation
    from functools import singledispatch
    
    @singledispatch
    def to_text(value):
        return str(value)
    
    @to_text.register
    def _(value: bool):
        return "yes" if value else "no"
    
    @to_text.register
    def _(value: list):
        return "[" + ", ".join(to_text(v) for v in value) + "]"
    
    @to_text.register
    def _(value: dict):
        pairs = (f"{k}: {to_text(v)}" for k, v in value.items())
        return "{" + ", ".join(pairs) + "}"
    
    data = {"name": "Ana", "active": True, "scores": [90, 85.5, False]}
    print(to_text(data))
    Expected output
    {name: Ana, active: yes, scores: [90, 85.5, no]}

    Because we registered bool explicitly, False inside the list also becomes no instead of 0.

    Exercise 6 — Debug the Broken Temperature

    Medium

    Task: the program below has several bugs. Run it, read each error, fix it, and run again until all three lines print sensible results. Expect more than one bug.

    class Temperature:
        def __init__(self, degrees):
            self.degrees = degrees
    
        def __eq__(self, other):
            return self.degrees == other.degrees
    
    temps = [Temperature(30), Temperature(12), Temperature(21)]
    print(sorted(temps))
    print(Temperature(5) == 5)
    print(len({Temperature(5), Temperature(5)}))

    Hint: the first error is TypeError: '<' not supported…. After fixing it, ask: what does the printed list look like? What happens when other is an int? What happens when you put the object in a set?

    Show solution & explanation
    from functools import total_ordering
    
    @total_ordering
    class Temperature:
        def __init__(self, degrees):
            self.degrees = degrees
    
        def __eq__(self, other):
            if not isinstance(other, Temperature):
                return NotImplemented
            return self.degrees == other.degrees
    
        def __lt__(self, other):
            if not isinstance(other, Temperature):
                return NotImplemented
            return self.degrees < other.degrees
    
        def __hash__(self):
            return hash(self.degrees)
    
        def __repr__(self):
            return f"Temperature({self.degrees})"
    
    temps = [Temperature(30), Temperature(12), Temperature(21)]
    print(sorted(temps))
    print(Temperature(5) == 5)
    print(len({Temperature(5), Temperature(5)}))
    Expected output
    [Temperature(12), Temperature(21), Temperature(30)] False 1

    Bug 1: no __lt__, so sorted() raised TypeError. Bug 2: no __repr__, so the list would print <__main__.Temperature object at 0x…>. Bug 3: __eq__ assumed other had .degrees; returning NotImplemented lets Python fall back to "not equal". Bug 4: defining __eq__ removed __hash__, so the set raised TypeError: unhashable type.

    Free-Form Python Playground

    Write and run any Python code right here — no installation. Use it to solve the exercises yourself, try the "Try it yourself" experiments, or just explore polymorphism interactively. The runtime loads once and stays cached.

    🐍 playground.py
    Press Run to execute your code.

    Try these challenges: (1) implement a Vector2D with __add__ and __abs__; (2) build a Shape class hierarchy and call area() polymorphically; (3) replace an isinstance ladder with a set of subclasses; (4) write a duck-typed log() function and test it with io.StringIO.

    The Polymorphism Lab

    Add shapes and watch the same loop — shape.draw(), shape.area(), shape.perimeter() — produce different results for each object. The trace panel shows which class's method answered every call. Then press Register Hexagon: a brand-new class joins at runtime and the drawing loop does not change by a single line.

    🌀 Shape Lab · 0 / 8 shapes Total area: 0.0
    Add a shape to begin.

    What to notice: the render function only ever calls draw(), area() and perimeter(). It never checks what kind of shape it holds. The abstract Shape refuses to be created (⚠ Try new Shape()), exactly like an ABC.

    Advertisement

    Where Polymorphism Shows Up

    Polymorphism is the engine behind most classic design patterns and everyday Python libraries.

    ♟️

    Strategy

    Swap algorithms (fee rules, sorting keys, pricing) behind one method name. Program 5 is a Strategy in disguise.

    📐

    Template Method

    A base method fixes the steps and calls overridable hooks. describe() and checkout() both work this way.

    🔌

    Plugins & Adapters

    Any object with the right methods plugs in — payment gateways, storage backends, notification channels.

    🧪

    Test Doubles

    A fake write() object replaces a real file in tests. Duck typing makes mocking easy.

    📄

    File-like Objects

    Files, StringIO, sockets and HTTP responses all share read() / write().

    ⚖️

    Liskov Substitution

    The rule that keeps polymorphism safe: a subclass must behave well anywhere its parent is accepted.

    Best Practices for Polymorphic Design

    Habits That Keep It Safe

    ✓ DO: Keep overrides call-compatible. Same name, same parameters, same kind of return value. A caller using the parent type must not be surprised.

    ✓ DO: Call super() when extending. If you add behavior rather than replace it, delegate the shared part to the parent.

    ✓ DO: Return NotImplemented from operator methods when the other operand's type isn't supported.

    ✓ DO: Define __repr__ for every class. Debugging polymorphic collections is miserable when every item prints as <object at 0x…>.

    ✓ DO: Write the duck-typed contract down with a Protocol or a docstring, so other developers know what write() or speak() must do.

    ✗ DON'T: Build isinstance ladders to choose behavior. If you control the classes, move the behavior into them.

    ✗ DON'T: Override to change the meaning. A ReadOnlyList.append() that raises breaks every caller that expects a list. If a subclass can't honor the parent's promise, it probably shouldn't be a subclass.

    ✗ DON'T: Wrap big blocks in except AttributeError. Duck-typing failures and genuine bugs look identical when the net is too wide.

    ✗ DON'T: Overload operators for unexpected meanings. + should add, combine or concatenate — not send, delete or save.

    ✗ DON'T: Forget the __eq__ / __hash__ pair. Equal objects must hash equally, or sets and dicts misbehave.

    Polymorphism Cheat Sheet

    🧭 Choose the Flavor

    NeedUse
    Related types, shared codeOverriding
    Unrelated types, one functionDuck typing
    Natural syntax for my classDunder methods
    Per-type behavior, can't edit class@singledispatch
    Written-down duck contractProtocol / ABC

    ➕ Common Dunders

    MethodHooks into
    __add__ / __radd__a + b
    __mul__ / __rmul__a * b
    __eq__ / __hash__== , set, dict key
    __lt__< , sorted(), max()
    __len__ / __iter__len(), for, in
    __getitem__x[i]
    __repr__ / __str__debug / print
    __bool__ / __call__if x / x()

    ⚠️ Common Pitfalls

    MistakeFix
    Raise TypeError in __add__return NotImplemented
    __eq__ without __hash__Define both
    True dispatches as intRegister bool
    Changed override signatureMatch the parent
    NotImplementedError vs NotImplementedRaise vs return
    Defining a function twiceUse singledispatch

    🧰 Handy Tools

    ToolPurpose
    super()Call the parent's version
    Cls.__mro__See the lookup order
    @total_orderingDerive all comparisons
    @singledispatchDispatch on 1st arg type
    typing.ProtocolDescribe a duck interface
    isinstance(x, Proto)With @runtime_checkable

    Quick Knowledge Check

    Five questions on the ideas in this guide.

    0/5

    Great effort — review any missed questions above.

    Polymorphism in Action — 10 Live Projects

    Every project below is a working app built around the same idea you just learned: one call site, many object types, each answering in its own way. Unlike a generic OOP demo, each of these was designed to expose a specific flavor of polymorphism — method overriding, duck typing, operator overloading, or single dispatch — so you can open it and see exactly how that flavor works in a real codebase.

    Shape Renderer

    A mini vector editor. Circle, Square, Triangle and FreeformPath all extend Shape and override draw(), area() and perimeter(). One canvas loop renders them all.

    🎨 Overriding Watch: shape.draw(ctx) hit four different classes
    Image Filter Plugin

    Blur, Grayscale, Sharpen, Sepia — four unrelated classes with a single apply(pixel) method. The pipeline never imports them by name; it just calls .apply() on whatever the plugin registry hands it.

    🦆 Duck Typing Watch: drop a new filter file in plugins/ — pipeline works unchanged
    Currency Converter

    A live Money class with __add__, __mul__, __eq__, __lt__ and __repr__. The whole app is arithmetic on Money objects — no helper functions, no .add(), no .multiply().

    ➕ Operators Watch: price * qty + tax run entirely through dunder methods
    Recipe Formatter

    A recipe holds ingredients of many shapes — int, float, Fraction, str ("a pinch"), Money. One format_quantity() function uses @singledispatch to render each correctly.

    🎯 Single Dispatch Watch: one call site, six registered type branches
    Media Player

    Songs, podcasts, audiobooks and video clips are all MediaItem subclasses. The player calls play(), pause() and duration() — each item decides what those mean.

    🎵 Overriding Watch: one transport bar, four media kinds
    Test-Double Lab

    A miniature testing playground. Real file, fake file, flaky file, slow file — all share the same read() / write() methods. The app under test never imports a real filesystem.

    🦆 Duck Typing Watch: swap the file backend in one line of config
    Double-Entry Ledger

    Every journal entry is a Debit or Credit object that supports __neg__, __add__, __eq__ and __hash__. Balancing a ledger is literally sum(entries) == 0.

    ➕ Operators Watch: account totals computed with +, not .total()
    Log Router

    A logging system where emit(message) dispatches on the message type: str → plain text, dict → JSON, Exception → traceback, bytes → base64. One function, many formats.

    🎯 Single Dispatch Watch: register a new format at runtime, no edits to emit
    Chess Pieces

    Every piece is a Piece subclass overriding legal_moves() and symbol(). The board loop asks each piece for its legal moves — the knight, bishop, queen each reply differently.

    ♟️ Overriding Watch: one move validator, six piece types
    Protocol Checker

    Paste any class and see live which typing.Protocol interfaces it satisfies — Iterable, Sized, Comparable, Hashable. Built with @runtime_checkable protocols.

    🦆 Duck Typing Watch: isinstance(x, Protocol) succeed without inheritance

    A good exercise: open any of these projects and answer three questions. (1) Which of the four flavors is being used? (2) Where is the single call site that handles many object types? (3) Add a new type — how many existing lines did you have to edit? If the answer to (3) is zero, the project was built correctly.

    Wrapping Up

    🏁 What You've Learned

    You met polymorphism in all four of its Python forms — overriding, duck typing, operator overloading and single dispatch — and saw the same payoff each time: callers stop asking "what are you?" and simply ask the object to do its job.

    You also practiced the safety rules that keep it healthy: keep overrides compatible, return NotImplemented from operators, pair __eq__ with __hash__, and keep except blocks narrow. Write to the interface, not the implementation.

    4 flavors of polymorphism 5 programs · 6 exercises Interactive playground & lab 10 live projects to explore

    Key Takeaways

    • Polymorphism = one call, many behaviors — the receiving object decides what happens.
    • Overriding works through the MRO: Python searches from the object's real class upward.
    • Duck typing needs only matching method names — no shared parent — but fails late, so keep try blocks small.
    • Dunder methods make your classes work with +, ==, len(), for and sorted().
    • @singledispatch adds per-type behavior when you can't edit the class — remember bool is an int.
    • Replace isinstance ladders with methods, and new types arrive without touching old code.

    ✨ Four pillars, one idea: hide the details, share the contract, reuse the code — and let every object answer in its own voice.

    — Glenn Junsay Pansensoy, Poetic Bytes

    Return to Home Main Hub Back to Top

    Share This Project

    Subscribe to Poetic Bytes

    Level up your Python knowledge.

    In-depth Python tutorials, AI insights, and software engineering guides — delivered straight to your inbox. No spam, ever.

    Weekly Python tutorials AI & ML deep-dives OOP series updates Free, cancel anytime

    🔒 Your email is never shared or sold.

    Follow Poetic Bytes

    Stay connected for Python tips, OOP insights, and new project walkthroughs.

    Advertisement