S010 · Inheritance, Composition & Polymorphism
When to inherit, when to compose, why 'favour composition' is a rule.
Module M01: Python Foundations · Session 10 of 130 · Track: SW 🧬
What you'll be able to do after this session
- Explain Inheritance, Composition & Polymorphism to a friend in one minute, out loud, no notes.
- Recognise it when you see it in real code / systems / papers.
- Complete the hands-on exercise at the end without looking up help.
Prerequisites
If you can't answer these in 30 seconds each, redo the linked session first:
Pre-read (skim before the session)
- 🎥 Intuition (5–15 min) · Composition over Inheritance — Fun Fun Function — The canonical 'why favour composition' explainer in 15 min.
- 🎥 Deep dive (30–60 min) · Python OOP Tutorial 4: Inheritance — Corey Schafer — Corey's careful walk-through of
super(), MRO, and subclassing. - 🎥 Hands-on demo (10–20 min) · Python's super() explained — mCoding — The one video that makes MRO and cooperative multiple inheritance click.
- 📖 Canonical article · Python Docs — Inheritance (§9.5) — The official reference for inheritance and
super(). - 📖 Book chapter / tutorial · Real Python — Inheritance and Composition: A Python OOP Guide — Long-form comparison of when to inherit vs when to compose.
- 📖 Engineering blog · Google Engineering Practices — Prefer composition to inheritance — Google's Python style guide's stance in one paragraph.
(a) Intuition · 5 min
The one-paragraph mental model. Why this concept exists, what problem it solves, and the analogy that makes it stick.
In one sentence: When to inherit, when to compose, why 'favour composition' is a rule.
Once you have classes, sooner or later two of them will share behaviour. Dog and Cat both have a name, age, and speak(). There are two ways to reuse code across them: inheritance ("Dog is-a Animal") and composition ("Car has-a Engine"). Both work; both are misused.
Inheritance creates a parent-child relationship: class Dog(Animal): gives Dog all of Animal's attributes and methods for free, and lets you override specific ones. It's beautiful when the "is-a" relationship is genuine and stable (a SavingsAccount is-an Account).
Composition gives one object another object as an attribute: class Car: def __init__(self): self.engine = Engine(). The car doesn't inherit from engine; it uses one. This is more flexible: you can swap engines, mock them for tests, and each class stays small.
Polymorphism is the payoff: any object with a speak() method can be treated as an animal. Python takes this to the extreme with duck typing — "if it walks like a duck and quacks like a duck, it's a duck." You don't need a common base class; you just need matching method names.
The industry consensus, from Google's style guide to the Gang of Four book, is: favour composition over inheritance. Inheritance is a strong coupling that gets painful past 2–3 levels deep. Composition is looser and easier to change.
Gotcha to carry forever: always call super().__init__(...) first in a subclass's __init__, or the parent's setup never runs. And avoid diamond inheritance (multiple inheritance where two parents share a grandparent) unless you truly understand Python's MRO — it's a source of subtle bugs even for experienced devs.
(b) Visual walkthrough · 15 min
Diagrams, worked examples, step-by-step visuals.
Inheritance vs composition, side by side:
When to use which:
| Situation | Prefer |
|---|---|
| "X is a specialised kind of Y" and Y is a stable interface | Inheritance |
| Sharing 1–2 helper methods across unrelated classes | Composition (or a plain function) |
| Behaviour that changes at runtime | Composition (swap the strategy) |
| Deep parent chains (> 2 levels) | Composition (flatten it) |
| Multiple orthogonal capabilities | Composition (mixins if you must) |
Worked example — inheritance done right:
class Animal:
def __init__(self, name: str, age: int):
self.name = name
self.age = age
def speak(self) -> str:
raise NotImplementedError("subclasses must implement speak()")
def __repr__(self) -> str:
return f"{type(self).__name__}(name={self.name!r}, age={self.age})"
class Dog(Animal):
def speak(self) -> str:
return f"{self.name} says Woof"
class Cat(Animal):
def __init__(self, name: str, age: int, indoor: bool = True):
super().__init__(name, age) # ← the crucial line
self.indoor = indoor
def speak(self) -> str:
return f"{self.name} says Meow"
animals: list[Animal] = [Dog("Snoopy", 5), Cat("Whiskers", 3, indoor=False)]
for a in animals: # polymorphism: same call, different behaviour
print(a.speak())Notice: Animal.speak raises to signal "must override"; each subclass overrides it; Cat.__init__ calls super().__init__ before adding its own field; the loop treats all animals uniformly.
Worked example — composition (Car HAS-A Engine):
class Engine:
def __init__(self, hp: int): self.hp = hp
def start(self): print(f"engine {self.hp}hp roars to life")
class ElectricEngine:
def __init__(self, kw: int): self.kw = kw
def start(self): print(f"engine {self.kw}kW whirs on")
class Car:
def __init__(self, brand: str, engine):
self.brand = brand
self.engine = engine # composition: any object with .start() fits
def go(self):
print(f"{self.brand} is driving —", end=" "); self.engine.start()
Car("Maruti", Engine(85)).go()
Car("Tesla", ElectricEngine(300)).go()You can swap engines without touching Car. That's the flexibility inheritance can't easily give you.
(c) Hands-on · 20 min
Code you type and run. Not pseudo-code — real, runnable, copy-pasteable.
Run each block. Predict output before running.
# s010_inheritance.py
# ---------------- 1. Basic single inheritance ----------------
class Shape:
def __init__(self, name: str):
self.name = name
def area(self) -> float:
raise NotImplementedError
def __repr__(self) -> str:
return f"{self.name}(area={self.area():.2f})"
class Circle(Shape):
def __init__(self, radius: float):
super().__init__("Circle")
self.radius = radius
def area(self) -> float:
return 3.14159 * self.radius ** 2
class Rectangle(Shape):
def __init__(self, w: float, h: float):
super().__init__("Rectangle")
self.w, self.h = w, h
def area(self) -> float:
return self.w * self.h
class Square(Rectangle): # inheritance chain: Square -> Rectangle -> Shape
def __init__(self, side: float):
super().__init__(side, side)
self.name = "Square"
shapes: list[Shape] = [Circle(3), Rectangle(4, 5), Square(2)]
for s in shapes:
print(s) # polymorphism in action
# ---------------- 2. isinstance / issubclass ----------------
print(isinstance(Square(1), Shape)) # True — grandparent counts
print(issubclass(Square, Rectangle)) # True
# ---------------- 3. super() with the MRO ----------------
class A:
def hi(self): print("A.hi")
class B(A):
def hi(self): print("B.hi"); super().hi()
class C(A):
def hi(self): print("C.hi"); super().hi()
class D(B, C):
def hi(self): print("D.hi"); super().hi()
D().hi()
print("MRO:", [cls.__name__ for cls in D.__mro__])
# MRO = D -> B -> C -> A -> object (cooperative multi-inheritance)
# ---------------- 4. Composition — the pluggable alternative ----------------
class ConsoleLogger:
def log(self, msg): print("[console]", msg)
class FileLogger:
def __init__(self, path): self.path = path
def log(self, msg):
with open(self.path, "a") as f: f.write(msg + "\n")
class Service:
def __init__(self, logger):
self.logger = logger # inject any object with .log(str)
def do_work(self):
self.logger.log("service started")
self.logger.log("work done")
Service(ConsoleLogger()).do_work()
Service(FileLogger("/tmp/svc.log")).do_work()
print(open("/tmp/svc.log").read())
# ---------------- 5. Duck typing — no shared base needed ----------------
class Dog:
def speak(self): return "Woof"
class Cat:
def speak(self): return "Meow"
class Robot:
def speak(self): return "01001000 01101001"
for thing in [Dog(), Cat(), Robot()]:
print(thing.speak()) # works — they all quackChecklist — what to observe:
Shape.arearaisesNotImplementedError— a common way to declare an abstract method (or useabc.ABC+@abstractmethodfor enforcement).Square(2)walks the chainSquare.__init__→super().__init__(2, 2)→Rectangle.__init__→Shape.__init__. Comment out anysuper().__init__and the chain breaks.- Step 3 prints
D.hi B.hi C.hi A.hi— Python's C3 MRO ensures each parent runs exactly once, in a deterministic order. - Step 4:
Servicedoesn't know or care what kind of logger it got. That's composition. - Step 5:
Dog,Cat,Robotshare no base class, yet the polymorphism works — duck typing.
Try this modification: rewrite Service twice — once with inheritance (class ConsoleService(Service): ...) and once with composition (as shown). Add a third logger (JSONLogger) to each version and compare how much code you had to touch.
(d) Production reality · 10 min
How this shows up in real systems, gotchas, war stories, common bugs.
War story — the deep inheritance tree. Django's class-based views originally had chains 6 levels deep. Overriding dispatch in a subclass sometimes had surprising interactions with LoginRequiredMixin, PermissionRequiredMixin, and 3 other mixins. The community eventually pushed back with function-based-view helpers and, ultimately, DRF's more composition-friendly ViewSet design. Rule: if you can't hold the full inheritance chain in your head, flatten it.
War story — the "brittle base class" problem. A team at a bank had class BaseModel with 40 helper methods. Twenty subclasses relied on the exact behaviour of BaseModel.save. One "harmless" refactor to save broke 14 subclasses in subtle ways, caught in staging by luck. Composition would have kept each model's save explicit and small.
How top teams write inheritance today (2025):
- Depth ≤ 2 as a rule of thumb. Beyond that, extract behaviour into components.
abc.ABC+@abstractmethodwhen you want the interpreter to refuse instantiation of an incomplete subclass.typing.Protocolfor duck-typed interfaces — you get static-type checking without forcing a base class (class Speaker(Protocol): def speak(self) -> str: ...). This is the modern Pythonic way.- Mixins for cross-cutting behaviour (e.g.
TimestampedMixinaddscreated_at/updated_at), but never more than 2–3 mixins per class. - Dataclasses inherit cleanly and are often preferable to hand-written class hierarchies.
When inheritance is genuinely right:
- Framework hooks (Django views, PyTorch
nn.Module, unittestTestCase) — you inherit because the framework needs a specific shape. - Modelling a real is-a hierarchy (
Employee→Manager→Director) where the parent's contract is stable. - Custom exceptions (
class DatabaseError(Exception):→class ConnectionLost(DatabaseError):).
Anti-patterns: using inheritance to share utility code (make it a module function instead); creating a base class "in case we need it later" (YAGNI); overriding a parent method without calling super() and then debugging why parent-side state is missing.
The one habit that will save you: when tempted to inherit, first ask "could this be a parameter I pass in?" (composition). Nine times out of ten the answer is yes and the code ends up smaller and more testable.
(e) Quiz + exercise · 10 min
- Explain "is-a" vs "has-a" with one example of each.
- Why must you call
super().__init__()in a subclass constructor? - What is duck typing, and how does it enable polymorphism without a shared base class?
- Give one concrete situation where inheritance is the right choice, and one where composition is.
- What does
Class.__mro__show, and why should you look at it before using multiple inheritance?
Stretch problem (connects to S009): starting from the BankAccount in S009, create two subclasses — SavingsAccount (adds apply_interest() using interest_rate) and CheckingAccount (adds an overdraft limit). Then rewrite the same functionality using composition: a plain BankAccount that takes a strategy object (InterestStrategy or OverdraftStrategy). Which version would be easier to add a third account type to, and why?
Explain-out-loud test
If you can't teach these 3 things to a friend without notes, redo the session:
- What is Inheritance, Composition & Polymorphism? (one sentence, no jargon)
- When would you use it? (one concrete example)
- When would you NOT use it? (one anti-pattern)
What comes next
- Next session (S011): Errors, Exceptions & Debugging with pdb
- Previous (S009): Classes & Objects — the OOP Mental Model
- Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: (a) intuition · (b) visual walkthrough · (c) hands-on · (d) production reality · (e) quiz + exercise. Duration: 60 minutes.
More from M01 · Python Foundations
all modules →