Inheritance and composition¶
Inheritance is the thing other languages spend half the object-orientation course on. Python has it, but Python programmers reach for it less often than you might expect. This notebook covers the mechanics — subclassing, super(), the MRO — and then spends at least as much time on when not to use it. The short form: prefer composition unless inheritance really does express what you mean.
The composition over inheritance concept essay goes deeper on the philosophy. Here we focus on the mechanics and the common traps.
Basic subclassing¶
A subclass inherits everything the parent class defines — attributes, methods, and any dunders. Listing the parent in parentheses is enough.
class Animal:
def __init__(self, name):
self.name = name
def describe(self):
return f"{self.name} is an animal"
class Dog(Animal):
def bark(self):
return f"{self.name} says woof"
d = Dog("Rex")
print(d.describe()) # inherited from Animal
print(d.bark()) # defined on Dog
super() — calling the parent's methods¶
When a subclass defines its own __init__, Python doesn't call the parent's __init__ automatically. If you want the parent to do its setup work, call super().__init__(...) explicitly.
class Vehicle:
def __init__(self, make, model):
self.make = make
self.model = model
class Car(Vehicle):
def __init__(self, make, model, num_doors):
super().__init__(make, model)
self.num_doors = num_doors
c = Car("Honda", "Civic", num_doors=4)
print(c.make, c.model, c.num_doors)
The same pattern works for any method. Call super().method_name(...) to delegate to the parent's version — useful when you want to extend rather than replace the parent's behaviour:
class LoudCar(Car):
def __init__(self, make, model, num_doors):
super().__init__(make, model, num_doors)
print(f"VROOM — new {make} {model} arrived")
lc = LoudCar("Ford", "Mustang", num_doors=2)
Method resolution order¶
When you look up an attribute on an instance and multiple classes in the hierarchy could provide it, Python follows a deterministic path called the method resolution order (MRO). For single inheritance it's simply child-then-parent. For multiple inheritance it's more involved, computed by the C3 linearisation algorithm.
You can inspect it:
class A:
def who(self): return "A"
class B(A):
def who(self): return "B"
class C(A):
def who(self): return "C"
class D(B, C):
pass
print([cls.__name__ for cls in D.__mro__])
print(D().who())
Reading the MRO from left to right: a D instance looks for who on D, then B, then C, then A, then object. It finds who on B and stops. That's C3 linearisation in action.
In practice, if you find yourself reasoning carefully about MRO to understand your own code, that's a strong signal the design has got too clever. Stick to single inheritance plus small mixins, and the MRO stays easy to reason about.
The is-a / has-a trap¶
The classic inheritance mistake: reaching for class Child(Parent) when the relationship is actually "has a" rather than "is a". Here's a concrete example — a Stack that inherits from list:
class Stack(list):
def push(self, item):
self.append(item)
def pop_top(self):
return self.pop()
s = Stack()
s.push(1)
s.push(2)
print(s.pop_top())
That works, but it's wrong. A stack should only allow access at the top — push and pop. Our Stack inherited the full list interface, so callers can reach past the abstraction:
s = Stack()
s.push(1)
s.push(2)
s.insert(0, 99) # legal on a list, violates stack semantics
s[0] = 42 # also legal, also wrong
print(s)
The fix is composition: a Stack has a list internally, and exposes only the operations that preserve its invariants.
class Stack:
def __init__(self):
self._items = []
def push(self, item):
self._items.append(item)
def pop(self):
return self._items.pop()
def __len__(self):
return len(self._items)
def __repr__(self):
return f"Stack({self._items!r})"
s = Stack()
s.push(1)
s.push(2)
print(s, len(s))
try:
s.insert(0, 99) # no such method — the abstraction holds
except AttributeError as e:
print(f"{type(e).__name__}: {e}")
The test for whether inheritance is appropriate: can the subclass be used anywhere the parent is expected without surprising the caller? If Stack inherits from list, callers who receive a Stack reasonably expect .insert() to work like it does on a list — but your whole point in making a Stack was to disallow that. The inheritance relationship is lying.
Where inheritance earns its place¶
Inheritance is genuinely useful in a handful of situations. The most common is exception hierarchies:
class AppError(Exception):
"""Base exception for this application."""
class ValidationError(AppError):
"""User-supplied data was malformed."""
class AuthError(AppError):
"""User was not permitted."""
try:
raise ValidationError("email missing @")
except AppError as e:
print(f"caught {type(e).__name__}: {e}")
The hierarchy lets callers catch broadly (except AppError) or specifically (except ValidationError) as they prefer. That's a clean fit for inheritance: subclasses are genuinely specialisations of their parent, all exception-shaped.
Other good fits:
- Framework extension points — subclassing Django's
View, PyTorch'snn.Module, or scikit-learn'sBaseEstimator. The framework expects specific hooks and inheritance is how you provide them. - Abstract base classes (briefly below) — declaring an interface that subclasses must implement.
- Small mixins — a class whose sole job is adding one capability, combined with the main class through multiple inheritance. Use sparingly.
Abstract base classes¶
abc.ABC lets you declare methods that subclasses must implement. Python enforces this at instantiation time — you can't make an instance of a class that still has unimplemented abstract methods.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
...
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
print(Circle(5).area())
try:
Shape() # can't instantiate — area is abstract
except TypeError as e:
print(f"{type(e).__name__}: {e}")
ABCs are useful when you're building a framework and want to document contracts for user code. For smaller internal projects they're often more ceremony than they earn — typing.Protocol (covered in the type hints guide) gives you structural typing without requiring the inheritance relationship.
Exercise¶
You have a Logger class:
class Logger:
def __init__(self, name):
self.name = name
def log(self, level, message):
print(f"[{level.upper()}] {self.name}: {message}")
You want to add a TimestampedLogger that prepends the current time to each message. Implement it as a subclass that overrides log and calls super().log(...) for the actual output.
Then — as a separate exercise — implement a MetricsReporter that writes metrics somewhere. The catch: MetricsReporter needs logging, but it isn't itself a kind of logger. Should it inherit from Logger or compose one as a field? Justify your choice in a comment, then write the version you think is right.
# Your code here
Solution
from datetime import datetime
class Logger:
def __init__(self, name):
self.name = name
def log(self, level, message):
print(f"[{level.upper()}] {self.name}: {message}")
# Inheritance is appropriate: TimestampedLogger IS a Logger —
# it supports the same interface and the same call sites work unchanged.
class TimestampedLogger(Logger):
def log(self, level, message):
ts = datetime.now().isoformat(timespec="seconds")
super().log(level, f"[{ts}] {message}")
# Composition is appropriate: MetricsReporter HAS-A Logger.
# A MetricsReporter is not a kind of logger — it's a thing that
# happens to use logging. Inheriting from Logger would let callers
# treat a MetricsReporter as a drop-in Logger, which is wrong.
class MetricsReporter:
def __init__(self, name):
self.logger = Logger(name)
def report(self, metric, value):
# ... send metric to metrics backend ...
self.logger.log("info", f"reported {metric}={value}")
Recap¶
- Subclassing inherits the parent's attributes and methods.
class Child(Parent):is all it takes. super()calls the parent's version of a method. Use it in overrides and in__init__.- The MRO determines which method wins in multi-inheritance. Keep hierarchies shallow and you'll rarely need to think about it.
- Favour composition over inheritance. Reach for inheritance only when the subclass genuinely is a kind of the parent, usable anywhere the parent is expected.
- Good fits for inheritance: exception hierarchies, framework extension points, abstract base classes, small mixins.
Next: Class attributes, properties, classmethods, and staticmethods — the supporting cast that rounds out a class definition.