S010 · Inheritance, Composition & Polymorphism
The single most-abused feature in OOP is inheritance. This session teaches the ‘composition first’ rule every senior codebase uses, plus the MRO, super() rules, ABCs, protocols, and how Python's duck typing makes half of Java's ceremony unnecessary.
🎯 Model behaviour re-use with the right tool: composition, single inheritance, ABC, or Protocol — and know why ‘favour composition over inheritance’ is not just a slogan.
Why this session exists
Inheritance looks like the answer to every design problem in the first month of learning OOP, and looks like the source of every problem after year two. Deep inheritance trees, diamond MRO puzzles, ‘override this internal method’ contracts that nobody documented — these are the bugs every senior engineer has scars from. This session teaches the rule every mature codebase (Django, requests, FastAPI) actually follows: inherit for is-a; compose for has-a; use Protocols for duck typing; escape to ABCs only when you truly need enforcement.
- Trace Python's MRO (Method Resolution Order) for any class hierarchy — and know when to use `super()` vs explicit parent calls.
- Pick between inheritance and composition on reflex — and explain the rule to a coworker.
- Use ABC + @abstractmethod when you MUST enforce a contract, and Protocol + duck typing for everything else.
- Recognise the ‘shallow class hierarchy that should have been deeper’ (rare) and the reverse (common).
- Read a class hierarchy you didn't write and predict which method will be called for `obj.foo()`.
Prerequisites
- S007 · Functions — closures replace many inheritance uses.
- S009 · Classes & Objects — you know what a class is.
(a) Intuition · 5 min
A dog IS-A mammal. A dog inherits mammal behaviour: breathe, warm-blood, produce milk. That's clean inheritance — every mammal must do these things; a dog is one.
A car HAS-A engine. A car doesn't inherit ‘engineness’; it embeds an engine and delegates ‘start’ to it. You can swap the engine for an electric motor. That's composition — flexible, testable, no drama.
Most software domains look more like ‘car has-a engine’ than ‘dog is-a mammal’. That's why senior codebases lean composition-first.
Concretely: inheritance couples subclass and parent forever. Change the parent and every subclass shifts. Composition keeps the pieces separate — swap one without touching the others. Composition also plays nicer with testing (mock the piece, not the base class).
The rule: use inheritance ONLY when (a) it's genuinely an is-a relationship, (b) the subclass shares the full contract of the parent (Liskov Substitution), and (c) you own both classes. Otherwise: compose.
The four re-use tools, in order of preference
- Plain function — no coupling at all. Always the first thought.
- Composition — object HAS-A collaborator; delegate calls. Coupled through interface, not identity.
- Protocol (duck typing) — ‘anything that has method X counts’. No inheritance required. Structural, not nominal.
- Inheritance — subclass IS-A parent. Use only when the is-a really holds and you own both classes.
A quick history
- 1967Simula 67 — classes + inheritanceThe birth of OOP. Object, class, inheritance, virtual methods — invented together.
- 1987Design Patterns · Gang of Four (published 1994)‘Favour object composition over class inheritance.’ Still valid 30 years later.
- 1994Liskov Substitution PrincipleBarbara Liskov's rule: a subclass must be usable ANYWHERE its parent is. Half of bad inheritance breaks this.
- 2001Python 2.2 — MRO by C3 linearisationMultiple inheritance stops being random-order; C3 gives a predictable, monotonic MRO.
- 2019PEP 544 · ProtocolsPython gets structural typing. ‘If it quacks like a Duck, mypy calls it a Duck.’
(b) Visual walkthrough · 15 min
Inheritance vs composition, side by side
Inheritance is a fixed tree — a Poodle is stuck being a Dog forever. Composition is a flexible bag — a Car can swap its Engine tomorrow.
Python's MRO — what actually gets called
The C3 linearisation gives you a deterministic order. super() follows the MRO, not the class tree. That's why D → B → C → A — B's super() doesn't go directly to A, it goes to the next class in D's MRO, which is C.
The five ways Python does polymorphism
Just try it
- def read(f): return f.read()
- Anything with .read() works — file, StringIO, http response
- Zero declarations, zero enforcement
- The Python default
Duck typing + static check
- class Readable(Protocol): def read(self) -> str: ...
- Any class with `def read` MATCHES — no inheritance needed
- mypy checks it; runtime doesn't (unless @runtime_checkable)
- The modern default when types matter
Nominal enforcement
- class Storage(ABC): @abstractmethod def save(self, x): ...
- Subclasses MUST implement or instantiation fails
- Enforces AT RUNTIME + at class definition
- Use only when you truly need the contract
Classic is-a
- class Dog(Animal):
- Overrides + super() for extension
- Simple, clear, hard to misuse
- The workhorse for real is-a relationships
Power tool
- class UserView(LoginRequired, ListView):
- Mixins add ORTHOGONAL behaviour
- Requires understanding MRO
- Django's whole class-based-view system
The decision tree
If no → composition or a plain function. If yes → continue.
If no (‘I just want to re-use some code’) → composition. If yes → continue.
If no → duck typing or Protocol (structural). If yes → ABC + @abstractmethod.
One → clean single inheritance. Many → mixins (only if truly orthogonal). Avoid diamond hierarchies.
The mental model to hold
"Inheritance means 'is a kind of'. If a Square is a kind of Rectangle, or an admin is a kind of user, subclassing is the right call — it saves me from duplicating code."
Inheritance is not a taxonomy claim, it is a substitutability claim: every place the parent works, the child must work, with no caller changes. Reusing code is not sufficient justification; a subclass that narrows what the parent allowed has broken every existing caller.
Because "is a" is how humans naturally categorise, and because in real vocabulary Square genuinely is a Rectangle. The trap is that the class hierarchy encodes behaviour, not vocabulary. Rectangle promises you can set width and height independently; Square cannot honour that promise without either breaking the invariant or breaking the interface. Any code written against Rectangle — including code written years before Square existed — silently produces wrong answers. Same for the admin case: Admin(User) looks obvious until admin needs a field user validation rejects, and now the parent's constructor is a liability. The myth is sticky because it is true at the level of nouns and false at the level of contracts, and only the nouns are visible when you are designing.
The classic, in ten lines — nothing raises, the answer is just wrong:
class Rect:
def __init__(s, w, h): s.w, s.h = w, h
def area(s): return s.w * s.h
class Square(Rect):
def __init__(s, side): super().__init__(side, side)
@property
def w(s): return s._w
# ... any setter must change BOTH to stay square
def stretch(r: Rect): # written before Square existed
r.w = 10; r.h = 4
assert r.area() == 40 # holds for Rect, fails for SquareWhy does Python need a Method Resolution Order algorithm as intricate as C3 linearisation? "Search depth-first, left to right" sounds obviously sufficient — derive why it isn't.
- 1With multiple inheritance, a name can be defined on several ancestors, so the interpreter needs a total order over all ancestors to pick a winner deterministically.forced by · attribute lookup must return exactly one thing, every time, for the same class
- 2The order must respect each class's own declaration order: if you wrote
class D(B, C), then B must precede C, because you stated a preference.forced by · otherwise the base list would be meaningless and overriding would be unpredictable - 3The order must also respect inheritance: a class must always precede its own parents, or a subclass could fail to override the very method it exists to override.forced by · overriding is the entire purpose of subclassing
- 4Naive depth-first violates the second rule in the diamond case. For
D(B, C)where both derive fromA, depth-first visits B, then A — placing the shared baseAahead ofC, soA's method wins overC's override.forced by · going all the way up the first branch reaches the common ancestor before the second branch is examined at all - 5C3 fixes this by merging the parents' linearisations while preserving both constraints, and refusing to build the class at all when they are contradictory.forced by · a consistent total order sometimes does not exist, and a
TypeErrorat class-creation time is strictly better than a silently wrong lookup at runtime
Therefore the MRO is the unique order satisfying "declaration order" and "children before parents" simultaneously — and TypeError: Cannot create a consistent MRO means you asked for two contradictory orderings, not that Python is being difficult.
And note what this predicts: super() cannot mean "my parent". It means "the next class in the MRO of the instance's actual type" — which for a diamond can be a sibling your class never mentioned. Verify with D.__mro__, then check that cooperative __init__ chains only work if every class in the diamond calls super().__init__(); one class that hardcodes Base.__init__(self) breaks the chain for everyone.
Subclassing signs a contract on your behalf: you promise that every method the parent offers still behaves as callers expect, forever, including methods added to the parent later. You inherit the parent's entire public surface whether you wanted it or not.
Composition makes no promises. You hold a reference to a collaborator and expose only what you choose to forward. You give up the free polymorphism and pay in delegation lines — and you can swap the collaborator at runtime, which no subclass can do.
- Subclass only when substitutability holds (Liskov): the child must accept everything the parent accepted and promise everything the parent promised.
- Wanting to reuse code is a reason to compose. Wanting to be used interchangeably is the only good reason to inherit.
- Depth is the enemy. Past two or three levels, finding where a method actually lives becomes archaeology; prefer shallow hierarchies plus composition.
- In Python, duck typing and
Protocoloften give you polymorphism with no base class at all — inheritance is not required for interchangeability here the way it is in nominally-typed languages.
Fire this model the moment you see: a subclass that overrides a method to raise NotImplementedError · a class hierarchy four levels deep · isinstance checks branching on subclass type · a mixin that assumes attributes it doesn't define · Cannot create a consistent MRO.
Several pipeline steps share 80% of their logic — setup, retry, logging — and differ in the transform. Base class with a template method, composition with injected strategies, or plain functions?
abstractmethod makes the extension points explicit and fails at instantiation if unimplemented; a new step is one small subclassStart with functions, add composition when a piece needs to vary, and reach for an ABC only when you need polymorphic dispatch over a set of implementations. The rule that ages best: inherit interfaces, compose implementations.
The specific failure to watch for in pipeline code is the base class that accumulates hooks. Each one is individually reasonable; collectively they make the base unmodifiable, because any change must be validated against every subclass. When you notice the base class knows about its children's special cases, the hierarchy has already inverted and composition is the exit.
(c) Hands-on · 25 min
Save as s010_inheritance_composition.py, run.
"""s010_inheritance_composition.py — the four re-use tools, side by side."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable
# =========================
# Part 1 · Single inheritance done right
# =========================
print("--- Part 1 · single inheritance ---")
class Animal:
def __init__(self, name: str) -> None:
self.name = name
def speak(self) -> str:
raise NotImplementedError(f"{type(self).__name__} must implement speak()")
def __repr__(self) -> str:
return f"{type(self).__name__}(name={self.name!r})"
class Dog(Animal):
def speak(self) -> str:
return "woof"
class Cat(Animal):
def speak(self) -> str:
return "meow"
for a in [Dog("rex"), Cat("whiskers")]:
print(a, a.speak())
# =========================
# Part 2 · super() and the MRO
# =========================
print("\n--- Part 2 · MRO ---")
class A:
def greet(self): return "A"
class B(A):
def greet(self): return "B → " + super().greet()
class C(A):
def greet(self): return "C → " + super().greet()
class D(B, C):
def greet(self): return "D → " + super().greet()
print(D().greet())
print("MRO:", [c.__name__ for c in D.__mro__])
# The MRO explains WHY B's super() ends up calling C's greet(), not A's.
# =========================
# Part 3 · Composition — the flexible alternative
# =========================
print("\n--- Part 3 · composition ---")
class Engine:
def start(self) -> str: return "gas engine running"
class ElectricMotor:
def start(self) -> str: return "silent electric motor running"
class Car:
"""Car HAS-A drivetrain. Swap the piece — the Car is unaffected."""
def __init__(self, drivetrain) -> None:
self.drivetrain = drivetrain
def start(self) -> str:
return f"key turned; {self.drivetrain.start()}"
gas_car = Car(Engine())
tesla = Car(ElectricMotor())
print(gas_car.start())
print(tesla.start())
# Contrast with inheritance:
# class GasCar(Engine): ...
# class TeslaCar(ElectricMotor): ...
# Now GasCar IS-A Engine, which is weird and locks you in.
# =========================
# Part 4 · Duck typing — the Python default
# =========================
print("\n--- Part 4 · duck typing ---")
def summarize(source) -> str:
"""Anything with .read() works — no declarations required."""
text = source.read()
return f"read {len(text)} chars starting {text[:30]!r}"
from io import StringIO
print(summarize(StringIO("hello from stringio " * 5)))
print(summarize(open(__file__))) # a real file also has .read()
# =========================
# Part 5 · Protocol — duck typing + static check
# =========================
print("\n--- Part 5 · Protocol ---")
@runtime_checkable
class Readable(Protocol):
def read(self) -> str: ...
def summarize_typed(source: Readable) -> str:
return f"typed: read {len(source.read())} chars"
print(summarize_typed(StringIO("typed stringio")))
print(isinstance(StringIO(), Readable)) # runtime check enabled by @runtime_checkable
print(isinstance(42, Readable)) # False — no .read()
# =========================
# Part 6 · ABC — when you MUST enforce a contract
# =========================
print("\n--- Part 6 · ABC ---")
class Storage(ABC):
"""Contract that every backend MUST implement."""
@abstractmethod
def save(self, key: str, value: bytes) -> None: ...
@abstractmethod
def load(self, key: str) -> bytes: ...
class InMemoryStorage(Storage):
def __init__(self) -> None: self._data: dict[str, bytes] = {}
def save(self, key: str, value: bytes) -> None: self._data[key] = value
def load(self, key: str) -> bytes: return self._data[key]
class BrokenStorage(Storage):
def save(self, key: str, value: bytes) -> None:
self._data = value # forgot to implement load — instantiation will FAIL
try:
BrokenStorage()
except TypeError as e:
print("caught abstract-not-implemented:", e)
s = InMemoryStorage()
s.save("hello", b"world")
print("loaded:", s.load("hello"))
# =========================
# Part 7 · Mixins — orthogonal behaviour
# =========================
print("\n--- Part 7 · mixins ---")
class LoggingMixin:
"""Adds .log(). Does NOT stand alone — pairs with a concrete class."""
def log(self, msg: str) -> None:
print(f"[{type(self).__name__}] {msg}")
class SerializableMixin:
def to_dict(self) -> dict:
return {k: v for k, v in vars(self).items() if not k.startswith("_")}
class User(LoggingMixin, SerializableMixin):
def __init__(self, name: str, role: str) -> None:
self.name = name
self.role = role
u = User("ada", "engineer")
u.log("created")
print(u.to_dict())
# Django's class-based views are 20 layers of this. Use sparingly.
# =========================
# Part 8 · Composition vs inheritance — same problem, both solutions
# =========================
print("\n--- Part 8 · trade-off ---")
# Inheritance
class Bird:
def move(self) -> str: return "walks"
class FlyingBird(Bird):
def move(self) -> str: return "flies"
class Penguin(Bird):
pass # inherits ‘walks’ — is that right? What about Ostrich? What about a bird that swims?
# Every new behaviour = new subclass or override. Combinatorial mess.
# Composition
class Movement:
def __init__(self, verb: str) -> None: self.verb = verb
def describe(self, name: str) -> str: return f"{name} {self.verb}"
class Bird2:
def __init__(self, name: str, movement: Movement) -> None:
self.name = name
self.movement = movement
def describe(self) -> str: return self.movement.describe(self.name)
print(Bird2("eagle", Movement("flies")).describe())
print(Bird2("penguin", Movement("swims")).describe())
print(Bird2("ostrich", Movement("runs fast")).describe())
# Composition scales linearly with behaviours; inheritance scales combinatorially.What each block teaches
Anatomy of the exercises
Given this Java-ish design:
class Order:
def total(self, price): return price
class DiscountedOrder(Order):
def total(self, price): return price * 0.9
class BulkDiscountedOrder(DiscountedOrder):
def total(self, price): return price * 0.9 - 5
class HolidayBulkDiscountedOrder(BulkDiscountedOrder):
def total(self, price): return price * 0.7 - 5Refactor to composition: Order holds a DiscountStrategy (or a list of them). New discount types are new strategy classes, not new Order subclasses. Show that adding ‘StudentDiscount’ becomes one class + zero changes to Order.
(d) Production reality · 15 min
A dev subclasses ListView, mixes in LoginRequiredMixin, adds CustomPaginator, overrides get_context_data. Some methods on the parent hierarchy get called; others don't. Nobody remembers the MRO. Two days of ‘why does my auth check not run?’
Print YourView.__mro__. The mixin order determines which method wins. When mixins fight, split the class into a plain function-based view + explicit collaborators. Django's docs even recommend function-based views for anything non-trivial.
A library exposes a base class with 20 methods, expects users to override 3 of them. Users override a fourth by accident (name clash) and internal behaviour breaks silently.
Prefer composition-based extension points: class Server: def __init__(self, handler): .... Users implement `handler`, not subclass Server. Or use Protocols to document the exact interface a handler must satisfy.
If inheritance is necessary, document the extension points explicitly (‘override only get_context_data’) and use @final (PEP 591) on methods that must not be overridden.
A dev builds AbstractBaseHandler → AbstractLoggingHandler → AbstractAuthHandler → ConcreteHandler. Four levels for a service that takes an HTTP request and returns JSON. Six months later nobody knows which level owns which method; adding a fifth capability requires editing three layers.
Flatten to a single ConcreteHandler that takes injected collaborators: ConcreteHandler(logger=..., auth=..., store=...). Each collaborator is testable in isolation. New capabilities = new collaborators.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- When do you use composition instead of inheritance? (one rule)
- What's the MRO? (one sentence + how you look it up)
- Protocol vs ABC — pick one for a new library. Why?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.