Object Oriented Programming
Polymorphism & Duck Typing
Same CALL, different BEHAVIOR — decided by the object's actual type at runtime.
Jr Codex Python Notes
Level: Intermediate–Advanced Prerequisites: Chapter 2 Time to complete: ~20 minutes
Table of Contents
- What is Polymorphism?
- Polymorphism Through Inheritance
- Duck Typing — Python's Real Style of Polymorphism
- Polymorphism with Built-in Functions
isinstance()vs Duck Typing- A Practical Example
- Summary & Next Steps
1. What is Polymorphism?
Polymorphism ("many forms") means the same method call produces different behavior depending on what object it's called on. You already saw this in Chapter 2's make_sound() example — that was polymorphism, just not named yet.
Polymorphism in Action
─────────────────────────────────────────
animal.make_sound()
│
├── if animal is a Dog → "Woof!"
├── if animal is a Cat → "Meow!"
└── if animal is a Duck → "Quack!"
Same CALL, different BEHAVIOR — decided by the object's actual type at runtime.
─────────────────────────────────────────
2. Polymorphism Through Inheritance
The classic form: a shared parent class defines a method signature; each subclass overrides it with its own behavior; calling code doesn't need to know which subclass it has.
class Shape:
def area(self):
raise NotImplementedError("Subclasses must implement area()")
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * 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 = [Rectangle(4, 5), Circle(3), Triangle(6, 4)]
for shape in shapes:
print(f"{type(shape).__name__}: area = {shape.area():.2f}")
# Rectangle: area = 20.00
# Circle: area = 28.27
# Triangle: area = 12.00The loop body (shape.area()) has no idea what specific shape it's dealing with — it just trusts that every Shape has an .area() method, and lets each object's own class decide what that means. This is the entire value of polymorphism: calling code stays simple and doesn't need a big if/elif chain checking types.
# Without polymorphism — the ugly alternative
for shape in shapes:
if isinstance(shape, Rectangle):
print(shape.width * shape.height)
elif isinstance(shape, Circle):
print(3.14159 * shape.radius ** 2)
elif isinstance(shape, Triangle):
print(0.5 * shape.base * shape.height)
# ...and this grows every time you add a new shape type3. Duck Typing — Python's Real Style of Polymorphism
"If it walks like a duck and quacks like a duck, it's a duck."
Python doesn't actually require inheritance for polymorphism to work. As long as an object has the method you're calling, Python doesn't care what class it belongs to — this is duck typing, and it's more idiomatic in Python than strict inheritance hierarchies for this purpose.
class Duck:
def make_sound(self):
print("Quack!")
class Dog:
def make_sound(self):
print("Woof!")
class Robot: # NOT related to Duck or Dog at all — no shared parent!
def make_sound(self):
print("Beep boop!")
def announce(entity):
entity.make_sound() # doesn't check the type — just calls the method
for thing in [Duck(), Dog(), Robot()]:
announce(thing)
# Quack!
# Woof!
# Beep boop!Inheritance-based Polymorphism Duck Typing
───────────────────────────── ─────────────────────────────
"Is this an instance of Shape?" "Does this have an .area() method?"
Requires a shared parent class Requires NOTHING but the right method existing
Enforced by the class hierarchy Enforced by convention/trust ("EAFP" — see below)
───────────────────────────── ─────────────────────────────
This is why Python is often called a dynamically, duck-typed language — it favors "does this object support the operation I need?" over "what exact type is this?"
4. Polymorphism with Built-in Functions
Python's own built-in functions are already polymorphic — len() works differently depending on what you give it, because each type defines its own __len__ (a dunder method, covered fully in Chapter 7):
print(len("hello")) # 5 — counts characters
print(len([1, 2, 3])) # 3 — counts list items
print(len({"a": 1, "b": 2})) # 2 — counts key-value pairs
print(1 + 2) # 3 — numeric addition
print("a" + "b") # "ab" — string concatenation
print([1, 2] + [3, 4]) # [1, 2, 3, 4] — list concatenation+ and len() behave completely differently depending on the object — that's polymorphism baked into the language itself, and Chapter 7 shows you how to make your own classes support +, len(), and more.
5. isinstance() vs Duck Typing
Sometimes you genuinely need to check an object's type — but even then, Python's philosophy leans toward "ask forgiveness, not permission" (EAFP) over strict up-front type checks.
# LBYL — "Look Before You Leap" — check first, then act
def process(item):
if hasattr(item, "make_sound"): # check the capability exists
item.make_sound()
else:
print("This item can't make a sound")
# EAFP — "Easier to Ask Forgiveness than Permission" — just try it, handle failure
def process(item):
try:
item.make_sound()
except AttributeError:
print("This item can't make a sound")Both work — EAFP (using try/except, from Module 3, Chapter 4) is generally considered more Pythonic, especially when the failure case is rare, since it avoids doing the same check twice (once to look, once implicitly when the method actually runs).
isinstance() still has its place — it's the right tool when you need to branch on type deliberately (not just check "can I call this method"), or when validating input at a boundary:
def calculate_area(shape):
if not isinstance(shape, Shape):
raise TypeError("Expected a Shape instance")
return shape.area()6. A Practical Example
Combining everything — a simple plugin-style system where new "processors" can be added without changing the code that uses them:
class TextProcessor:
def process(self, text):
raise NotImplementedError
class UppercaseProcessor(TextProcessor):
def process(self, text):
return text.upper()
class ReverseProcessor(TextProcessor):
def process(self, text):
return text[::-1]
class WordCountProcessor(TextProcessor):
def process(self, text):
return f"Word count: {len(text.split())}"
def run_pipeline(text, processors):
for processor in processors:
text_result = processor.process(text)
print(f"{type(processor).__name__}: {text_result}")
pipeline = [UppercaseProcessor(), ReverseProcessor(), WordCountProcessor()]
run_pipeline("hello world", pipeline)
# UppercaseProcessor: HELLO WORLD
# ReverseProcessor: dlrow olleh
# WordCountProcessor: Word count: 2Adding a new processor later means writing one new class — run_pipeline never needs to change. This pattern (a shared interface, many interchangeable implementations) reappears constantly in real frameworks, including scikit-learn's Estimator interface and PyTorch's nn.Module, both covered in later curriculum modules.
7. Summary & Next Steps
Key Takeaways
- Polymorphism means the same method call behaves differently depending on the actual object it's called on — it lets calling code stay simple instead of branching on type.
- Duck typing is Python's preferred style: if an object has the method you need, that's enough — no shared base class required.
- Python's built-ins (
len(),+) are already polymorphic, dispatching to each type's own implementation (dunder methods, Chapter 7). - Prefer EAFP (
try/except) over LBYL (isinstance/hasattrchecks) for typical duck-typed code; reserveisinstance()for genuine type validation at a boundary.
Concept Check
- What's the difference between polymorphism achieved through inheritance versus duck typing?
- Why does
len("hello")andlen([1,2,3])both work, despitestrandlistbeing unrelated types? - When would
isinstance()be the right tool, rather than just trying the operation and catching a failure?
Next Chapter
→ Chapter 4: Composition vs Inheritance
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index