Inheritance and composition
Inheritance is the feature people most associate with object-oriented programming, and the one most often misused. This lesson covers how it works, then spends the second half on why you usually want composition instead.
How it works
class Expense:
def __init__(self, amount: float, note: str = ""):
self.amount = amount
self.note = note
def describe(self) -> str:
return f"₹{self.amount:,.2f} — {self.note}"
class RecurringExpense(Expense):
def __init__(self, amount: float, note: str = "", months: int = 1):
super().__init__(amount, note)
self.months = months
def total_cost(self) -> float:
return self.amount * self.months
class RecurringExpense(Expense) means it inherits everything Expense has.
rent = RecurringExpense(15000, "flat", months=12)
print(rent.describe()) # inherited
print(rent.total_cost()) # its own
₹15,000.00 — flat
180000
super().__init__(amount, note) calls the parent's __init__. Call it, and
call it first. Forget, and the attributes it sets never exist, giving you an
AttributeError somewhere confusing later.
Overriding
A child can replace a parent's method:
class RecurringExpense(Expense):
def describe(self) -> str:
return f"₹{self.amount:,.2f} — {self.note} (x{self.months} months)"
Same name, different behaviour. Python uses the most specific version it finds.
Often you want to extend rather than replace:
def describe(self) -> str:
base = super().describe()
return f"{base} (x{self.months} months)"
super() inside an override calls the parent's version, so you build on it
instead of duplicating it.
isinstance
print(isinstance(rent, RecurringExpense)) # True
print(isinstance(rent, Expense)) # True — it is both
print(type(rent) is Expense) # False
isinstance respects inheritance; type() is does not. Prefer isinstance
when you are checking types at all — though needing to check often means the
design could be better.
The trap: inheriting for reuse
Here is where it goes wrong. You have a User class with useful methods, and
you need an AdminUser, so:
class AdminUser(User):
...
That seems efficient. But inheritance does not mean "borrow some code" — it
means "is a kind of", and it makes a promise: an AdminUser can be used
anywhere a User is expected, behaving sensibly.
Break that promise and you get bugs that are hard to see. The classic:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Square(Rectangle):
def __init__(self, size):
super().__init__(size, size)
A square is a rectangle, mathematically. But:
def stretch(rectangle):
rectangle.width = 10
return rectangle.area()
print(stretch(Square(5)))
Returns 50. A square with a width of 10 and a height of 5 is not a square. The function is reasonable, the class is reasonable, and together they are wrong.
The test: can a child be used anywhere the parent can, without surprising the caller? If not, it should not inherit — whatever the real-world relationship.
Deep hierarchies
Animal → Mammal → Carnivore → Feline → Cat → HouseCat
Textbooks love these. Real code suffers from them. Understanding HouseCat
means reading six files, a change to Animal can break things four levels down,
and behaviour is scattered across a chain instead of sitting in one place.
Two levels is usually plenty. Three is worth questioning.
Composition
The alternative, and usually the better answer: instead of being a thing, have one.
class Engine:
def start(self) -> str:
return "Engine started"
class Car:
def __init__(self):
self.engine = Engine() # has an engine
def start(self) -> str:
return self.engine.start()
A car is not a kind of engine; it has one. That is composition.
Back to the expense example. Suppose you need different tax treatments:
class Expense:
def __init__(self, amount: float, tax_rate: float = 0.18):
self.amount = amount
self.tax_rate = tax_rate
def total(self) -> float:
return round(self.amount * (1 + self.tax_rate), 2)
Inheritance would have given you GstExpense, ZeroRatedExpense,
ReducedRateExpense — three classes to express one number. A parameter is
better.
When the difference is genuinely behavioural, pass in the behaviour:
class Expense:
def __init__(self, amount: float, tax_strategy):
self.amount = amount
self.tax_strategy = tax_strategy
def total(self) -> float:
return self.tax_strategy(self.amount)
def gst(amount: float) -> float:
return round(amount * 1.18, 2)
def zero_rated(amount: float) -> float:
return amount
print(Expense(250, gst).total()) # 295.0
print(Expense(250, zero_rated).total()) # 250
Functions are values, from module 5. No inheritance, no new classes, and adding a fourth tax treatment is one function.
Why composition usually wins
- Flexible at runtime. You can change what an object has; you cannot change what it is.
- Easier to test. Pass in a simple stand-in rather than constructing a hierarchy.
- Shallower. Behaviour sits where it is used.
- No fragile base class. Changing a parent cannot silently break children you forgot about.
The common advice is "prefer composition over inheritance", and it holds up. Reach for inheritance when there is a genuine "is a kind of" relationship and the child is substitutable. Reach for composition the rest of the time.
Where inheritance does earn its place
Being fair to it:
Framework base classes. Django models, exception classes. You inherit because the framework requires it, and the relationship is real.
Your own exceptions, from module 6:
class InsufficientStockError(Exception):
"""Raised when an order asks for more than is available."""
That is inheritance, and it is correct — your error genuinely is a kind of
exception, and except Exception should catch it.
Abstract base classes, where a parent defines an interface children must implement:
from abc import ABC, abstractmethod
class Storage(ABC):
@abstractmethod
def save(self, data: dict) -> None: ...
@abstractmethod
def load(self) -> dict: ...
class JsonStorage(Storage):
def save(self, data: dict) -> None: ...
def load(self) -> dict: ...
ABC makes Storage uncreatable on its own, and a subclass missing a method
fails at creation rather than later. This is inheritance used for a contract
rather than for code reuse, which is its best use.
Check your work
Forgetting super().__init__() means the parent's attributes are never set,
giving AttributeError somewhere confusing later rather than at the point of
the mistake.
The Square problem.
def stretch(rectangle):
rectangle.width = 10
return rectangle.area()
stretch(Square(5)) # 50
A square with width 10 and height 5. The function is reasonable, the class is reasonable, and together they are wrong. The test: can a child be used anywhere the parent can without surprising the caller?
Three subclasses versus one parameter. The subclass version is roughly
thirty lines for what a single tax_rate parameter expresses. Adding a fourth
treatment is a new class against a new argument.
Passing a function in is better still: no inheritance, no new classes, and a fourth tax treatment is one function. Functions are values, from module 5.
Car(Engine) says "a car is a kind of engine", which is false. A car has
an engine — that is composition, and it is the usual right answer.
Abstract base classes. Instantiating Storage directly raises
TypeError: Can't instantiate abstract class. A subclass missing a method fails
at creation rather than later. That is inheritance used for a contract rather
than for code reuse, which is its best use.
Custom exceptions are legitimate inheritance too — your error genuinely is a
kind of exception, and except Exception should catch it.
Practice
- Write
ExpenseandRecurringExpenseas above. Confirm the child has both its own and inherited methods. - Remove the
super().__init__()call. Read the error and explain it. - Override
describeto replace the parent's, then to extend it withsuper(). - Build the
Rectangle/Squareexample and thestretchfunction. Watch it produce an invalid square. - Rewrite the tax example with three subclasses, then with one parameter. Compare the line counts.
- Rewrite it again passing a function in. Add a fourth tax treatment to both versions and see which was easier.
- Write
CarandEngineusing composition. Then write it withCar(Engine)and say what is wrong with that sentence. - Define a custom exception inheriting from
Exceptionand catch it specifically. - Write an abstract
Storagewith two implementations. Try to instantiateStoragedirectly, and try a subclass missing a method.
Next: the dunder methods, which make your objects work with Python's own syntax.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship