Python Part I · Module 16 · Object-Oriented Programming
Polymorphism
Three chapters of inheritance machinery exist to make one thing possible: a loop that holds a mixed collection of objects, calls the same method on each, and gets a different and correct answer every time without ever asking what it is holding. The alternative — a chain of type checks — works perfectly on the day it is written and becomes a liability on the day somebody adds a fourth kind of job, because the chain lives in a different file from the class and nothing will tell you it was not updated.
Intermediate15 min readPythonPart IFundamentalsOOPDesign
Part I · Module 16 · Chapter 16.12 · Polymorphism
1. One Loop, Three Answers
class Job:
def __init__(self, name):
self.name = name
def describe(self):
return f"job {self.name}"
class SimulationJob(Job):
def describe(self):
return f"simulation {self.name}"
class SynthesisJob(Job):
def describe(self):
return f"synthesis {self.name}"
for job in (SimulationJob("smoke"), SynthesisJob("top"), Job("misc")):
print(job.describe())simulation smoke
synthesis top
job miscThe loop contains no if, no type name and no check. It calls describe() and
each object answers in its own way — which is Chapter 16.11's override mechanism
seen from the caller's side.
Polymorphism is this: many forms behind one call.
2. The Payoff Is That the Loop Never Changes
Add a fourth kind of job and look at what has to be edited:
class Job:
def __init__(self, name):
self.name = name
def describe(self):
return f"job {self.name}"
class SimulationJob(Job):
def describe(self):
return f"simulation {self.name}"
class SynthesisJob(Job):
def describe(self):
return f"synthesis {self.name}"
class LintJob(Job): # NEW
def describe(self):
return f"lint {self.name}"
def print_all(jobs):
"""Unchanged. Knows nothing about LintJob."""
for j in jobs:
print(" ", j.describe())
print_all([SimulationJob("smoke"), SynthesisJob("top"), LintJob("rtl")]) simulation smoke
synthesis top
lint rtlprint_all was written before LintJob existed and handles it correctly. The
new class is entirely self-contained: everything it needed to say about
itself, it said in its own body.
3. The Alternative, and What It Costs
Here is the same program written with type checks:
class SimulationJob:
def __init__(self, name):
self.name = name
class SynthesisJob:
def __init__(self, name):
self.name = name
class LintJob: # NEW -- and the ladder forgot it
def __init__(self, name):
self.name = name
def describe(job):
"""A dispatcher that must know every type that exists."""
if isinstance(job, SimulationJob):
return f"simulation {job.name}"
if isinstance(job, SynthesisJob):
return f"synthesis {job.name}"
return f"job {job.name}" # silent fallback
for j in (SimulationJob("smoke"), SynthesisJob("top"), LintJob("rtl")):
print(" ", describe(j)) simulation smoke
synthesis top
job rtlLintJob printed job rtl, which is wrong and raised nothing. The ladder's
final line is a fallback, so an unknown type gets a plausible, incorrect answer.
Three problems, in increasing order of seriousness:
- The knowledge is in the wrong place. How a lint job describes itself is
written in
describe(), not inLintJob. - Adding a type means editing a different file. The class is complete and correct on its own, and still does not work.
- Forgetting the edit is silent. Removing the fallback converts this into a crash, which is better — but then the dispatcher must be kept exhaustive by hand forever.
An
isinstanceladder over your own classes is almost always a method that wants to be written.
4. No Shared Base Class Required
Here is where Python differs from C++, Java and SystemVerilog. The loop never asked about types, so the objects do not need a common ancestor at all:
class SimulationJob:
"""No base class. No relationship to anything."""
def __init__(self, name):
self.name = name
def describe(self):
return f"simulation {self.name}"
class HumanNote:
"""Entirely unrelated -- a note somebody typed."""
def __init__(self, text):
self.text = text
def describe(self):
return f"note: {self.text}"
def print_all(items):
for i in items:
print(" ", i.describe())
print_all([SimulationJob("smoke"), HumanNote("rerun after merge")])
print("related?", issubclass(HumanNote, SimulationJob), issubclass(SimulationJob, HumanNote)) simulation smoke
note: rerun after merge
related? False FalseTwo classes with nothing in common except a method name, used interchangeably.
This is called duck typing — if it has describe(), it is describable, and
the language asks nothing further.
The practical consequence is large: an object can satisfy an interface without inheriting anything, which is what makes Chapter 16.13's composition a first-class option rather than a workaround.
5. The Honest Limit
Duck typing has a real cost: nothing is checked until the call happens.
class SimulationJob:
def __init__(self, name):
self.name = name
def describe(self):
return f"simulation {self.name}"
class Forgot:
"""Looks like a job. Has no describe()."""
def __init__(self, name):
self.name = name
def print_all(items):
for i in items:
print(" ", i.describe())
print_all([SimulationJob("smoke"), Forgot("cdc")]) simulation smoke
Traceback (most recent call last):
File "late.py", line 21, in <module>
print_all([SimulationJob("smoke"), Forgot("cdc")])
~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "late.py", line 18, in print_all
print(" ", i.describe())
^^^^^^^^^^
AttributeError: 'Forgot' object has no attribute 'describe'The first item printed before the failure, which means half the work happened.
In a compiled language this would have been caught before the program ran; in
Python it is an AttributeError at the call site (Chapter 14.3 Section 8).
Two mitigations, both small.
Check for an optional capability with hasattr:
class WithNotes:
def describe(self):
return "has notes"
def notes(self):
return ["rerun after merge"]
class WithoutNotes:
def describe(self):
return "no notes"
for obj in (WithNotes(), WithoutNotes()):
extra = obj.notes() if hasattr(obj, "notes") else []
print(f"{obj.describe():<10} extra={extra}")has notes extra=['rerun after merge']
no notes extra=[]Fail at the boundary rather than deep in a loop, which is Chapter 14.7's rule applied to an interface:
class Forgot:
def __init__(self, name):
self.name = name
def print_all(items):
for i in items:
if not hasattr(i, "describe"):
raise TypeError(f"{type(i).__name__} has no describe() -- cannot be printed")
for i in items:
print(" ", i.describe())
print_all([Forgot("cdc")])Traceback (most recent call last):
File "boundary.py", line 14, in <module>
print_all([Forgot("cdc")])
~~~~~~~~~^^^^^^^^^^^^^^^^^
File "boundary.py", line 9, in print_all
raise TypeError(f"{type(i).__name__} has no describe() -- cannot be printed")
TypeError: Forgot has no describe() -- cannot be printedThe message names the offending class and the missing method, and nothing was printed before it failed — the check ran over the whole collection first. That is strictly better than discovering the problem halfway through a report.
6. You Have Used This Since Module 4
Built-in polymorphism is the same mechanism:
for value in ["smoke", [1, 2, 3], {"a": 1, "b": 2}, (1,), {1, 2, 3, 4}]:
print(f"{type(value).__name__:<5} len={len(value)} defines __len__={hasattr(value, '__len__')}")str len=5 defines __len__=True
list len=3 defines __len__=True
dict len=2 defines __len__=True
tuple len=1 defines __len__=True
set len=4 defines __len__=Truelen() does not check what it was given. It calls __len__ — Chapter 16.8's
special method — and five unrelated types answer in their own way.
So does for, via __iter__. So does print, via __str__. The language is
built out of the thing this chapter is about, which is why adding __len__ to
your own class makes len() work on it with no further arrangement.
7. When There Is No Object: a Dispatch Table
Section 3 rejected the isinstance ladder because the knowledge belonged in the
class. Sometimes there is no class — the thing you are dispatching on is a
string from a config file or a command-line flag, and inventing a class
hierarchy for it is the same over-reach in the other direction.
The answer is a dictionary of functions, which Chapter 16.1 Section 8 showed is possible because functions are objects:
def run_sim(target):
return f"simulating {target}"
def run_syn(target):
return f"synthesising {target}"
def run_lint(target):
return f"linting {target}"
HANDLERS = {"sim": run_sim, "syn": run_syn, "lint": run_lint}
def dispatch(kind, target):
handler = HANDLERS.get(kind)
if handler is None:
raise ValueError(f"no handler registered for {kind!r}")
return handler(target)
for kind in ["sim", "syn", "lint"]:
print(" ", dispatch(kind, "top"))
try:
dispatch("place", "top")
except ValueError as e:
print("refused:", e) simulating top
synthesising top
linting top
refused: no handler registered for 'place'This keeps the two properties that made polymorphism worth having:
- Adding a kind is one entry, not an edit to a chain of
ifs. - An unknown kind fails loudly — the
.get()plusraiseis Chapter 14.7's boundary check, and it is what Section 3's silent fallback was missing.
It is not object polymorphism and does not pretend to be. The rule for choosing:
if you have objects, call the method; if you have a string, look up a
function. Reaching for a class hierarchy to dispatch on a config string is the
isinstance ladder wearing better clothes.
8. Predict, Run, Observe
class Base:
def label(self):
return "base"
def shout(self):
return self.label().upper()
class Child(Base):
def label(self):
return "child"
class Stranger:
def label(self):
return "stranger"
for obj in (Base(), Child(), Stranger()):
name = type(obj).__name__
has_shout = hasattr(obj, "shout")
print(f"{name:<9} label={obj.label():<9} has shout={has_shout}")
print("---")
print("Base().shout() :", Base().shout())
print("Child().shout():", Child().shout())Base label=base has shout=True
Child label=child has shout=True
Stranger label=stranger has shout=False
---
Base().shout() : BASE
Child().shout(): CHILDAll three answer label() — the loop is polymorphic over two inheriting
classes and one unrelated one.
Only the first two have shout. Duck typing is per-method, not per-object:
Stranger satisfies the part of the interface the loop uses and no more, and
that is enough.
shout() is defined once in Base and gives two answers, because it calls
self.label(). A Stranger could be made to work with shout by giving it its
own — inheritance is one way to acquire a method, not the only way.
9. Exercises
Exercise 1 — one loop. Write three classes with a shared method name and a loop that calls it without any type check.
Exercise 2 — add a fourth. Add a new class and confirm the loop needs no edit.
Exercise 3 — the ladder. Rewrite the same program with isinstance checks.
Add a fifth class, forget to update the ladder, and record what happens.
Exercise 4 — remove the fallback. Delete the ladder's final return and say
how the failure changes. Which do you prefer and why?
Exercise 5 — no base class. Make two unrelated classes usable in one loop.
Confirm with issubclass that they share nothing.
Exercise 6 — the late failure. Put an object missing the method into the loop. Record the error and how much work happened first.
Exercise 7 — boundary check. Add a validation pass that raises before any work is done. Compare the two failure modes.
Exercise 8 — your own __len__. Give a class __len__ and call len() on
it. Explain which mechanism made that work.
10. Summary
- Polymorphism is many forms behind one call: a loop calls one method and each object answers in its own way.
- The payoff is that the caller never changes when a new type is added.
- A new class is self-contained — everything about how it behaves is in its own body.
- An
isinstanceladder puts the knowledge in the wrong place, requires an edit in another file per new type, and fails silently when that edit is forgotten. - An
isinstanceladder over your own classes is a method that wants to be written. - Python does not require a shared base class — duck typing means having the method is enough.
- An object can satisfy an interface without inheriting anything, which makes composition a first-class option.
- The cost is late failure: a missing method is an
AttributeErrorat the call site, after some work has already happened. - Use
hasattrfor optional capabilities or a boundary check, never for dispatch. len(),forandprintare all polymorphic over your classes too, via__len__,__iter__and__str__.- When you are dispatching on a string rather than an object, a dict of
functions is the right tool — adding a kind is one entry, and
.get()plus araisemakes an unknown kind fail loudly.
The next chapter builds objects out of other objects rather than inheriting from them — and argues that it should be your default.
Where this fits
Part of the Python curriculum.
