Skip to content
VLSI Mentor

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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())
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
simulation smoke
synthesis top
job misc

The 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")])
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  simulation smoke
  synthesis top
  lint rtl

print_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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  simulation smoke
  synthesis top
  job rtl

LintJob 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:

  1. The knowledge is in the wrong place. How a lint job describes itself is written in describe(), not in LintJob.
  2. Adding a type means editing a different file. The class is complete and correct on its own, and still does not work.
  3. 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 isinstance ladder 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  simulation smoke
  note: rerun after merge
related? False False

Two 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.

Azvya Education Pvt. Ltd.VLSI Mentor
late.py (BROKEN ON PURPOSE)
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")])
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
boundary.py (BROKEN ON PURPOSE)
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")])
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 printed

The 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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__')}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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__=True

len() 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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() plus raise is 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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())
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
Base      label=base      has shout=True
Child     label=child     has shout=True
Stranger  label=stranger  has shout=False
---
Base().shout() : BASE
Child().shout(): CHILD

All 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 isinstance ladder 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 isinstance ladder 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 AttributeError at the call site, after some work has already happened.
  • Use hasattr for optional capabilities or a boundary check, never for dispatch.
  • len(), for and print are 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 a raise makes 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.