Part 2 · Intermediate Python · Module 12
Inheritance, Composition & Protocols
Classes rarely live alone. This module is about how types relate: reusing behavior with inheritance, combining objects with composition, and describing "anything that can do X" with abstract base classes and protocols. Choosing well between these is one of the core design skills of a professional.
- Create subclasses, override methods, and extend behavior with
super() - Explain the method resolution order (MRO) and read
__mro__for multiple inheritance - Define abstract base classes with
abc.ABCand@abstractmethod - Describe interfaces structurally with
typing.Protocol - Use mixins for small reusable capabilities
- Refactor inheritance into composition and justify the choice with the Liskov substitution principle
- Choose between
isinstancechecks and duck typing
Inheritance and overriding
Inheritance lets a new class, the subclass (or child), start from an existing class, the base class (or parent, or superclass), and then add or change behavior. It models an "is-a" relationship: a Dog is an Animal. You write the base class in parentheses:
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "..."
def describe(self):
return f"{self.name} says {self.speak()}"
class Dog(Animal): # Dog inherits everything from Animal
def speak(self): # ...and overrides one method
return "Woof"
Recall the attribute lookup rule from M10: instance first, then the class. Inheritance extends the chain: instance, then the class, then its base, then its base, ending at object, the root of every class. When Animal.describe calls self.speak(), the lookup starts at type(self), which is Dog, so the dog's version runs. This is polymorphism: one call site, different behavior depending on the object's actual type.
describe is a small example of the template method pattern: the base class defines the overall algorithm and calls a "hook" method that subclasses fill in.
Remember: a subclass inherits everything and overrides what it needs; method lookup always starts from the object's actual type.
Extending behavior with super()
Overriding replaces a method completely. Often you want to extend it instead: do what the parent does, plus a bit more. super() returns a proxy object that looks up methods starting after the current class in the inheritance chain. The most common use is in __init__:
class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise ValueError("insufficient funds")
self.balance -= amount
class SavingsAccount(Account):
def __init__(self, owner, balance=0, rate=0.02):
super().__init__(owner, balance) # let Account set its fields
self.rate = rate # then add our own
def withdraw(self, amount):
if amount > 500:
raise ValueError("savings withdrawals are limited to 500")
super().withdraw(amount) # reuse the parent's rules
Forgetting to call super().__init__ is a classic bug: the subclass's instances never get the parent's attributes, and you see an AttributeError far from the cause. Step through the chain:
Remember: super().method(...) calls the next implementation in the chain; always call super().__init__ when you override __init__.
Multiple inheritance and the MRO
Python lets a class have several bases: class D(B, C):. That raises a question: if both B and C define hello, which runs? The answer is the method resolution order (MRO): a single, linear list of classes that Python searches, in order, for every attribute lookup. You can read it from D.__mro__ or D.mro().
Python computes the MRO with the C3 linearization algorithm, which guarantees three things:
- A class always comes before its bases.
- Bases appear in the order you listed them (
BbeforeCinclass D(B, C)). - Each class appears exactly once, even in a "diamond" where two parents share a grandparent.
classDiagram
direction BT
class A { +hello() }
class B { +hello() }
class C { +hello() }
class D
B --|> A
C --|> A
D --|> B
D --|> C
For this diamond, the MRO is D → B → C → A → object. Note that C comes before A even though A is B's parent: C3 delays a class until every class that inherits from it has been listed.
This shows the deeper truth about super(): it does not mean "my parent". It means "the next class in the MRO of the object's type". Inside B.hello, when self is a D, super() refers to C, a class B doesn't even know about. That is what makes cooperative multiple inheritance possible: if every class calls super(), each implementation in the chain runs once.
class Broken(Base, Child) asks for Base before Child, but rule 1 says Child must precede its base. No order satisfies both, so Python raises TypeError: Cannot create a consistent method resolution order (MRO).
Remember: attribute lookup follows __mro__; super() means "next in the MRO of type(self)", not "my parent".
Abstract base classes
Sometimes a base class exists only to define what its subclasses must do. A generic Shape has no area; only circles and squares do. An abstract base class (ABC) makes that explicit. Mark the required methods with @abstractmethod, and Python refuses to create instances of any class that still has unimplemented abstract methods. Mistakes surface when the object is created, not later, deep inside some call.
The standard library ships many ABCs in collections.abc: Iterable, Sequence, Mapping, Callable, and more. Inheriting from one gives you mixin methods for free; for example, a Sequence subclass that implements __getitem__ and __len__ receives __contains__, __iter__, __reversed__, index, and count.
Remember: an ABC with @abstractmethod declares required methods and blocks instantiation until they're implemented; collections.abc ABCs also supply ready-made methods.
Protocols: duck typing, formalized
ABCs use nominal typing: an object counts as a Shape only if its class explicitly inherits from Shape. Python's everyday style is duck typing: any object with an area() method can be used where an area is needed, no inheritance required. typing.Protocol lets you write down that implicit contract so type checkers such as mypy can verify it. This is structural typing: compatibility is determined by structure (which methods exist), not by ancestry.
Neither Rug nor Pond inherits from HasArea, yet both satisfy it. The protocol matters in two places:
- Statically: when you run
pixi run mypy, passing an object withoutareatototal_areais reported as an error before the program runs. You will use this heavily in M16. - At runtime, only if decorated with
@runtime_checkable:isinstance(x, HasArea)checks that the methods exist. It does not check their signatures or return types.
ABC (abc.ABC) | Protocol (typing.Protocol) | |
|---|---|---|
| Relationship | Nominal: must inherit (or register) | Structural: just have the methods |
| Checked | At instantiation (missing abstract methods) | By the type checker; optionally isinstance |
| Can share code | Yes, concrete methods are inherited | Not usefully for implementers that don't inherit |
| Best for | A family of classes you control | Accepting third-party or unrelated objects |
Remember: a Protocol describes required methods structurally; any object with them qualifies, no inheritance needed.
Mixins
A mixin is a small class that provides one focused capability and is meant to be combined with other classes through multiple inheritance. It isn't a complete thing on its own (you'd never instantiate JsonMixin by itself) and typically has no __init__. By convention its name ends in Mixin, and mixins are listed before the main base class so their methods take priority in the MRO.
The standard library uses mixins too: socketserver.ThreadingMixIn, and the collections.abc classes you just met act as mixins for container methods.
Remember: a mixin adds one capability, has no state of its own, and goes left of the main base.
Composition over inheritance
Composition builds a class by holding other objects as attributes and delegating work to them: a "has-a" relationship. A Car has an Engine; it isn't one. The famous design advice "favor composition over inheritance" (from the Design Patterns book) exists because inheritance couples the child to every detail of the parent, including methods it doesn't want.
Here is a realistic refactoring. A developer wants a stack and inherits from list to save typing:
class Stack(list):
def push(self, item):
self.append(item)
def peek(self):
return self[-1]
s = Stack()
s.push(1); s.push(2)
s.insert(0, 99) # oops: breaks stack discipline
s.sort() # also allowed
print(isinstance(s, list)) # True: callers may treat it as a list
The stack has inherited every list method (insert, sort, extend, remove, reverse, plus operators like __setitem__ and +), and every one of which can break the "last in, first out" invariant. You can't remove them without fighting the language.
class Stack:
def __init__(self):
self._items = [] # has-a list, hidden
def push(self, item):
self._items.append(item) # delegate
def pop(self):
if not self._items:
raise IndexError("pop from empty stack")
return self._items.pop()
def peek(self):
if not self._items:
raise IndexError("peek at empty stack")
return self._items[-1]
def __len__(self):
return len(self._items)
The public interface is exactly the operations a stack should have. The list is an implementation detail you could swap for collections.deque without changing any caller.
Composition also beats inheritance when behavior varies along several independent axes. Suppose reports can be formatted as text or HTML, and delivered by email or file. Inheritance needs a class for every combination (TextEmailReport, HtmlFileReport, ...): m × n classes. Composition needs m + n small classes, combined at runtime:
This is the strategy pattern. Each collaborator is small and testable alone, and in tests you can pass a fake sink that records output instead of printing.
Remember: inherit for genuine "is-a" with full substitutability; compose ("has-a") to reuse behavior without inheriting an interface you don't want.
The Liskov substitution principle
Barbara Liskov's principle (the "L" in SOLID) gives the precise test for when inheritance is correct: anywhere code expects a base-class object, a subclass object must work without the code noticing. A subclass may accept more and promise more, but it must not demand more or deliver less. Concretely, an override must not:
- require stricter inputs (raise on arguments the parent accepted);
- return something the caller can't use the same way;
- break invariants the parent guaranteed;
- raise new kinds of exceptions callers couldn't anticipate.
The classic violation is Square(Rectangle). Mathematically a square is a rectangle, but a mutable rectangle promises that setting the width leaves the height alone:
stretch is correct for every Rectangle, but a Square quadruples instead of doubling. The fix isn't a cleverer override; it's a different design: make shapes immutable (then "set width" returns a new shape, and the problem disappears), or make Square and Rectangle siblings under a common Shape ABC.
Remember: a subclass must be usable anywhere its base is expected. If you need to weaken a parent's promise, don't inherit.
isinstance vs duck typing
When a function receives an object, should it check the type first? Python culture leans toward EAFP, "easier to ask forgiveness than permission": just use the object and handle the exception if it doesn't fit. The opposite style, LBYL ("look before you leap"), checks first.
Too strict (LBYL on concrete types)
def total(values):
if not isinstance(values, list):
raise TypeError("need a list")
return sum(values)
total((1, 2)) # rejected, though it would work
Duck typing (EAFP)
def total(values):
return sum(values) # any iterable of numbers
total((1, 2)); total({3, 4}); total(range(5))
Guidelines professionals follow:
- Prefer duck typing; check against the least specific abstraction you need, such as
collections.abc.Iterableor a Protocol, notlist. isinstanceis the right tool when behavior genuinely differs by type: inside__eq__/__add__(M11), when parsing heterogeneous data (a JSON value may be a dict, list, or str), and at system boundaries where you validate input.matchwith class patterns (M04) is often the clearest way to branch by type.- Never use
type(x) == SomeClass; it rejects subclasses and breaks Liskov. Useisinstance. - Let static type hints and protocols do most "type checking" before the program runs (M16).
Remember: use objects by what they can do; reserve isinstance (against abstract types) for real type-dependent branching and input validation.
Practice
Given the base class Employee, write subclasses Manager and Intern that override bonus_rate(): managers get 0.2, interns 0.0. Do not override bonus(); it must use the hook. Manager also takes a list of reports (other employees) as a third initializer argument and must call super().__init__.
Hint
class Manager(Employee): with def __init__(self, name, salary, reports): super().__init__(name, salary); self.reports = reports.
Show solution
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def bonus_rate(self):
return 0.1
def bonus(self):
return self.salary * self.bonus_rate()
class Manager(Employee):
def __init__(self, name, salary, reports):
super().__init__(name, salary)
self.reports = reports
def bonus_rate(self):
return 0.2
class Intern(Employee):
def bonus_rate(self):
return 0.0
Write LimitedAccount(Account) that takes an extra daily_limit. Its withdraw(amount) raises ValueError if the total withdrawn today (tracked in withdrawn_today, starting at 0) would exceed the limit; otherwise it calls the parent's withdraw (which still rejects overdrafts) and then adds to withdrawn_today.
Hint
Check the limit first, then call super().withdraw(amount), and only after it succeeds update withdrawn_today.
Show solution
class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise ValueError("insufficient funds")
self.balance -= amount
class LimitedAccount(Account):
def __init__(self, owner, balance=0, daily_limit=500):
super().__init__(owner, balance)
self.daily_limit = daily_limit
self.withdrawn_today = 0
def withdraw(self, amount):
if self.withdrawn_today + amount > self.daily_limit:
raise ValueError("daily limit exceeded")
super().withdraw(amount)
self.withdrawn_today += amount
Write a base class Widget whose classes() returns ["widget"], and two mixins RoundedMixin and ShadowMixin whose classes() return their own tag ("rounded", "shadow") followed by super().classes(). Then define Card(RoundedMixin, ShadowMixin, Widget) and Badge(ShadowMixin, Widget). Card().classes() must be ["rounded", "shadow", "widget"].
Hint
Each mixin's method is return ["rounded"] + super().classes(). The mixin doesn't know who comes next; the MRO of Card decides.
Show solution
class Widget:
def classes(self):
return ["widget"]
class RoundedMixin:
def classes(self):
return ["rounded", *super().classes()]
class ShadowMixin:
def classes(self):
return ["shadow", *super().classes()]
class Card(RoundedMixin, ShadowMixin, Widget):
pass
class Badge(ShadowMixin, Widget):
pass
Define an ABC Shape with abstract methods area() and perimeter(), and a concrete method ratio() returning area / perimeter. Implement Circle(r) and Rect(w, h). Instantiating Shape directly, or a subclass missing a method, must raise TypeError.
Hint
class Shape(ABC): with @abstractmethod on each required method. The body can be a docstring.
Show solution
import math
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
"""Return the area."""
@abstractmethod
def perimeter(self):
"""Return the perimeter."""
def ratio(self):
return self.area() / self.perimeter()
class Circle(Shape):
def __init__(self, r):
self.r = r
def area(self):
return math.pi * self.r ** 2
def perimeter(self):
return 2 * math.pi * self.r
class Rect(Shape):
def __init__(self, w, h):
self.w = w
self.h = h
def area(self):
return self.w * self.h
def perimeter(self):
return 2 * (self.w + self.h)
Define a @runtime_checkable Protocol Closeable with a single method close(self) -> None. Then write close_all(items) that calls close() on every item that satisfies the protocol, skips the rest, and returns the number of items closed. Unrelated classes (that never inherit from Closeable) must count.
Hint
The protocol body is def close(self) -> None: .... In close_all, use isinstance(item, Closeable).
Show solution
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
def close_all(items):
count = 0
for item in items:
if isinstance(item, Closeable):
item.close()
count += 1
return count
The History class below inherits from list, so callers can insert, sort, or assign into it and corrupt the log. Rewrite it using composition: keep the entries in a private list and expose only record(event), latest(n=1) (the last n events, newest last, as a new list), __len__, and __iter__. It must not be a list and must not have insert or sort.
Hint
Create self._events = [] in __init__. Slicing a list already returns a new list. Watch out: latest(0) with self._events[-0:] returns everything; handle it if you like.
Show solution
class History:
def __init__(self):
self._events = []
def record(self, event):
self._events.append(event)
def latest(self, n=1):
return self._events[-n:] if n > 0 else []
def __len__(self):
return len(self._events)
def __iter__(self):
return iter(self._events)
Mini-project: a shape plugin system
Quiz
Recap
- Inheritance models "is-a"; subclasses inherit everything and override selectively; lookups start at
type(self). super()calls the next class in the MRO; always callsuper().__init__in an overriding initializer.- The MRO (C3) is a linear order: children before parents, bases in listed order, each class once. Read
Cls.__mro__. - ABCs with
@abstractmethodenforce required methods at instantiation;collections.abcadds mixin methods. typing.Protocoldescribes interfaces structurally;@runtime_checkableenables a shallowisinstance.- Mixins add a single capability, hold no state, and go before the main base.
- Prefer composition ("has-a") when you'd inherit unwanted methods or need to combine independent behaviors.
- Liskov: a subclass must work everywhere its base does.
- Duck typing first;
isinstanceagainst abstractions when behavior truly depends on type; nevertype(x) == C.
Going further
- Python Tutorial: Inheritance
- The Python 2.3 Method Resolution Order (the C3 algorithm explained)
abcandcollections.abc- Typing spec: Protocols and PEP 544
- Raymond Hettinger: "Python's super() considered super!"
- Next: M13 Dataclasses, Enums & Records