What you'll learn
- Write code that calls the same method on different classes and gets different behavior
- Explain duck typing: Python cares whether an object has the method, not what class it is
- Replace
isinstancechains with a shared method name - Know when to check types anyway, and how to do it politely
Concept
Polymorphism means "many shapes": one piece of code works with objects of different classes because they all answer to the same method name. A loop that calls shape.area() does not care whether each shape is a circle or a square; each class provides its own area, and the right one runs. The caller is written once and never changes when a new shape appears. That is the payoff of the previous lessons: encapsulated objects with a shared vocabulary.

In many languages polymorphism requires inheritance: the shapes must share a parent that declares area. Python does not require that. If an object has an area() method, shape.area() works, whatever the object's class and whoever wrote it. This is duck typing, from the saying "if it walks like a duck and quacks like a duck, it is a duck". Python asks what an object can do, not what it is. Built-ins work this way: len() accepts anything with __len__, and for accepts anything that can be iterated.
| Approach | Requires | Checks | Where you see it |
|---|---|---|---|
| inheritance-based | a shared parent class | isinstance(x, Shape) |
many other languages |
| duck typing | the method to exist | nothing; call it | idiomatic Python |
| protocols (later) | the method to exist, declared | static type checker | typed Python codebases |
The practical consequence is a rule of thumb: when you find yourself writing if isinstance(x, Circle): ... elif isinstance(x, Square): ..., stop. Give each class a method with the same name and call it. The chain of type checks grows every time a class is added and lives far from the classes it knows about; the method lives with its class and new classes bring their own.

Sometimes you do need to check. When an object lacks the method, the call raises AttributeError at the moment of use, which can be far from the cause. For inputs from outside your code, check early: hasattr(x, "area") asks whether the method exists, and isinstance(x, (int, float)) is right for built-in values where behavior genuinely depends on type. The later lesson on abstract base classes shows how to declare the required methods formally, and the typing lessons show how a checker can verify them before the program runs.
Code
Three classes with no shared parent, one loop. Each provides area and describe; the loop knows only the names.
# shapes.py
import math
class Circle:
def __init__(self, r):
self.r = r
def area(self):
return math.pi * self.r ** 2
def describe(self):
return f"circle r={self.r}"
class Square:
def __init__(self, side):
self.side = side
def area(self):
return self.side ** 2
def describe(self):
return f"square side={self.side}"
class Triangle:
def __init__(self, base, height):
self.base, self.height = base, height
def area(self):
return 0.5 * self.base * self.height
def describe(self):
return f"triangle {self.base}x{self.height}"
shapes = [Circle(1), Square(3), Triangle(4, 5)]
for shape in shapes:
print(f"{shape.describe():<16} area {shape.area():6.2f}")
print(f"total {sum(s.area() for s in shapes):.2f}")
circle r=1 area 3.14
square side=3 area 9.00
triangle 4x5 area 10.00
total 22.14Duck typing with the built-ins. len and for work on anything that supports them, including a class you wrote a minute ago.
# ducks.py
class Playlist:
def __init__(self, songs):
self._songs = list(songs)
def __len__(self):
return len(self._songs)
def __iter__(self):
return iter(self._songs)
things = ["abc", [1, 2], {"k": 1}, Playlist(["intro", "verse", "chorus"])]
for thing in things:
print(f"{type(thing).__name__:<9} len={len(thing)} items={list(thing)}")
str len=3 items=['a', 'b', 'c']
list len=2 items=[1, 2]
dict len=1 items=['k']
Playlist len=3 items=['intro', 'verse', 'chorus']The refactor: an isinstance chain and the polymorphic version produce the same result, and only one of them survives adding a new kind of notification.
# refactor.py
class Email:
def __init__(self, to):
self.to = to
def send(self):
return f"emailing {self.to}"
class Sms:
def __init__(self, number):
self.number = number
def send(self):
return f"texting {self.number}"
def notify_with_checks(target):
if isinstance(target, Email):
return f"emailing {target.to}"
elif isinstance(target, Sms):
return f"texting {target.number}"
raise TypeError("unknown notification")
def notify(target):
return target.send()
targets = [Email("ada@example.com"), Sms("555-0100")]
print([notify_with_checks(t) for t in targets])
print([notify(t) for t in targets])
['emailing ada@example.com', 'texting 555-0100']
['emailing ada@example.com', 'texting 555-0100']Checking politely when the input is untrusted. hasattr asks up front, and the failure is a clear message rather than an AttributeError deep inside a loop.
# check_early.py
class Square:
def __init__(self, side):
self.side = side
def area(self):
return self.side ** 2
def total_area(shapes):
for shape in shapes:
if not hasattr(shape, "area"):
raise TypeError(f"{type(shape).__name__} has no area() method")
return sum(shape.area() for shape in shapes)
print(total_area([Square(2), Square(3)]))
try:
total_area([Square(2), "not a shape"])
except TypeError as err:
print("rejected:", err)
13
rejected: str has no area() methodTry it yourself
Create payments.py with three unrelated classes, CardPayment, BankTransfer, and Cash, each with a process(amount) method that returns a short string and a fee(amount) method with a different rule. Write one function that takes a list of payments and an amount, calls both methods on each, and prints a summary line, without a single isinstance. Then add a fourth class and confirm the function needs no change.
Common mistakes
- Type-check chains. Every
elif isinstanceis a method that wanted to live on the class; move it there. - Assuming a shared parent is required. Python needs the method, not the ancestry; unrelated classes can be used interchangeably.
- Never checking anything. For untrusted input, an early
hasattrorisinstancegives a clear error instead of anAttributeErrorfar from the cause.
What's next
- Polymorphism: one call site, one method name, different behavior per class.
- Duck typing: Python asks whether the object has the method, not what class it is; built-ins like
lenandforwork this way. - Replace
isinstancechains with a shared method; check types only at the boundary, and do it early.
You can write code that welcomes new classes. Next you will make the shared vocabulary explicit and enforceable, with abstract base classes.