Polymorphism and Duck Typing
15 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)In statically typed languages like Java, polymorphism requires explicit interfaces (implements Serializable). In Python, polymorphism is governed by Duck Typing: "If it walks like a duck and quacks like a duck, it’s a duck." An object’s capability matters, not its explicit inheritance pedigree.
What is Duck Typing?
Duck typing means Python cares what an object can do, not what class it has. If it has the method you call, it works. If it does not, you get an AttributeError at that moment.
A function does not care what class an object belongs to, only that it has the required method or attribute.
class PDFReport: def export(self): return "Exporting PDF document..."
class CSVReport: def export(self): return "Exporting CSV spreadsheet..."
def generate(report): # report does not need to inherit from any BaseReport class! print(report.export())Example 1 · Unrelated classes, one loop
class Courier: def cost(self, kg): return 50 + 20 * kg
class Freight: def cost(self, kg): return 400 + 5 * kg
class Pickup: def cost(self, kg): return 0
for carrier in (Courier(), Freight(), Pickup()): print(type(carrier).__name__, carrier.cost(10))Courier 250 Freight 450 Pickup 0
What this code does
- None of the three classes shares a parent.
- The loop works because each one has a
cost(kg)method.
Both classes implement export(). generate() works seamlessly with any future report type with zero inheritance needed.
EAFP vs LBYL in Python
EAFP stands for "Easier to Ask Forgiveness than Permission". You run the operation inside try and handle the exception, instead of checking every condition first (LBYL, "Look Before You Leap").
Python culture strongly favors EAFP: "Easier to Ask for Forgiveness than Permission" over LBYL: "Look Before You Leap".
# LBYL (Cautious check first)def play_lbyl(sound): if hasattr(sound, "play"): sound.play()
# EAFP (Pythonic: try it and handle exception)def play_eafp(sound): try: sound.play() except AttributeError: print("Object cannot be played")Example 2 · EAFP handles cases a check misses
def parse_lbyl(text): if text.isdigit(): return int(text) return None
def parse_eafp(text): try: return int(text) except ValueError: return None
for text in ["12", "-5", "abc"]: print(text, parse_lbyl(text), parse_eafp(text))12 12 12 -5 None -5 abc None None
What this code does
"-5".isdigit()isFalse, so the check rejects a valid number.- Trying
int(text)and handlingValueErrorlets Python decide what is valid.
Example 3 · A try block can hide a real bug
class Broken: def render(self): return self.title.upper()
def show(item): try: return item.render() except AttributeError: return "Incompatible"
print(show(Broken()))Incompatible
What this code does
Brokendoes haverender(). The bug is inside it:self.titlewas never set.- The AttributeError from the bug is caught too, so the message blames the wrong thing. Keep the try block small.
EAFP avoids race conditions (e.g. file exists checks) and is faster when exceptions are rare.
Inspecting Capabilities at Runtime
You can check or access attributes dynamically using built-in functions.
class Report: def render(self): return "Report ready"
def run(self): return self.render()
obj = Report()print(hasattr(obj, "render")) # True: the method existsprint(callable(getattr(obj, "run"))) # True: the attribute is executableExample 4 · Optional behaviour with getattr
class Backup: def run(self): return "backup done"
class Report: def run(self): return "report ready"
def on_finish(self, result): print("hook got:", result)
def execute(task): result = task.run() hook = getattr(task, "on_finish", None) if callable(hook): hook(result) return result
print(execute(Backup()))print(execute(Report()))backup done hook got: report ready report ready
What this code does
getattr(task, "on_finish", None)returnsNonewhen the hook does not exist, so nothing is raised.- Only
Reporthas the hook, so only it prints the extra line.
getattr(obj, "attr", default) lets you provide safe fallbacks if an attribute is missing.
Duck Typing Across the Standard Library
Built-in tools ask for behaviour, not for a type. Any object with a read() method can be used as a file, so code written for files also works on text held in memory.
import io
def count_lines(source): return len(source.read().splitlines())
class FakeFile: def read(self): return "one\ntwo"
print(count_lines(io.StringIO("a\nb\nc")))print(count_lines(FakeFile()))Example 5 · One checkout, any payment method
class CreditCard: def pay(self, amount): return f"Paid Rs. {amount} via Credit Card"
class UPI: def pay(self, amount): return f"Paid Rs. {amount} via UPI"
def checkout(payment_method, total): return payment_method.pay(total)
print(checkout(CreditCard(), 1200))print(checkout(UPI(), 550))Paid Rs. 1200 via Credit Card Paid Rs. 550 via UPI
What this code does
checkoutaccepts any payment handler providing apay(amount)method.- No abstract interfaces or rigid class hierarchies required.
count_lines never checks a type. A real file, a StringIO and a class written in five lines all work, because each has read().
Next step
Check your understanding
11 quick questions