Matheion

MA-PY1 · Module 4.5

Inheritance and polymorphism

Topics: subclass · super() · override · isinstance · MRO (awareness)

Overview

Module 4.5 — Inheritance and polymorphism

Scroll Core ideas → worked snippets → Practice (quiz). Attempt each question before Discuss / reveal. Use any Python REPL, script, or notebook you like.

Concepts

Core ideas — inheritance & polymorphism

This session has three big ideas, and each one answers a question you will hit the moment you start building real class hierarchies:

  1. Overriding vs. extending — when my subclass method has the same name as a parent method, do I throw the parent's version away, or build on top of it?
  2. Nominal polymorphism — what does "polymorphism" really mean, and when does Python decide by the declared class hierarchy (isinstance) versus by what the object can actually do (duck typing)?
  3. MRO — when several classes are involved, which class's method actually runs, and how does super() know where to go next?

Read the whole file once top to bottom, then come back and type each snippet into a REPL. The practice questions at the end make sense only after you have traced a few call orders by hand.


1. Overriding vs. extending

The setup

Inheritance gives you a parent class with working methods. When you write a subclass, you can define a method with the same name as one in the parent. Python then always uses the subclass version for subclass instances — the parent version is shadowed. That part is easy.

The subtle part is that there are two completely different ways to write that same-named method:

  • Override — your method replaces the parent's entirely. The parent's code never runs.
  • Extend — your method keeps the parent's work and adds its own steps before or after it, by calling super().

A tiny story: the café's coffee recipes

Harbor Café has a base recipe and two variations. Here is the base:

class Coffee:
    def make(self) -> str:
        return "espresso"

The cappuccino keeps the espresso and adds steamed milk on top — that's an extension:

class Cappuccino(Coffee):
    def make(self) -> str:
        return f"{super().make()} + steamed milk"

super().make() runs the parent's make first, then the child adds its own part. Result: "espresso + steamed milk". The parent's work is preserved — the child only extended it.

The mocha does not want a plain espresso at all; it starts from chocolate. That's an override:

class Mocha(Coffee):
    def make(self) -> str:
        return "chocolate + espresso + milk"

No super() call anywhere, so Coffee.make never runs for a Mocha. If we had called super(), the chocolate would be layered on top of the parent's espresso — a different drink.

The rule of thumb

Ask one question: does the parent's behaviour still belong in the final result?

  • If yes — the parent's work is a genuine step of your result — extend: call super() and add your extra steps.
  • If no — the parent's behaviour would be wrong for you — override: leave super() out entirely.

Extending comes in two directions

When you extend, your extra work can happen before the parent or after it.

Post-processing (parent first, then you) is what the cappuccino does: run the base recipe, then add milk.

Pre-processing (you first, then parent) is common when you must validate or prepare inputs before the parent is allowed to see them:

class DoubleShot(Coffee):
    def make(self, shots: int = 1) -> str:
        if shots < 1:
            raise ValueError("at least one shot required")
        base = super().make()
        return f"{base} x{shots}"

Both are "extending". The only hard requirement is that super() actually runs somewhere in your method, so the parent gets its turn.

The two classic bugs

Bug 1 — "I thought I extended, but I overrode." You call super(), but ignore its return value. If the parent's method returns something important (a string, a dict, a price), discarding it silently drops the parent's work:

class SilentCoffee(Coffee):
    def make(self) -> str:
        super().make()            # return value thrown away
        return "quiet coffee"

This runs without any error — and quietly produces a coffee with no espresso. If the parent returns a value you need, keep it: base = super().make().

Bug 2 — "I thought I overrode, but I extended." You meant to replace the parent's behaviour, but called super() out of habit and then had to patch over the result to hide it. The parent's dead code still runs, and the output becomes layered and confusing. If the parent's behaviour is wrong for you, don't call it.


2. Nominal polymorphism

What "polymorphism" means

"Poly-morphism" literally means many forms. One line of code — device.trigger() — behaves differently depending on the object it is called on:

class Alarm:
    def trigger(self):
        raise NotImplementedError

class LoudAlarm(Alarm):
    def trigger(self):
        return "BUZZ"

class BlinkAlarm(Alarm):
    def trigger(self):
        return "blink"

def sound_off(devices):
    return [d.trigger() for d in devices]

sound_off never asks what kind of alarm each device is. It just calls trigger() and trusts each object to do the right thing. One call site serving many forms — that is polymorphism.

What "nominal" means

Python's attribute polymorphism is duck-typed: it does not care about declared types, only about whether the object has the method. "If it walks like a duck and quacks like a duck, it's a duck."

But Python also has a separate, nominal (name-based) kind of polymorphism: checks that trust the declared class hierarchy, by name. The two tools are isinstance and issubclass:

