Search Tech Journey

Find topics, journeys and posts

6-month learning plan10 / 130
back to blog
pythonbeginner 55m read

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.

🧠SoftwareM01 · Python Foundations· Session 010 of 130 90 min

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

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



(a) Intuition · 5 min

Inheritance vs composition — is-a vs has-a
🌍 Real world

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.

💻 Code world

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

Reach for these in order — from least coupling to most
  • 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

  1. 1967
    Simula 67 — classes + inheritance
    The birth of OOP. Object, class, inheritance, virtual methods — invented together.
  2. 1987
    Design Patterns · Gang of Four (published 1994)
    ‘Favour object composition over class inheritance.’ Still valid 30 years later.
  3. 1994
    Liskov Substitution Principle
    Barbara Liskov's rule: a subclass must be usable ANYWHERE its parent is. Half of bad inheritance breaks this.
  4. 2001
    Python 2.2 — MRO by C3 linearisation
    Multiple inheritance stops being random-order; C3 gives a predictable, monotonic MRO.
  5. 2019
    PEP 544 · Protocols
    Python 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

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

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

Duck typing

Just try it

  • def read(f): return f.read()
  • Anything with .read() works — file, StringIO, http response
  • Zero declarations, zero enforcement
  • The Python default
Protocols (PEP 544)

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
Abstract Base Classes (ABC)

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
Single inheritance

Classic is-a

  • class Dog(Animal):
  • Overrides + super() for extension
  • Simple, clear, hard to misuse
  • The workhorse for real is-a relationships
Multiple inheritance / mixins

Power tool

  • class UserView(LoginRequired, ListView):
  • Mixins add ORTHOGONAL behaviour
  • Requires understanding MRO
  • Django's whole class-based-view system

The decision tree

1step 1
Is there a real is-a relationship?

If no → composition or a plain function. If yes → continue.

2step 2
Do subclasses need to be interchangeable with the parent?

If no (‘I just want to re-use some code’) → composition. If yes → continue.

3step 3
Do you need runtime enforcement of the contract?

If no → duck typing or Protocol (structural). If yes → ABC + @abstractmethod.

4step 4
One parent or many?

One → clean single inheritance. Many → mixins (only if truly orthogonal). Avoid diamond hierarchies.

The mental model to hold


Common misconception
✗ What most people think

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

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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 Square
From first principles
Start with the 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.

  1. 1
    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.
    forced by · attribute lookup must return exactly one thing, every time, for the same class
  2. 2
    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.
    forced by · otherwise the base list would be meaningless and overriding would be unpredictable
  3. 3
    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.
    forced by · overriding is the entire purpose of subclassing
  4. 4
    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.
    forced by · going all the way up the first branch reaches the common ancestor before the second branch is examined at all
  5. 5
    C3 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 TypeError at class-creation time is strictly better than a silently wrong lookup at runtime
⇒ Therefore

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.

Mental modelInheritance is a promise; composition is a wire

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 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.
🔔 Fires when you see

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.

The tradeoff

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?

Abstract base class + template method
+ you gain the shared skeleton is written once and cannot be skipped; abstractmethod makes the extension points explicit and fails at instantiation if unimplemented; a new step is one small subclass
− you pay 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 when the skeleton is genuinely stable, extension points are few and known, and steps must be discovered/registered polymorphically
Composition with injected collaborators
+ you gain each piece is independently testable with a fake; behaviour is swappable at runtime and per-environment; no hierarchy to navigate when reading
− you pay 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 when 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
Plain functions + a decorator or two
+ you gain the least machinery, trivially testable, and retry/logging as decorators are composable and reusable across unrelated code
− you pay shared state must be passed explicitly, which gets awkward past a few parameters; no enforced structure, so drift between steps is easy
pick when the steps are stateless transforms and the shared logic is genuinely cross-cutting rather than structural — which describes most ETL steps honestly examined
What a senior engineer actually does

Start 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

Part 1 · single inheritance
The clean is-a case. Every subclass overrides speak; the parent provides shared __init__ and __repr__.
clean
Part 2 · MRO + super()
The diamond D(B, C) shows why super() follows the MRO, not the parent-child edge. C3 linearisation is deterministic.
mro
Part 3 · composition
Car has-a drivetrain. Swap the piece; the Car code doesn't change. This is what senior codebases mostly look like.
compose
Part 4 · duck typing
summarize accepts anything with .read(). Zero declarations. The Pythonic default when types aren't required.
duck
Part 5 · Protocol
The same idea, statically typed. mypy checks structure; @runtime_checkable enables isinstance() checks.
protocol
Part 6 · ABC
@abstractmethod prevents instantiation until every abstract method is implemented. Use for real contracts (plugin systems, DB drivers).
enforce
Part 7 · mixins
Orthogonal capabilities added via MI. Works when the mixins truly don't overlap. Otherwise → composition.
mixin
Part 8 · trade-off
The bird example: inheritance forces new subclasses for every behaviour combo. Composition scales linearly.
compare
Try itRefactor an inheritance mess into composition

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 - 5

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

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

(d) Production reality · 15 min

War story Common failure mode · every framework userDjango class-based views mystery bugs
🔥 What broke

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?’

🧯 The fix

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.

🎓 Lesson to steal
Multiple inheritance is a power tool. When you're gluing together library-defined mixins whose interactions you can't see at a glance, escape to composition or plain functions.
War story Common failure mode · library authorsUsers can't subclass without breaking things
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
Inheritance is a public API. Once you expose a class for subclassing, every internal method is a de-facto public API — changing it will break users. Composition sidesteps this entirely.
War story Common failure mode · Python-in-Java teamsOver-engineered class hierarchies that never scale
🔥 What broke

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.

🧯 The fix

Flatten to a single ConcreteHandler that takes injected collaborators: ConcreteHandler(logger=..., auth=..., store=...). Each collaborator is testable in isolation. New capabilities = new collaborators.

🎓 Lesson to steal
Any inheritance chain deeper than 2 levels in application code is a smell. Frameworks (Django, PyTorch) have deep hierarchies because that's their public API; your application code isn't a framework — flat wins.

Where this shows up in the rest of the plan

OOP choices ripple through every design session ahead
S011 · Errors & debugging
Exception hierarchies are the ONE place inheritance is unambiguously right.
S014 · Dataclasses & pydantic
Both replace inheritance-based ‘model’ patterns with composition + typing.
S057 · FastAPI
Dependency injection is composition, not inheritance. Study how the framework doesn't force subclassing.
S110 · PyTorch nn.Module
PyTorch DOES use deep inheritance — but well. Study its Module class for a good example of subclassable design.
S120 · System design
The strategy, adapter, and decorator patterns are all composition. Interview classics.
S099 · Code review
The single most useful comment: ‘could this be composition instead?’

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three without notes, redo the session:

  1. When do you use composition instead of inheritance? (one rule)
  2. What's the MRO? (one sentence + how you look it up)
  3. 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.