Learning Goals
3 minBy the end of this lesson you can:
- Define polymorphism: same interface, different implementations.
- Write a loop that works on any object with the right method name.
- Explain "duck typing" — if it walks and talks like a duck, treat it as a duck.
- Use a common parent class to make polymorphism explicit.
Warm-Up · You've Already Used It
5 minEvery for x in something: loop is polymorphic. The same loop works for a list, a string, a dict, a set, a file — because they all support iteration.
for ch in "hello": print(ch) for n in [1, 2, 3]: print(n) for line in open("x.txt"): print(line)
Three completely different types. Same syntax. That's polymorphism — and Python relies on it everywhere.
If your code calls x.method(), it doesn't care what kind of x is. As long as x has that method, the code works. Different classes can implement the same method differently — and the calling code stays generic.
New Concept · Two Flavours of Polymorphism
14 min1 · Inheritance polymorphism
The classic shape. All children share a parent; each overrides a method.
class Animal: def speak(self): raise NotImplementedError("Subclasses must override speak()") class Dog(Animal): def speak(self): print("Woof!") class Cat(Animal): def speak(self): print("Meow.") class Cow(Animal): def speak(self): print("Moo.") zoo = [Dog(), Cat(), Cow()] for a in zoo: a.speak() # Python picks the right speak() automatically
The loop says a.speak(). Python looks at a's class, finds the right method. Three different printouts from the same line of code.
The parent's raise NotImplementedError trick is a way to say "every child must override this" — if someone forgets, they get a clear error instead of mysterious behaviour.
2 · Duck typing — no parent needed
Python doesn't even require a common parent. Anything with the right method works.
class Duck: def speak(self): print("Quack!") class Person: def speak(self): print("Hello!") class Doorbell: def speak(self): print("Ding-dong.") # No inheritance at all — but they all have .speak() things = [Duck(), Person(), Doorbell()] for t in things: t.speak()
This is "duck typing" — the famous Python proverb: If it walks like a duck and quacks like a duck, treat it as a duck. The loop only cares that t.speak() works.
For small programs, this is wonderfully flexible. For big ones, sharing a parent class makes the contract explicit — "everything in this list is an Animal".
3 · Polymorphism with built-ins
Many builtins use polymorphism. len(x) works for any x with a __len__ method:
len("hi") # → 2 (str.__len__) len([1, 2, 3]) # → 3 (list.__len__) len({1: "a"}) # → 1 (dict.__len__) class Box: def __init__(self, items): self.items = items def __len__(self): return len(self.items) len(Box([1, 2, 3, 4])) # → 4 — works on your class too!
Same len() function. Different __len__ implementations. You can make your own class "len-able" just by defining __len__ — Python doesn't care about ancestry. We'll meet this trick properly in PY-L3-16.
The benefit · loop once, scale forever
Add a new Animal? The zoo loop doesn't change.
class Sheep(Animal): def speak(self): print("Baa.") zoo.append(Sheep()) for a in zoo: a.speak() # same loop — handles Sheep now too
This is how big software grows — add new types without touching old code. The opposite is the "giant if-elif chain":
# ❌ Not polymorphism — fragile for a in zoo: if isinstance(a, Dog): print("Woof!") elif isinstance(a, Cat): print("Meow.") elif isinstance(a, Cow): print("Moo.") elif isinstance(a, Sheep): print("Baa.") # ... and on, and on, every new animal needs a new branch
If you ever catch yourself writing a chain of isinstance checks, polymorphism is usually the fix.
Worked Example · The Notification System
12 minA realistic example. Save as notify.py:
# notify.py — different notification channels, same interface class Channel: """A channel that can send a notification. Children override .send().""" def send(self, message): raise NotImplementedError class EmailChannel(Channel): def __init__(self, address): self.address = address def send(self, message): print(f" 📧 Email to {self.address}: {message}") class SMSChannel(Channel): def __init__(self, phone): self.phone = phone def send(self, message): print(f" 📱 SMS to {self.phone}: {message[:160]}") class PushChannel(Channel): def __init__(self, device_id): self.device_id = device_id def send(self, message): print(f" 🔔 Push to device {self.device_id}: {message}") class LoggingChannel(Channel): def send(self, message): from datetime import datetime ts = datetime.now().isoformat(timespec="seconds") print(f" 📝 [{ts}] LOG: {message}") def notify_all(channels, message): """Send the same message to every channel. Polymorphic by design.""" for ch in channels: ch.send(message) # The user's preferred channels — could be any mix of types my_channels = [ EmailChannel("aisyah@example.com"), SMSChannel("012-3456789"), PushChannel("device-42"), LoggingChannel(), ] notify_all(my_channels, "Your order has shipped!")
Output
📧 Email to aisyah@example.com: Your order has shipped! 📱 SMS to 012-3456789: Your order has shipped! 🔔 Push to device device-42: Your order has shipped! 📝 [22:31:04] LOG: Your order has shipped!
Read the diff
notify_all is six lines and knows nothing about email, SMS, push or logs. It only knows that every channel has a send method. Tomorrow you can add Slack, WhatsApp, or a Discord webhook — define the class, override send, and the same notify_all handles it. That's the design power. Every big system uses this shape.
Try It Yourself
13 minBuild three classes — Dog, Robot, Ghost — each with a speak() method that prints something different. Loop them in a list. No parent class.
Hint
class Dog: def speak(self): print("Woof!") class Robot: def speak(self): print("BEEP BOOP") class Ghost: def speak(self): print("oOoOo...") for thing in [Dog(), Robot(), Ghost()]: thing.speak()
Pure duck typing. Three unrelated classes; the loop doesn't care.
Add SlackChannel to the notify example. Pass a channel name like "#alerts". Update my_channels and re-run notify_all.
Hint
class SlackChannel(Channel): def __init__(self, channel): self.channel = channel def send(self, message): print(f" 💬 Slack {self.channel}: {message}") my_channels.append(SlackChannel("#alerts")) notify_all(my_channels, "All systems green!")
The new class plugs in without changing any other code. That's the polymorphism payoff.
Build three classes — Circle, Square, Triangle — each with an area() method. Write a function total_area(shapes) that sums every shape's area.
Hint
import math class Circle: def __init__(self, r): self.r = r def area(self): return math.pi * self.r * self.r class Square: def __init__(self, s): self.s = s def area(self): return self.s * self.s class Triangle: def __init__(self, b, h): self.b = b; self.h = h def area(self): return 0.5 * self.b * self.h def total_area(shapes): return sum(s.area() for s in shapes) print(total_area([Circle(3), Square(4), Triangle(5, 6)]))
One sum function. Three shape classes. The function doesn't care which is which.
Mini-Challenge · The Renderer
8 minBuild render.py. Imagine a tiny report system. Three classes that all have a render() method:
TextLine(content)— prints the content plainly.Heading(content, level)— prints with#markers — level 1 =# Title, level 2 =## Subtitle.BulletList(items)— prints each item with a-bullet.
Build a list of mixed instances representing a small report. Loop them, call render() on each.
Show one possible solution
# render.py — three render shapes, one loop class TextLine: def __init__(self, content): self.content = content def render(self): print(self.content) class Heading: def __init__(self, content, level=1): self.content = content self.level = level def render(self): print("#" * self.level + " " + self.content) class BulletList: def __init__(self, items): self.items = items def render(self): for item in self.items: print(f"- {item}") report = [ Heading("Class Marks Report", level=1), TextLine("Generated by jot."), TextLine(""), Heading("Top three students", level=2), BulletList(["Priya — 95", "Aisyah — 92", "Aizat — 81"]), Heading("Action items", level=2), BulletList(["Re-test Iman", "Reward top-three"]), ] for item in report: item.render()
Non-negotiables: three classes, no inheritance needed, one loop that calls .render(). Adding a new render type (a Table, a CodeBlock, a Quote) is the same shape — define a class with render, drop it in the list.
Recap
3 minPolymorphism is "same method name, different classes". Code that calls x.method() works for any x that has the method — regardless of class. Two flavours: inheritance polymorphism uses a shared parent (explicit contract); duck typing doesn't need a parent (flexible, Pythonic). Both let you write code that scales: add a new class, no caller changes. If you ever find yourself writing a chain of isinstance checks, replace it with a method on each class.
Vocabulary Card
- polymorphism
- Same method name, different implementations across classes. Caller code stays generic.
- duck typing
- Python's idea: if it has the method, it's acceptable. No formal interface needed.
- NotImplementedError
- What a parent class raises in a method to force subclasses to override.
- isinstance chain
- An anti-pattern — long if/elif on object types. Almost always replaceable with polymorphism.
Homework
4 minBuild payment.py. A Payment parent class with process(amount) that raises NotImplementedError. Three children — CashPayment, CardPayment, EwalletPayment — each override process with a different message.
Write a function checkout(payments, total) that splits total evenly across all payments and calls process on each.
Sample · payment.py
class Payment: def process(self, amount): raise NotImplementedError class CashPayment(Payment): def process(self, amount): print(f" 💵 Cash: paid RM {amount:.2f} on the spot.") class CardPayment(Payment): def __init__(self, last4): self.last4 = last4 def process(self, amount): print(f" 💳 Card ending {self.last4}: charged RM {amount:.2f}.") class EwalletPayment(Payment): def __init__(self, wallet): self.wallet = wallet def process(self, amount): print(f" 📲 {self.wallet}: paid RM {amount:.2f}.") def checkout(payments, total): share = total / len(payments) print(f"Total RM {total:.2f}, split {len(payments)} ways (RM {share:.2f} each):") for p in payments: p.process(share) checkout([ CashPayment(), CardPayment("4242"), EwalletPayment("Touch'n Go"), ], total=120)
Non-negotiables: a Payment parent with the unimplemented method, three children with distinct processes, and a checkout that splits the total polymorphically. The parent doesn't need ANY code — it just signals "every child must implement this".