class Fruit:
    pass

class Berry(Fruit):
    pass

b = Berry()
print(isinstance(b, Berry))     # True  — exact class and everything below it
print(isinstance(b, Fruit))     # True  — Berry is *declared* to be a Fruit
print(type(b) is Berry)         # True  — exact-type comparison
print(type(b) is Fruit)         # False — type() is strict; subclasses do NOT count
print(issubclass(Berry, Fruit))  # True — the hierarchy itself

The word "nominal" is just a reminder that these checks compare names in the inheritance tree — they never probe methods. isinstance(x, Duck) is true only if Duck actually appears in x's declared family tree.

When to reach for isinstance

Good uses:

  • Dispatching on a declared hierarchy: "is this an order, an invoice, or a refund?"
  • Writing a fallback for one special type among many.
  • Being deliberately lenient: accepting a base class and everything below it.

A sign to avoid it:

  • An isinstance ladder — if isinstance(a, X): ... elif isinstance(a, Y): ... elif isinstance(a, Z): ... — usually means the polymorphic method (or functools.singledispatch) should carry the branch instead. Adding a new type then means adding a new method, not editing a chain of checks.

Common confusion — object is lowercase. The class every class ultimately inherits from is spelled object, all lowercase. isinstance("a", object) is True, because a string is an object. But Object (capital O) is not a class at all — it's just an undefined name, so isinstance("a", Object) raises NameError. Whenever you read an MRO tuple, the final entry is that lowercase object.

A note on substitutability

Because Berry is declared under Fruit, you can hand a Berry anywhere a Fruit is expected and the nominal check passes. This is the Liskov idea in action: a subclass should be usable wherever its base class is. It's what makes isinstance(x, Base) meaningful — if subclasses could silently break the base's promises, the "is a" test would be a lie.


3. MRO — method resolution order

The one-sentence version

When you call obj.method(), Python consults a single linear list of classes — from the object's own class up to object — called the MRO (method resolution order) — and uses the first class in that list that defines the method. super() means "the next class in that same list."

Single inheritance: the boring case

For a plain chain, the MRO is exactly what you'd guess:

class A:
    def ping(self):
        print("A")

class B(A):
    def ping(self):
        print("B")
        super().ping()

class C(B):
    def ping(self):
        print("C")
        super().ping()

c = C()
c.ping()           # prints C, then B, then A
print(C.__mro__)   # (C, B, A, object)

C.__mro__ is a tuple you can print directly — go ahead. It reads top-to-bottom: "first check C, then B, then A, then object."

Multiple inheritance: order is not obvious

Python lets you write class D(B, C): to inherit from two classes at once. Now which parent's method does D use, and where does super() go? The MRO answers both.

The practical rule for reading an MRO: depth-first, left-to-right, and each class appears after all of its own parents.

Consider the classic diamond:

class A:
    def ping(self):
        print("A", end=" ")

class B(A):
    def ping(self):
        print("B", end=" ")
        super().ping()

class C(A):
    def ping(self):
        print("C", end=" ")
        super().ping()

class D(B, C):
    def ping(self):
        print("D", end=" ")
        super().ping()

d = D()
d.ping()          # what does this print?
print()
print(D.__mro__)

Run it. You get:

D B C A
(D, B, C, A, object)

Here is the part that surprises most people: after B runs, super() hops to C, not to A. Why? Because the MRO is (D, B, C, A, object) — and super() inside any method means "the next class in the MRO," not "the class I literally listed as my parent." B's literal parent is A, but the MRO says C comes before A, so the baton passes B → C → A. Each class cooperates: it does its own work, then hands control to the next class in the shared list.

This is called cooperative multiple inheritance, and it has one hard rule: every class in a diamond should call super() for its chained methods — including __init__. If B skips super().__init__(), then C never initializes, and A never initializes, silently.

Checking the MRO

Three ways, all free in a REPL:

D.__mro__    # tuple of classes
D.mro()      # same information as a list
help(D)      # full docs, including the MRO section

Why the MRO matters even in plain code

  • super() is not "the parent" — it is "next in the MRO." In single inheritance that is almost always your literal parent, but it is a habit worth unlearning early, before a diamond sneaks in.
  • A skipped super() in the middle of a chain breaks everything after it.
  • Keep diamonds shallow. Multiple inheritance is powerful, but the readable pattern is mixins — small helper classes with no __init__ of their own, composed into one class. Deep diamond webs are where MRO surprises live.

ABC and @abstractmethod (recognition)

One more tool you will meet: abc.ABC with @abstractmethod marks a base class as unfinished. Python then refuses to instantiate it until every abstract method is overridden by a concrete subclass — a cheap way to enforce "you must implement trigger before you can build an alarm."

