Codetail

Article 5 of 8

Polymorphism and Duck Typing

Same call, different behavior. Python's way.

18 min read

Same call, different behavior

Polymorphism sounds academic. The idea is not. You are using it every time you call len() on different things:

Python
1print(len("hello")) # 5 -- counts characters
2print(len([1, 2, 3])) # 3 -- counts elements
3print(len({"a": 1})) # 1 -- counts keys

The same function call (len(x)) does something different depending on what x is. You do not write string_len() and list_len() separately. One name, behavior determined by the object.

The word comes from Greek: poly (many) morphism (forms). The same interface, many implementations. The caller does not need to know or care which implementation is running.

Python
1class Dog:
2 def speak(self):
3 return "Woof!"
4
5class Cat:
6 def speak(self):
7 return "Meow."
8
9class Duck:
10 def speak(self):
11 return "Quack."
12
13# This function works with any object that has .speak()
14def make_noise(animal):
15 print(animal.speak())
16
17make_noise(Dog()) # Woof!
18make_noise(Cat()) # Meow.
19make_noise(Duck()) # Quack.

make_noise() never asks what type it received. It just calls speak(). The right thing happens because each class knows what speak() means for it. That is polymorphism.

Duck typing: Python's approach

In Java, for polymorphism to work, your classes must share a common parent or implement a declared interface. Python does not require that. It asks a simpler question: does this object have the method I want to call?

The name comes from a saying: "If it walks like a duck and quacks like a duck, treat it as a duck." Python does not care what class an object belongs to. It cares whether the object can do the thing you are asking it to do.

Python
1class Dog:
2 def speak(self):
3 return "Woof!"
4
5class Robot:
6 def speak(self):
7 return "Bzzzt... hello, human."
8
9class TrafficLight:
10 def speak(self):
11 return "Please stop."
12
13def make_noise(thing):
14 print(thing.speak())
15
16# None of these share a parent class or interface
17# Python only cares that they all have .speak()
18make_noise(Dog())
19make_noise(Robot())
20make_noise(TrafficLight())

A Dog, a Robot, and a TrafficLight have nothing in common except a speak() method. That is all make_noise() needs.

If the object does not have the method, you get an AttributeError at the point of the call. Python does not check ahead of time. This is the trade-off: you get flexibility, you lose early detection. Abstract base classes (covered next) can give you earlier errors when you need them.

Duck typing is why Python code is often more flexible than equivalent Java or C++ code. You can write a function that accepts any object that satisfies an informal interface, without declaring anything upfront.

Method overriding

When a subclass defines a method that already exists on the parent, the subclass version replaces the parent's version for instances of that subclass. This is method overriding.

Python
1class Shape:
2 def area(self):
3 return 0
4
5 def describe(self):
6 return f"I am a {type(self).__name__} with area {self.area():.2f}"
7
8class Circle(Shape):
9 def __init__(self, radius):
10 self.radius = radius
11
12 def area(self): # overrides Shape.area
13 import math
14 return math.pi * self.radius ** 2
15
16class Rectangle(Shape):
17 def __init__(self, w, h):
18 self.w, self.h = w, h
19
20 def area(self): # overrides Shape.area
21 return self.w * self.h
22
23shapes = [Circle(5), Rectangle(3, 4)]
24for s in shapes:
25 print(s.describe()) # describe() is inherited, area() is overridden

Notice describe() is defined once on Shape and calls self.area(). When called on a Circle, self.area() dispatches to Circle.area(), not Shape.area(). The parent's method benefits from the child's override. This is called dynamic dispatch, and it is how polymorphism works in practice.

isinstance checks in business logic are a warning sign

isinstance() has legitimate uses: validating input at function boundaries, checking types before an operation that is genuinely type-specific. But when you see it in the middle of business logic, it is almost always a sign that polymorphism should be doing that work instead.

Python
1# Wrong: type-checking in business logic
2def process_payment(payment):
3 if isinstance(payment, CreditCard):
4 charge_credit_card(payment)
5 elif isinstance(payment, BankTransfer):
6 initiate_transfer(payment)
7 elif isinstance(payment, Crypto):
8 broadcast_transaction(payment)
9 # Every time you add a payment type, you edit this function.
10 # It never ends.

Every new payment type means editing process_payment(). The logic that belongs inside each payment type lives outside it. The fix is to put the behavior on the object:

Python
1class CreditCard:
2 def process(self):
3 charge_credit_card(self)
4
5class BankTransfer:
6 def process(self):
7 initiate_transfer(self)
8
9class Crypto:
10 def process(self):
11 broadcast_transaction(self)
12
13# Now process_payment never changes, regardless of new types
14def process_payment(payment):
15 payment.process()
16
17# Adding PayPal? Write PayPal.process(), done.
18# process_payment() stays the same.

The second version follows the Open-Closed Principle: the process_payment() function is open for extension (add new payment types) but closed for modification (you never touch it again). The isinstance version is the opposite.

When you find yourself writing if isinstance(x, A): ... elif isinstance(x, B): ..., ask: can each class implement this behavior itself? Usually yes.