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.
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.
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.hSmell: 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
| Term | Plain-English meaning | Where you'll see it |
|---|---|---|
| Polymorphism | One call, many behaviors — chosen by the receiving object. | Everywhere on this page |
| Method overriding | A subclass supplies its own version of an inherited method. | Circle.area |
| Dynamic dispatch | Python picks which method to run by looking at the object's actual type at runtime. | s.area() in a loop |
| MRO | Method 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 |
| Protocol | A typing tool that describes a duck-typed interface without inheritance. | Speaker |
| Dunder method | A "double underscore" hook such as __add__ or __len__ that Python calls for you. | Money, Playlist |
| Operator overloading | Making +, ==, < work for your own classes via dunder methods. | price + tax |
NotImplemented | A sentinel value an operator method returns to say "I don't know how — ask the other side." It is not an exception. | __add__ |
| Single dispatch | Choosing a function implementation by the type of its first argument. | @singledispatch |
| Liskov Substitution | A 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)- Python searches the MRO in order. For
B().hello()it checksB(nothing), thenA(found). ForC().hello()it stops atC— the override wins. - The loop never changes.
obj.hello()is one expression that ran two different functions. That is polymorphism in its smallest form. - The last line proves it with identity checks:
B.hello is A.helloisTrue(inherited, same function object), whileC.hello is A.helloisFalse(overridden).
Error Message Decoder
You will meet these while experimenting. Each points at a specific polymorphism mistake.
| Message | What it means | Fix |
|---|---|---|
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. |
NotImplementedError | A 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 type | You 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?
| Flavor | How it works | Use it when… | Program |
|---|---|---|---|
| 🧬 Method overriding | Subclasses replace an inherited method; Python dispatches on the real type. | Types share a family and some shared code. | Program 1 |
| 🦆 Duck typing | Any object with the right methods works. No base class needed. | Unrelated types should plug into the same function. | Program 2 |
| ➕ Operator overloading | Dunder 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)🔍 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 does | Result |
|---|---|---|
| 1 | Looks for describe on Square | Not there. |
| 2 | Moves up the MRO to Rectangle | Not there either. |
| 3 | Moves up to Shape | Found — runs Shape.describe with self = the square. |
| 4 | Inside it, self.area() — search restarts at Square | Not on Square; found on Rectangle. |
| 5 | Rectangle.area runs: 5 * 5 | 25 → 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
class Shape:with__init__(self, name)— stores a label every shape needs. Shared state lives once, in the parent.area()raisesNotImplementedError— a placeholder that says "subclasses must override me". It is a plain class (noABC), so you can still create aShape; the error only fires whenarea()is called. For enforcement at creation time, use an abstract base class.describe()— the shared, polymorphic method.{self.area():.2f}calls whicheverareathe object has and formats it to two decimals.Circle.__init__andsuper().__init__("Circle")— the subclass sets its own data (radius) and delegates the shared part to the parent. Skippingsuper()would leaveself.nameunset.Circle.area=math.pi * r ** 2— the override. Same name and signature as the parent's, different behavior.Rectangle(width, height, name="Rectangle")— the extranameparameter has a default so a subclass can relabel it. This is what letsSquarereuse the class cleanly.Square(Rectangle)— overrides nothing except the constructor: it callssuper().__init__(side, side, name="Square"). A square is a rectangle with equal sides, so it inheritsarea()as-is. Overriding is optional.class Blob(Shape): pass— a subclass that forgot to override. It exists to demonstrate the failure path.shapes = [Circle(2), Rectangle(3, 4), Square(5)]— a list holding three different classes. That works because they all promise the same interface.- The
forloop — one line,shape.describe(), three behaviors. Noisinstance, noif. sum(s.area() for s in shapes)— a generator expression callingarea()polymorphically: 12.57 + 12 + 25 = 49.57.- The final
try / except—Blob("Blob").describe()reachesShape.area, which raises. We catchNotImplementedErrorand 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()inCircleto append the radius, and callsuper().describe()inside it. What gets reused? - Change
Blobto inherit fromABCwith@abstractmethodonarea. 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))🔍 Deep dive — nominal vs structural typing
| Inheritance-based | Duck typing | |
|---|---|---|
| Question asked | "Are you a Shape?" | "Can you .speak()?" |
| Needs a shared parent | Yes | No |
| Checked when | At class definition (ABC) or call time | Only when the method is actually called |
| Great for | Families with shared code | Plugging unrelated or third-party types together |
| Risk | Deep hierarchies get rigid | A 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
Duck,Dog,Robot— three independent classes (each inherits only fromobject) with the same method name. Their only link is a promise made by convention.class Rock: pass— deliberately has nospeak. It is the control case.make_noise(thing)— justreturn thing.speak(). No type annotation, noisinstance. The function trusts the caller.- The first loop —
type(thing).__name__prints each class name;make_noiseworks unchanged for all three. make_noise(Rock())insidetry— the failure surfaces asAttributeError: 'Rock' object has no attribute 'speak'.safe_noise()— the EAFP style: try the call and handle the failure, instead of checking first.@runtime_checkable+class Speaker(Protocol)— a Protocol writes the duck-typing contract down: "anything withspeak() -> str".isinstance(Duck(), Speaker)— normallyisinstancechecks inheritance, but@runtime_checkablemakes it check "does this object have aspeakattribute?"
⚠️ 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
Rockaspeakthat returns"...". Does anything else need to change? - Add a
Catclass withspeakreturning"Meow"and add it to the list. How many lines elsewhere did you edit? - Make
speaka property on one class (not a method). What error appears whenmake_noisecalls 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)🔍 Deep dive — how Python translates each expression
| You write | Python actually tries | Outcome |
|---|---|---|
price + tax | price.__add__(tax) | Both are Money → new Money. |
price * 3 | price.__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
@total_ordering— a decorator fromfunctools. You write__eq__and__lt__; it fills in__le__,__gt__,__ge__.__init__rounds to 2 decimals — keeps display tidy. (Real money code should usedecimal.Decimal; floats keep this lesson simple.)_check(self, other)— a private helper shared by__add__and__lt__: mixing currencies raises a clearValueError.__add__— firstisinstance(other, Money); if not,return NotImplemented. Then check currency and return a newMoney.__mul__— acceptsintorfloatonly.Money * Moneymakes no sense, so it declines.__rmul__ = __mul__— multiplication is commutative here, so the reflected version reuses the same function. That makes2 * pricework.__eq__— compares(amount, currency)tuples.Money(10) == Money(10.0)isTrue.__lt__— the one ordering primitive@total_orderingneeds.__hash__— defining__eq__silently sets__hash__toNone, making objects unhashable. We restore it using the same fields as__eq__.__repr__vs__str__—repris for developers (shown in the sorted list);stris for users (printed withprint()).- The demo lines — 120.50 + 14.46 = 134.96;
price * 3= 361.50;2 * price= 241.00;sortedandmaxuse__lt__;sumwith aMoney(0)start gives 3.00. - The two error cases —
price + 5ends in a standardTypeErrorthat Python itself builds;Money(1) + Money(1, "USD")hits our ownValueError.
⚠️ 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 runprint(2 * price). Read the error. - Remove
@total_ordering, keep__lt__, then tryMoney(1) >= Money(2). - Add
__sub__that raisesValueErrorif the result would be negative. - Add
__bool__soMoney(0)is falsy. Then testif 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))🔍 Deep dive — protocols behind the built-ins
| Built-in syntax | Calls your method | Fallback if missing |
|---|---|---|
len(x) | x.__len__() | TypeError: object has no len() |
for item in x | x.__iter__() | Old-style __getitem__(0, 1, 2…), else TypeError |
item in x | x.__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
len(...)on four types — a string, list, dict andrangeall answerlen()differently (6, 3, 1, 10).+on numbers, strings, lists, tuples — addition, concatenation, list joining, tuple joining.*on number, string, list — multiply, repeat, repeat.Playlist.__init__(self, *songs)—*songscollects any number of positional arguments into a tuple; stored as a list.__len__— returns the count. That alone makeslen(mix)work and also makes an empty playlist falsy.__iter__— returns an iterator. This one method powerslist(mix),for,inandsorted().print(len(mix), list(mix), "Tides" in mix, sorted(mix))— four built-ins, one class, zero extra code.@singledispatch def describe(value)— the base function is the fallback used for any unregistered type.@describe.registerwith a type annotation — registers the function for the annotated type.@describe.register(list)— the explicit form, passing the type as an argument.- The final loop — each value is dispatched by its type.
- The last item,
True— printsInteger True (odd)!boolis a subclass ofint, so with noboolregistration 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__toPlaylistand trymix[0]andmix[1:]. - Add
__contains__that compares case-insensitively; test"tides" in mix. - Register a
floatversion ofdescribeand aboolversion. Check the output forTrueagain. - Delete
__len__and runlen(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))🔍 Deep dive — what changes when a new type arrives
| Task: support Maya | Ladder version | Polymorphic version |
|---|---|---|
| Files / functions to edit | fee_ladder — and every other ladder (receipts, refunds, reports…) | None. Write MayaPayment. |
| Forget one spot? | Silent wrong answer or late TypeError | Impossible — the class carries its own behavior |
| Test existing methods again? | Yes — you touched shared code | No — existing classes unchanged |
| Behavior lives… | Away from the data, in callers | With 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
Card,GCash,Cash(empty classes) — stand-ins that only exist to be tested withisinstance. They carry no behavior, which is the whole problem.fee_ladder— asks "what are you?" withisinstancethree times, then raisesTypeErrorfor anything unknown.class Maya: passand the failing call — a brand-new method arrives. The ladder has no branch for it.print("-" * 44)— a divider. Here*repeats a string.PaymentMethodbase class — declaresfee()(placeholder) and implementscheckout()once.- The format spec
{name:<14}and{amount + fee:>9,.2f}— aligns the columns of output. CardPayment,GCashPayment,CashPayment— each overrides onlyfee().MayaPayment— the "later" addition at 1.5%. Nothing above it changed.- 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
BankTransferwith a flat25.0fee. How many existing lines did you touch? - Add a second operation,
refund(amount), toPaymentMethod. In the ladder version you'd add another ladder — what do you do here? - Use
abc.ABC+@abstractmethodonfeeso 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-upTask: 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__)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
EasyTask: 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)})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
EasyTask: 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()))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
MediumTask: 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))Adding Poster touched no existing code: 3.0 + 15.0 + 0.0 + 5.0 = 23.0.
Exercise 5 — A Recursive to_text
MediumTask: 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))Because we registered bool explicitly, False inside the list also becomes no instead of 0.
Exercise 6 — Debug the Broken Temperature
MediumTask: 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)}))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.
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.
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.
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
| Need | Use |
|---|---|
| Related types, shared code | Overriding |
| Unrelated types, one function | Duck typing |
| Natural syntax for my class | Dunder methods |
| Per-type behavior, can't edit class | @singledispatch |
| Written-down duck contract | Protocol / ABC |
➕ Common Dunders
| Method | Hooks 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
| Mistake | Fix |
|---|---|
| Raise TypeError in __add__ | return NotImplemented |
| __eq__ without __hash__ | Define both |
| True dispatches as int | Register bool |
| Changed override signature | Match the parent |
| NotImplementedError vs NotImplemented | Raise vs return |
| Defining a function twice | Use singledispatch |
🧰 Handy Tools
| Tool | Purpose |
|---|---|
| super() | Call the parent's version |
| Cls.__mro__ | See the lookup order |
| @total_ordering | Derive all comparisons |
| @singledispatch | Dispatch on 1st arg type |
| typing.Protocol | Describe a duck interface |
| isinstance(x, Proto) | With @runtime_checkable |
Quick Knowledge Check
Five questions on the ideas in this guide.
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.
A mini vector editor. Circle, Square, Triangle and FreeformPath all extend Shape and override draw(), area() and perimeter(). One canvas loop renders them all.
shape.draw(ctx) hit four different classes
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.
plugins/ — pipeline works unchanged
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().
price * qty + tax run entirely through dunder methods
A recipe holds ingredients of many shapes — int, float, Fraction, str ("a pinch"), Money. One format_quantity() function uses @singledispatch to render each correctly.
Songs, podcasts, audiobooks and video clips are all MediaItem subclasses. The player calls play(), pause() and duration() — each item decides what those mean.
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.
Every journal entry is a Debit or Credit object that supports __neg__, __add__, __eq__ and __hash__. Balancing a ledger is literally sum(entries) == 0.
+, not .total()
A logging system where emit(message) dispatches on the message type: str → plain text, dict → JSON, Exception → traceback, bytes → base64. One function, many formats.
emit
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.
Paste any class and see live which typing.Protocol interfaces it satisfies — Iterable, Sized, Comparable, Hashable. Built with @runtime_checkable protocols.
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.
Explore the Full OOP Series
Polymorphism is where the four pillars come together. Revisit any of them, or review how they combine.
Bundle data with methods. Control access. Protect state. The guardian of object integrity.
Explore →Define the contract polymorphism relies on. Hide complexity, expose only what is necessary.
Explore →Build on existing classes. Reuse code elegantly. The family tree that overriding runs on.
Explore → CurrentOne interface, many forms. Duck typing. Write code that adapts to any conforming object.
You are here →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.
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
tryblocks small. - Dunder methods make your classes work with
+,==,len(),forandsorted(). @singledispatchadds per-type behavior when you can't edit the class — rememberboolis anint.- Replace
isinstanceladders 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
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.
🔒 Your email is never shared or sold.