from abc import ABC, abstractmethod

class Alarm(ABC):
    @abstractmethod
    def trigger(self): ...

class LoudAlarm(Alarm):
    def trigger(self):
        return "BUZZ"

For this session you only need to recognise the shape: an abstract base cannot be instantiated, and it acts as a contract for subclasses. (Protocols — the duck-typed cousin — appear later.)


Quick reference

| Situation | Do | |---|---| | Child keeps the parent's result and adds to it | Extend — call super() and use its return value | | Child must prepare inputs before the parent runs | Extend — validate, then call super() | | Parent's behaviour is wrong for the child | Override — leave super() out | | "This object, or anything declared under it" | isinstance(obj, Cls) | | "Exactly this class, no subclasses" | type(obj) is Cls | | "Is one class declared under another?" | issubclass(A, B) | | Need to see the dispatch order | Klass.__mro__ | | isinstance ladder keeps growing | Replace with a polymorphic method or singledispatch |

Self-study checklist

  • Type the diamond into a REPL; predict d.ping() before you run it, then compare with D.__mro__.
  • Swap to class D(C, B) and predict the new MRO and the new output — the order of the listed parents matters.
  • Write an __init__ version of the diamond: each class sets one field and calls super().__init__(). Verify all three fields exist on a D() instance.
  • Take one isinstance ladder you have written and convert it to a polymorphic method (or functools.singledispatch).

Examples

Worked snippets — inheritance patterns

Every snippet below is complete and runnable. Type it into a script or REPL, run it, and read the printed output before reading the explanation that follows it.


1. A simple override

class Greeter:
    def greet(self, who: str) -> str:
        return f"Hello, {who}"


class ShoutGreeter(Greeter):
    def greet(self, who: str) -> str:
        return super().greet(who).upper()


print(Greeter().greet("ada"))
print(ShoutGreeter().greet("ada"))

Output:

Hello, ada
HELLO, ADA

ShoutGreeter keeps the parent's wording and then transforms it — this is an extend (post-processing). The super() call does the parent's work, and the child adds .upper() on top.


2. Extend vs. override, side by side

class Coffee:
    def make(self) -> str:
        return "espresso"


class Cappuccino(Coffee):
    def make(self) -> str:
        return f"{super().make()} + steamed milk"


class Mocha(Coffee):
    def make(self) -> str:
        return "chocolate + espresso + milk"


print(Coffee().make())
print(Cappuccino().make())
print(Mocha().make())

Output:

espresso
espresso + steamed milk
chocolate + espresso + milk

Notice the difference in how each subclass is written, not in what it prints:

  • Cappuccino calls super().make() and layers milk on the result — the parent's espresso is still inside its drink. Extend.
  • Mocha never calls super() — its recipe replaces the espresso entirely. Override.

Ask the one question from the concepts: does the parent's behaviour still belong in the result? Cappuccino: yes. Mocha: no.


3. Pre-processing before super()

class Coffee:
    def make(self) -> str:
        return "espresso"


class DoubleShot(Coffee):
    def make(self, shots: int = 1) -> str:
        if shots < 1:
            raise ValueError("at least one shot required")
        base = super().make()
        return f"{base} x{shots}"


print(DoubleShot().make(2))
try:
    print(DoubleShot().make(0))
except ValueError as e:
    print("rejected:", e)

Output:

espresso x2
rejected: at least one shot required

This extends the parent before it runs: the child validates the input first, then hands control to Coffee.make, then uses the returned base string. Three steps happen in one method — check, delegate, assemble.


4. The "I extended, but I really overrode" bug

class Coffee:
    def make(self) -> str:
        return "espresso"


class SilentCoffee(Coffee):
    def make(self) -> str:
        super().make()          # return value thrown away!
        return "quiet coffee"


print(SilentCoffee().make())

Output:

quiet coffee

No error — but the espresso vanished. The super() call ran Coffee.make, and the returned "espresso" was discarded. If you need the parent's return value, keep it:

class LoudCoffee(Coffee):
    def make(self) -> str:
        base = super().make()
        return f"{base} LOUD"


print(LoudCoffee().make())

Output:

espresso LOUD

5. Initialising parent state with super().__init__()

class Meter:
    def __init__(self, label):
        self.label = label


class Gauge(Meter):
    def __init__(self, label, max_value):
        super().__init__(label)
        self.max_value = max_value


g = Gauge("temp", 100)
print(g.label, g.max_value)

Output:

temp 100

The child's __init__ is a pre-processing extend: it delegates the shared label to the parent first, then sets its own field. If the child instead wrote self.label = label and skipped super().__init__(label), the instance would look correct today — until Meter later adds validation, logging, or a cache inside its __init__, and Gauge quietly never runs it.


