Search Tech Journey

Find topics, journeys and posts

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

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)


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

SituationPrefer
"X is a specialised kind of Y" and Y is a stable interfaceInheritance
Sharing 1–2 helper methods across unrelated classesComposition (or a plain function)
Behaviour that changes at runtimeComposition (swap the strategy)
Deep parent chains (> 2 levels)Composition (flatten it)
Multiple orthogonal capabilitiesComposition (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 quack

Checklist — what to observe:

  1. Shape.area raises NotImplementedError — a common way to declare an abstract method (or use abc.ABC + @abstractmethod for enforcement).
  2. Square(2) walks the chain Square.__init__super().__init__(2, 2)Rectangle.__init__Shape.__init__. Comment out any super().__init__ and the chain breaks.
  3. 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.
  4. Step 4: Service doesn't know or care what kind of logger it got. That's composition.
  5. Step 5: Dog, Cat, Robot share 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 + @abstractmethod when you want the interpreter to refuse instantiation of an incomplete subclass.
  • typing.Protocol for 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. TimestampedMixin adds created_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, unittest TestCase) — you inherit because the framework needs a specific shape.
  • Modelling a real is-a hierarchy (EmployeeManagerDirector) 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

  1. Explain "is-a" vs "has-a" with one example of each.
  2. Why must you call super().__init__() in a subclass constructor?
  3. What is duck typing, and how does it enable polymorphism without a shared base class?
  4. Give one concrete situation where inheritance is the right choice, and one where composition is.
  5. 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:

  1. What is Inheritance, Composition & Polymorphism? (one sentence, no jargon)
  2. When would you use it? (one concrete example)
  3. When would you NOT use it? (one anti-pattern)

What comes next


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 →
  1. 005Python Variables & Types — Mental Model of Memory
  2. 006Control Flow — if/else, loops, comprehensions
  3. 007Functions — arguments, scope, closures
  4. 008Data Structures — list, tuple, dict, set (when to use what)
  5. 009Classes & Objects — the OOP Mental Model
  6. 011Errors, Exceptions & Debugging with pdb