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.

⏱ ~2.5 hoursPrerequisites: M10 Classes, M11 Data Model

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:

  1. A class always comes before its bases.
  2. Bases appear in the order you listed them (B before C in class D(B, C)).
  3. 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:

ABC (abc.ABC)Protocol (typing.Protocol)
RelationshipNominal: must inherit (or register)Structural: just have the methods
CheckedAt instantiation (missing abstract methods)By the type checker; optionally isinstance
Can share codeYes, concrete methods are inheritedNot usefully for implementers that don't inherit
Best forA family of classes you controlAccepting 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:

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:

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

Mini-projectPluggable shapes with a registry

Many real applications (static-site generators, pytest, data tools) let users add new behavior by writing a class, without editing the core program. Build a small version: a geometry calculator where each shape is a plugin discovered from a registry.

pixi init shapes
cd shapes
mkdir -p src/shapes
pixi run python -m shapes.cli circle 2     # run from src/ or set up the package as in M08

Specification

  • shapes/base.py: an ABC Shape with abstract area() and perimeter(), a class attribute name, and a class method from_args(cls, args: list[str]) that subclasses override to parse command-line numbers.
  • A registry: REGISTRY: dict[str, type[Shape]] filled automatically. Use __init_subclass__(cls, **kwargs) in Shape (call super().__init_subclass__(**kwargs)) to register every concrete subclass under its name. Reject duplicate names with ValueError.
  • shapes/plugins.py: Circle, Rectangle, and Triangle (validate the triangle inequality in __init__).
  • A Describable Protocol with describe() -> str, and a ReprMixin giving every shape a useful __repr__.
  • A Drawing class that composes a list of shapes (it is not a list) with add(), total_area(), largest(), and __len__.
  • shapes/cli.py with main(): shape-name numbers... looks up the plugin, builds it via from_args, and prints its description.

Acceptance criteria

  • Defining a new Shape subclass in a new file registers it without editing the registry code
  • Shape() and incomplete subclasses can't be instantiated
  • Two plugins with the same name raise ValueError
  • Drawing uses composition and does not expose list methods like insert
  • Every shape satisfies the Describable protocol (isinstance with @runtime_checkable)
  • You can explain the MRO of one shape class (print Circle.__mro__)
  • Invalid input (unknown shape, wrong number of arguments, impossible triangle) prints a clear error rather than a traceback

Stretch goals

  • Add a Square plugin. Should it subclass Rectangle? Use the Liskov principle to decide, and write down why.
  • Discover plugins from a folder at runtime with importlib.import_module.
  • Run pixi add mypy and pixi run mypy src to check your Protocol usage statically.

Quiz

Recap

Going further