6. A polymorphic call site

class Alarm:
    def trigger(self):
        raise NotImplementedError


class LoudAlarm(Alarm):
    def trigger(self):
        return "BUZZ"


class QuietAlarm(Alarm):
    def trigger(self):
        return "blink"


def sound_off(devices):
    return [d.trigger() for d in devices]


print(sound_off([LoudAlarm(), QuietAlarm()]))

Output:

['BUZZ', 'blink']

sound_off never inspects the objects — no if, no isinstance. The same call d.trigger() dispatches to whichever class each object actually is. This is the duck-typed (behaviour-based) polymorphism Python does for free.


7. Nominal checks: isinstance, type, issubclass

class Fruit:
    pass


class Berry(Fruit):
    pass


b = Berry()
print(isinstance(b, Berry))     # True
print(isinstance(b, Fruit))     # True — subclasses count
print(type(b) is Berry)         # True
print(type(b) is Fruit)         # False — exact class only
print(issubclass(Berry, Fruit))  # True — declared hierarchy

Output:

True
True
True
False
True

The first two lines are nominal checks — they consult the declared inheritance tree, by name. type(b) is Fruit is deliberately strict: it rejects subclasses. Use isinstance when you mean "this type or anything declared under it"; use type(...) is only when you really want the exact class.


8. MRO: read the tuple

class A:
    def ping(self):
        print("A", end=" ")


class B(A):
    def ping(self):
        print("B", end=" ")
        super().ping()


class C(A):
    def ping(self):
        print("C", end=" ")
        super().ping()


class D(B, C):
    def ping(self):
        print("D", end=" ")
        super().ping()


d = D()
d.ping()
print()
print(D.__mro__)

Output:

D B C A
(<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)

The key surprise is in the middle: after B, the baton goes to C, not to A. super() inside a method means "the next class in the MRO" — and the MRO says the order is D, B, C, A, object. If you change the class line to class D(C, B), the MRO becomes (D, C, B, A, object) and the output flips to D C B A. The listed order of parents is part of the contract.


9. Cooperative __init__ through a diamond

class A:
    def __init__(self):
        print("A init")
        self.a = "a"


class B(A):
    def __init__(self):
        print("B init")
        super().__init__()
        self.b = "b"


class C(A):
    def __init__(self):
        print("C init")
        super().__init__()
        self.c = "c"


class D(B, C):
    def __init__(self):
        print("D init")
        super().__init__()
        self.d = "d"


d = D()
print(d.__dict__)

Output:

D init
B init
C init
A init
{'a': 'a', 'c': 'c', 'b': 'b', 'd': 'd'}

Watch the order: D → B → C → A, exactly the MRO. Every class calls super().__init__(), so all four fields land on the single d instance. Now imagine B had not called super().__init__(): C would never run, A would never run, and the object would silently lack c and a. That is the failure mode the "keep calling super()" rule prevents.


10. Extending a chain when each layer adds data

class Serializable:
    def dump(self):
        return {"kind": type(self).__name__}


class Point3D(Serializable):
    def __init__(self, x, y, z):
        self.x, self.y, self.z = x, y, z

    def dump(self):
        data = super().dump()
        data.update({"x": self.x, "y": self.y, "z": self.z})
        return data


print(Point3D(1, 2, 3).dump())

Output:

{'kind': 'Point3D', 'x': 1, 'y': 2, 'z': 3}

Each layer extends the previous one: the base contributes the kind field, the child adds the coordinates. This is the same "call super(), then add to the returned value" pattern from the coffee examples, applied to data instead of strings — and it is exactly the shape used by real serialization and framework code.

Practice check — inheritance and polymorphism

Multiple-choice check from the Matheion app lesson. Pick an answer for each question, then submit.

  1. 1.A subclass's `__init__` should usually call which to set up the parent's state?

  2. 2.If `Berry` is a subclass of `Fruit` and `b = Berry()`, what is `isinstance(b, Fruit)`?

  3. 3.When should you *override* a method versus *extend* it with `super()`?

  4. 4.Several alarm classes each define `trigger()`. Calling `device.trigger()` on different devices runs each class's own version. This is an example of…

  5. 5.With `b = Berry()` (a subclass of `Fruit`), what is `type(b) is Fruit`?

  6. 6.For `class D(B, C)` where `B` and `C` each inherit from `A`, what is `D.__mro__`?

  7. 7.Why is `isinstance(b, Fruit)` called a *nominal* check when `b` is a `Berry`?

  8. 8.A subclass method calls `super().save()` and then adds its own line of logging. This pattern is…