Inheritance, Composition & Polymorphism
Compare inheritance and composition; trace method resolution and cooperative super calls, and distinguish ABC, protocol and runtime duck-typing contracts.
Original publication date unrecordedUpdated
Lesson preparation & details
Level: beginner
By the end, you should be able to
- super follows the instance class MRO, not necessarily the lexical parent
Bring with you
Sources checked: . A source check does not change the publication or update date.
Editorial review: · What review means
In this article · 23 sections
Review boundary and corrected contracts
super follows the instance class MRO, not necessarily the lexical parent. C3 is a deterministic monotonic linearization, not merely the unique ordering satisfying two informal constraints. ABC checks abstract method implementation, not semantic correctness; runtime Protocol checks do not validate method signatures.
The examples below teach the stated input contracts; fragments and deliberately incorrect counterexamples are exercises, not production implementations. Complexity counts elementary operations unless a bit-cost or allocation caveat is stated. Practice times are suggestions, not measured performance or hiring guarantees. Programs target Python 3.11+; verification uses CPython 3.12.3. Optional third-party tools and frameworks are not required for the standard-library examples.
Learning objective: 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 uses a design heuristic, not a survey of framework internals: inherit for is-a; compose for has-a; use Protocols for duck typing; use ABCs when nominal membership and abstract-method instantiation checks help.
You will be able to
-
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.
-
Distinguish ABC abstract-method checks from Protocol static structural checks; neither proves behavioral correctness.
-
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.
Pre-read · skim before the session (choose readings within your time budget)
Optional original media pointers (not watched or independently verified here):
- Composition Over Inheritance
- Python Object Oriented Programming (OOP) - For Beginners
- Python OOP Tutorial 4 — Inheritance
Use the official sources below for the contracts. Original channel/duration claims are not treated as verified attribution.
Further reading: Python Tutorial · Inheritance
Further reading: Composition Over Inheritance
Further reading: PEP 544 — Protocols: Structural subtyping (static duck typing)
(a) Intuition · 5 min
Inheritance vs composition — is-a vs has-a
Real:
A dog IS-A mammal in a biological taxonomy. In software, an Animal interface might promise a speak() operation that Dog implements; the actual behavioral promise, not the noun relationship, determines whether inheritance fits.
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 a subclass to its base API and extension behavior.
A base change can affect subclasses. Composition lets a collaborator be swapped
when its interface remains compatible and supports testing with small fakes.
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) the base exposes a documented extension contract. Otherwise prefer composition; framework bases can be legitimate even when you do not own them.
The four re-use tools, in order of preference
Reach for these in order — from least coupling to most
-
Plain function — little structural machinery, but still coupled to its inputs, outputs and effects. Start here for stateless transformations.
-
Composition — object HAS-A collaborator; delegate calls. Coupled through interface, not identity.
-
Protocol — static structural compatibility with the declared members and signatures, without required inheritance.
-
Inheritance — subclass IS-A parent. Use when substitutability holds and the base has a documented subclassing contract.
The single most common inheritance mistake
Inheriting to re-use code instead of to express a subtype relationship. If you're subclassing list because you want append, you're going to be very sad when your subclass breaks in surprising places (inherited mutation paths may bypass the override you added). Compose instead: hold a list as an instance attribute and expose the methods you actually want.
A quick history
The original timeline conflated several ideas. Simula is part of the class/inheritance lineage; the Gang of Four composition principle and Liskov substitutability concern design contracts rather than Python syntax. Here their practical question is whether a caller's promises survive substitution—not whether two nouns fit a taxonomy.
For Python's actual milestones: 2.2 introduced new-style classes, but 2.3 introduced C3. Earlier lookup was deterministic too, not random. ABCs were added in 2.6 (PEP 3119). Protocols arrived in 3.8 (PEP 544), adding static structural subtyping without requiring inheritance. This history explains why Python supports both nominal and structural interfaces rather than declaring one universally superior.
(b) Choosing between a hierarchy and collaborators · 15 min
Inheritance vs composition, side by side
The reuse tools in the timeline offer different promises. Compare the Animal hierarchy with the Car’s parts: one relates types through inheritance, while the other assembles an object from collaborators.
Figure text equivalent — labels and relationships
- Node “Animal” (square).
- Node “Dog” (square).
- Node “Cat” (square).
- Node “Poodle” (square).
- Node “Car” (square).
- Node “Engine” (square).
- Node “Wheels” (square).
- Node “Radio” (square).
- Group “Inheritance: is-a” contains “Dog”, “Animal”, “Cat”, “Poodle”.
- Group “Composition: has-a” contains “Car”, “Engine”, “Wheels”, “Radio”.
- “Animal” leads to “Dog”. Line: normal; ends: arrow point.
- “Animal” leads to “Cat”. Line: normal; ends: arrow point.
- “Dog” leads to “Poodle”. Line: normal; ends: arrow point.
- “Car” leads to “Engine”: has-a. Line: normal; ends: arrow point.
- “Car” leads to “Wheels”: has-a. Line: normal; ends: arrow point.
- “Car” leads to “Radio”: has-a. Line: normal; ends: arrow point.
The illustrated single-inheritance hierarchy is a tree; multiple inheritance forms a directed acyclic graph. In normal design, subclass relationships fix the inherited interface, while composition lets a Car replace its drivetrain with another collaborator satisfying the same contract.
Python's MRO — what actually gets called
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()) # 'D → B → C → A'
print(D.__mro__) # (D, B, C, A, object)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
Name: Duck typing
Tagline: Just try it
-
def read(f): return f.read()
-
An object with a compatible no-argument
.read()works; its returned data must support the caller's operations -
Zero declarations, zero enforcement
-
The Python default
Name: Protocols (PEP 544)
Tagline: Duck typing + static check
-
class Readable(Protocol): def read(self) -> str: ...
-
Static matching requires a compatible callable signature and return type, not just the name
read -
A static checker can verify compatibility;
@runtime_checkableonly enables shallow runtime attribute checks -
The modern default when types matter
Name: Abstract Base Classes (ABC)
Tagline: Nominal enforcement
-
class Storage(ABC): @abstractmethod def save(self, x): ...
-
Subclasses MUST implement or instantiation fails
-
Abstract classes may be defined; instantiation fails while abstract members remain unresolved
-
Use only when you truly need the contract
Name: Single inheritance
Tagline: Classic is-a
-
class Dog(Animal):
-
Overrides + super() for extension
-
Simple, clear, hard to misuse
-
The workhorse for real is-a relationships
Name: Multiple inheritance / mixins
Tagline: Power tool
-
class UserView(LoginRequired, ListView):
-
Mixins add ORTHOGONAL behaviour
-
Requires understanding MRO
-
Django's whole class-based-view system
The decision tree
Is there a real is-a relationship?
Desc: If no → composition or a plain function. If yes → continue.
Do subclasses need to be interchangeable with the parent?
Desc: If no (‘I just want to re-use some code’) → composition. If yes → continue.
Do you need explicit nominal membership and an abstract-method instantiation guard?
Desc: If no → duck typing or a Protocol. If yes → ABC plus @abstractmethod. An ABC does not validate method signatures or behavior; registering a virtual subclass also does not inject methods or enforce abstract implementations. A runtime-checkable Protocol checks member presence, not full signatures.
One parent or many?
Desc: One → clean single inheritance. Many → mixins (only if truly orthogonal). Avoid diamond hierarchies.
The mental model to hold
One rule that will save you years of pain
Prefer composition for structure and Protocol for typing. Use inheritance when substitutability and a documented extension contract hold, including supported framework bases. ABCs make required extension points visible and block instantiation of ordinary incomplete subclasses; frequency depends on the application, not a once-a-year rule.
Myth: "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."
Truth: 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 can break callers relying on the narrowed part of the contract.
Why: 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. A caller that independently sets both dimensions can therefore compute a wrong result with this Square. 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.
Test a complete mutable square counterexample. The missing setter in the earlier fragment would have raised during construction, before testing substitutability.
class Rect:
def __init__(self, w, h):
self.w, self.h = w, h
def area(self):
return self.w * self.h
class Square(Rect):
def __init__(self, side):
self._side = side
@property
def w(self):
return self._side
@w.setter
def w(self, value):
self._side = value
@property
def h(self):
return self._side
@h.setter
def h(self, value):
self._side = value
def stretch(r):
r.w = 10
r.h = 4
return r.area()
assert stretch(Rect(2, 3)) == 40
assert stretch(Square(2)) == 16 # violates the caller's independent-dimensions promiseThis is about this mutable Rectangle API, not a proof that immutable squares can never be rectangle values.
Question: Why 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.
Premise: With 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.
Because: attribute lookup must return exactly one thing, every time, for the same class
Premise: The 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.
Because: otherwise the base list would be meaningless and overriding would be unpredictable
Premise: The 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.
Because: overriding is the entire purpose of subclassing
Premise: Naive depth-first violates the second rule in the diamond case. For D(B, C) where both derive from A, depth-first visits B, then A — placing the shared base A ahead of C, so A's method wins over C's override.
Because: going all the way up the first branch reaches the common ancestor before the second branch is examined at all
Premise: C3 merges parent linearizations and the direct-base list. It chooses a list head only when that class occurs in no remaining tail, preserving local precedence and monotonicity (ancestor order cannot reverse in a subclass). If no head is eligible, class creation fails.
Because: a consistent total order sometimes does not exist, and a TypeError at class-creation time is strictly better than a silently wrong lookup at runtime
Conclusion: Therefore the MRO is a deterministic C3 linearization preserving local precedence and monotonicity, not merely those two informal ordering constraints — and TypeError: Cannot create a consistent MRO means you asked for two contradictory orderings, not that Python is being difficult. 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__(); a direct base call can skip a sibling or invoke a shared ancestor twice. Cooperative methods also need compatible signatures and a terminating implementation; calling super() alone does not guarantee correctness.
Name: Inheritance is a promise; composition is a wire
Picture: 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 still promises its collaborator interface. You hold a reference to a collaborator and expose only what you choose to forward. You pay in delegation lines but can retain structural polymorphism — and you can swap the collaborator at runtime, without changing the enclosing object's class; a subclass may also use composition.
-
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 Protocol often give you polymorphism with no base class at all — inheritance is not required for interchangeability here the way it is in nominally-typed languages.
Trigger: 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.
Decision: 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?
Name: Abstract base class + template method
Gain: the shared skeleton is written once and abstract methods mark required extension points; incomplete ordinary subclasses cannot be instantiated. A subclass can still override the skeleton, so documentation and tests matter
Cost: subclasses are coupled to the base's internals and to each other — changing the base changes every step; testing a step requires constructing the whole hierarchy; the "just one more hook" ratchet is real and one-directional
Pick: the skeleton is genuinely stable, extension points are few and known, and steps must be discovered/registered polymorphically
Name: Composition with injected collaborators
Gain: each piece is independently testable with a fake; behaviour is swappable at runtime and per-environment; no hierarchy to navigate when reading
Cost: more wiring and more names; the flow is assembled at the call site so you must read the construction code to know what will happen; over-applied it becomes a DI framework nobody asked for
Pick: a variation needs to change per environment or per test — retry policy, storage backend, clock — or two variations must be combined in ways a single inheritance chain can't express
Name: Plain functions + a decorator or two
Gain: the least machinery, trivially testable, and retry/logging as decorators are composable and reusable across unrelated code
Cost: shared state must be passed explicitly, which gets awkward past a few parameters; no enforced structure, so drift between steps is easy
Pick: the steps are stateless transforms and the shared logic is genuinely cross-cutting rather than structural — which describes most ETL steps honestly examined
Verdict: Start with functions, add composition when a piece needs to vary, and consider an ABC for nominal grouping and an instantiation guard; duck typing and Protocols also provide polymorphism. 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:
"""Read from a compatible no-argument source; result needs len and slicing."""
text = source.read()
return f"read {len(text)} chars starting {text[:30]!r}"
from io import StringIO
print(summarize(StringIO("hello from stringio " * 5)))
with open(__file__) as source:
print(summarize(source)) # close the real file after use
# =========================
# 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 — an abstract-method instantiation guard
# =========================
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())
# Mixins require a documented cooperation contract. 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’; this example asks how to represent additional movement choices.
# One subclass per combination would duplicate independent capability choices.
# 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())
# Independent strategy implementations can be combined without a subclass for every combination; not every inheritance design has combinatorial growth.What each block teaches
Anatomy of the exercises
Name: Part 1 · single inheritance
Desc: The clean is-a case. Every subclass overrides speak; the parent provides shared __init__ and __repr__.
Name: Part 2 · MRO + super()
Desc: The diamond D(B, C) shows why super() follows the MRO, not the parent-child edge. C3 linearisation is deterministic.
Name: Part 3 · composition
Desc: Car has-a drivetrain. Swap the piece; the Car code doesn't change. This is what senior codebases mostly look like.
Name: Part 4 · duck typing
Desc: summarize relies on a compatible no-argument read returning length/slice-capable data. Duck typing avoids a declaration, not a behavioral contract.
Name: Part 5 · Protocol
Desc: The same idea, statically typed. mypy checks structure; @runtime_checkable enables isinstance() checks.
Name: Part 6 · ABC
Desc: @abstractmethod prevents instantiation until every abstract method is implemented. Use for real contracts (plugin systems, DB drivers).
Name: Part 7 · mixins
Desc: Orthogonal capabilities added via MI. Works when the mixins truly don't overlap. Otherwise → composition.
Name: Part 8 · trade-off
Desc: The bird example: an inheritance design that creates a subclass per combination can grow rapidly; independent strategies avoid that particular duplication.
What to observe
Part 2's output D → B → C → A — that's the MRO in action. If super() went straight to A, you'd see D → B → A.
Part 4 works with StringIO and an open text file; other sources need the same usable read contract, not only that attribute name.
Part 6's BrokenStorage() raises immediately — that's the ABC contract being enforced.
Part 8 represents movement as data instead of one subclass per behavior. For two independent variation axes, compare a subclass per pair against two injected strategies; neither is a universal complexity theorem.
Goal: Refactor an inheritance mess into composition
Hint: A discount is a strategy — capture it as an object with apply(price) -> price and hand it to the Order. Adding a new discount type becomes adding a new strategy class, not changing the Order hierarchy.
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.
Worked C3 merge and enforcement counterexamples
For the diamond, merge [B, A, object], [C, A, object], and [B, C]. B is an eligible head; then A is blocked because it occurs in C's tail. Choose C, then A, then object, and prepend D. This derives [D, B, C, A, object] rather than merely memorizing it. Monotonicity means an ancestor ordering accepted in a base cannot be reversed by its subclass.
class X: pass
class Y: pass
class XY(X, Y): pass
class YX(Y, X): pass
try:
class Impossible(XY, YX): pass
except TypeError:
pass # contradictory X-before-Y and Y-before-X constraints
else:
raise AssertionError("inconsistent MRO should fail")
# A shallow runtime check is not signature validation.
class WrongReader:
def read(self, required_argument):
return 42
assert isinstance(WrongReader(), Readable)
try:
summarize_typed(WrongReader())
except TypeError:
pass
else:
raise AssertionError("runtime Protocol check cannot prove call compatibility")
# Replacing an abstract name makes this concrete even with the wrong signature.
class WrongStorage(Storage):
def save(self): pass
def load(self): return 42
bad = WrongStorage()
try:
bad.load("key")
except TypeError:
pass
else:
raise AssertionError("ABC did not validate the signature")Read a hierarchy by finding the public call, inspecting type(obj).__mro__, then walking each implementation and its delegation. Explicit Base.method(self) deliberately selects one implementation and bypasses cooperative MRO dispatch; use it only when that non-cooperative contract is intended. ABC virtual registration and duck typing give different membership promises; neither inserts a cooperative base into the MRO.
Worked discount strategy solution: order is part of the contract
The original holiday subclass replaces the 10% discount with 30%; it does not compound both. Preserve those formulas when refactoring: at price 100 the four original totals are 100, 90, 85 and 65. If combining percentage and fixed discounts, document the order because the operations do not commute.
from decimal import Decimal
from typing import Protocol
class DiscountStrategy(Protocol):
def apply(self, price: Decimal) -> Decimal: ...
class PercentOff:
def __init__(self, fraction: Decimal):
if not fraction.is_finite() or not 0 <= fraction <= 1:
raise ValueError("fraction must be between zero and one")
self.fraction = fraction
def apply(self, price):
return price * (1 - self.fraction)
class FixedOff:
def __init__(self, amount: Decimal):
if not amount.is_finite() or amount < 0:
raise ValueError("discount must be finite and nonnegative")
self.amount = amount
def apply(self, price):
return price - self.amount
class ComposedOrder:
def __init__(self, discounts=()):
self.discounts = tuple(discounts)
def total(self, price: Decimal):
if not price.is_finite() or price < 0:
raise ValueError("price must be finite and nonnegative")
for discount in self.discounts:
price = discount.apply(price)
return price
class StudentDiscount:
def apply(self, price: Decimal) -> Decimal:
return price * Decimal("0.95")
plain = ComposedOrder()
ten = PercentOff(Decimal("0.10"))
five = FixedOff(Decimal("5"))
assert plain.total(Decimal("100")) == Decimal("100")
assert ComposedOrder([ten]).total(Decimal("100")) == Decimal("90")
assert ComposedOrder([ten, five]).total(Decimal("100")) == Decimal("85")
assert ComposedOrder([PercentOff(Decimal("0.30")), five]).total(Decimal("100")) == Decimal("65")
assert ComposedOrder([StudentDiscount()]).total(Decimal("100")) == Decimal("95")ComposedOrder is the composed replacement for Order; the distinct name lets both examples run in one file. Adding StudentDiscount changes no order implementation. The example preserves the original formulas, including negative totals for small prices after a fixed discount. A real checkout must separately decide clipping, currency, rounding, tax order and promotion compatibility rather than silently changing those semantics during a refactor.
(d) Production reality · 15 min
Three extension-contract failure mechanisms
A non-cooperative mixin skips work. In the diamond above, replacing B's super().greet() with A.greet(self) changes the result to D → B → A, skipping C. Inspect D.__mro__ and each override's delegation before rearranging framework mixins. This is a local mechanism, not a reproduced Django authentication incident or a claim that Django recommends abandoning all nontrivial class-based views.
An accidental override becomes an API problem. A base that internally calls self.validate() invokes a subclass override too. Document which hooks may be overridden and keep collaborators small. @final helps a static checker flag prohibited overrides but does not enforce that prohibition at runtime. Composition narrows the collaborator surface; it does not eliminate contracts.
Capability axes do not need hierarchy levels. A request handler can hold logger, authorization and storage collaborators. Test authorization failure with a fake that rejects, and assert the store was never called. A deeper hierarchy may be valid for a documented framework lifecycle; “two levels” is a review prompt, not a language limit. Conversely, two independently implemented plugins sharing a stable template can justify introducing one common ABC rather than copying its lifecycle.
Where this shows up in the rest of the plan
Root: OOP choices ripple through every design session ahead
Label: Errors & debugging
Desc: Exception inheritance is useful for matching families of failures, but it is not the only valid use of inheritance.
Label: Dataclasses & pydantic
Desc: Dataclasses generate methods; validation libraries add their own runtime contracts. Neither removes inheritance from Python.
Label: FastAPI
Desc: Dependency injection is composition, not inheritance. Study how the framework doesn't force subclassing.
Label: PyTorch nn.Module
Desc: PyTorch DOES use deep inheritance — but well. Study its Module class for a good example of subclassable design.
Label: System design
Desc: The strategy, adapter, and decorator patterns are all composition. Design review classics.
Label: Code review
Desc: The single most useful comment: ‘could this be composition instead?’
(e) Recall + stretch · 10 min
Self-check (answer follows each question).
Recall — questions and worked answers
Q: State ‘favour composition over inheritance’ in one sentence, and give one reason.
A: Prefer object composition (HAS-A) to class inheritance (IS-A) because composition keeps parts independently swappable and testable, while inheritance couples the subclass to the parent's entire (often undocumented) API forever.
Q: What is the MRO, and why do we need it?
A: Method Resolution Order — the deterministic sequence in which Python searches a class's ancestors for a method. Computed by C3 linearisation; visible via Class.__mro__. Needed to disambiguate multiple inheritance predictably; earlier algorithms were deterministic too, not random.
Q: What does super() do in a method?
A: super() creates a proxy that searches after the defining class in the applicable MRO. The next class may not define the method, so lookup can continue to a more distant ancestor. In a diamond, it can visit a sibling, not just the lexical parent.
Q: When would you use ABC + @abstractmethod instead of a Protocol?
A: Use an ABC for explicit nominal membership and required abstract extension points; use a Protocol for static structural compatibility without inheritance. Ordinary abstract subclasses cannot be instantiated, but neither ABCs nor shallow runtime Protocol checks prove signatures or semantics. Static checkers and behavior tests supply different checks.
Q: Why can subclassing list or dict be a bad idea?
A: If an invariant depends on intercepting mutations, overriding one method does not establish that all inherited methods route through it. Inspect every relevant mutation path and test it. collections.UserList/UserDict can ease customization; composition can expose only the operations you intend. The needed overrides depend on the invariant, not a fixed method count.
Q: Explain the Liskov Substitution Principle in one sentence.
A: A subclass instance must be usable wherever an instance of the parent class is expected, without callers needing to know the difference. Break this and inheritance is a lie.
Q: Write a Protocol SupportsRead for anything with a .read() -> str method.
A: A structural interface with the required signature:
from typing import Protocol
class SupportsRead(Protocol):
def read(self) -> str: ...A checker accepts a compatible class without explicit inheritance. Runtime calls still need the promised behavior; annotations do not enforce it.
Q: When are mixins a good design, and when are they a smell?
A: Mixins work when their required state, signatures and cooperation rules are documented. Independent capabilities are simple; overlapping methods can also work with deliberate cooperative super(). Undocumented collisions or hidden state requirements are the smell, not all overlap.
Q: What does @final (PEP 591) do?
A: Signals to type checkers that a class or method must not be subclassed / overridden. Runtime doesn't enforce it, but mypy will flag violations. Useful when you want to document ‘this is not an extension point’.
Q: Stretch — you're designing a payment processor. You'll add many payment methods (Stripe, PayPal, ACH, crypto, ...). Design as inheritance vs composition vs Protocol. Which do you pick and why?
Stretch: true
A: Protocol is the right primary abstraction: class PaymentGateway(Protocol): def charge(self, amount: int, ref: str) -> ChargeResult: .... Each implementation (StripeGateway, PayPalGateway) is a plain class satisfying the protocol structurally. Composition wires them in: PaymentService(gateway=StripeGateway()). Inheritance would force a shared base class that gets bloated as different gateways need different features. Protocol keeps each impl clean AND lets you type-check calls.
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?
Model answers: (1) Compose when behavior varies through collaborators without a substitutability promise. (2) The MRO is the deterministic C3 order visible in type(obj).__mro__; super() searches after its defining class within that order. (3) A Protocol supports structural static compatibility, whereas an ABC supplies nominal grouping and an abstract-method instantiation guard; behavior still requires tests.
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.
Corrective source notes
- C3 MRO: local precedence, monotonicity and the merge rule.
- abc: abstract-method and virtual-subclass boundaries.
- typing: Protocol, runtime_checkable and final contracts.
- Python classes: inheritance and method dispatch.
Official Python 3.12 documentation snapshots checked 2026-10-09 support these specific claims; they are mutable maintenance pages, not whole-lesson certification. Local tests are finite; optional media, static type checking, external frameworks, application rendering and deployment are separate unperformed checks.
Pause / Recall / Apply
Can you explain it without the page?
Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.
Stored in this browser only. No account, no sync. Clearing browser data removes your record.