FreeCampus Python

Choose Composition Before Inheritance

Split responsibilities among injected collaborators, delegate through shared behavior, test subtype promises, and replace brittle inheritance with clearer composition.
python-foundations object-oriented-python composition inheritance
Open in Colab
  • Level: Python Foundations
  • Estimated time: 5–6 hours
  • You will learn: Separate responsibilities, inject and replace collaborators, use duck typing and delegation, override behavior safely, and accept inheritance only when every subtype preserves the base contract.
  • Practice in: Google Colab, JupyterLab, or a local Python interpreter

An Explorer object begins by storing a name and energy. Then it chooses routes, formats messages, rolls random weather, saves progress, and sends notifications. The class now changes for unrelated reasons and is hard to exercise without the outside world.

The solution is not automatically a family tree. First ask which responsibilities can collaborate through small, visible behaviors. Composition lets an object receive and use another object. Inheritance says something stronger: every specialized object can stand in for the base object without surprising its callers.

This lesson asks:

1. Split a class by reasons to change

Here is the beginning of an overloaded design:

class Explorer:
    def __init__(self, name, energy=10):
        self.name = name
        self.energy = energy

    def choose_route(self, routes):
        return min(routes, key=lambda route: route["distance"])

    def travel(self, route):
        self.energy -= route["distance"]
        return f"{self.name} travels to {route['name']}"

    def format_status(self):
        return f"{self.name}: {self.energy} energy"

This class is not yet enormous, but it already owns three decisions:

  1. explorer state and travel rules;
  2. route-selection policy; and
  3. presentation text.

Counting methods is not enough. A class with twelve cohesive methods can be healthier than a class with three unrelated ones. Ask what would cause the code to change. A new route algorithm should not risk the energy invariant. A new display language should not alter travel.

Move route choice to a collaborator:

class ShortestRoutePlanner:
    def choose(self, routes):
        if not routes:
            raise ValueError("routes cannot be empty")
        return min(routes, key=lambda route: route["distance"])


class Explorer:
    def __init__(self, name, planner, energy=10):
        if energy < 0:
            raise ValueError("energy cannot be negative")
        self.name = name
        self._planner = planner
        self._energy = energy

    @property
    def energy(self):
        return self._energy

    def choose_route(self, routes):
        return self._planner.choose(routes)

    def travel(self, route):
        cost = route["distance"]
        if cost > self._energy:
            raise ValueError("not enough energy")
        self._energy -= cost
        return route["name"]

The constructor receives planner rather than constructing it. The explorer depends on an object with choose(routes) behavior. It stores that collaborator and delegates route choice to it.

routes = [
    {"name": "River Gate", "distance": 4, "scenic": 3},
    {"name": "Hill Garden", "distance": 6, "scenic": 5},
]

explorer = Explorer("Mina", ShortestRoutePlanner(), energy=10)
route = explorer.choose_route(routes)

assert route["name"] == "River Gate"
assert explorer.travel(route) == "River Gate"
assert explorer.energy == 6

This is dependency injection in a simple form: the outside caller provides the collaborator. It makes the dependency visible and replaceable.

2. Swap behavior without inventing a parent class

Add a second planner:

class ScenicRoutePlanner:
    def choose(self, routes):
        if not routes:
            raise ValueError("routes cannot be empty")
        return max(routes, key=lambda route: route["scenic"])

It does not inherit from ShortestRoutePlanner. It simply supports the behavior Explorer uses:

scenic_explorer = Explorer("Sol", ScenicRoutePlanner(), energy=10)
route = scenic_explorer.choose_route(routes)

assert route["name"] == "Hill Garden"

Python commonly supports duck typing: code relies on available behavior rather than requiring an object’s class to appear in one hierarchy. If both planners can accept the route collection and return one route under the stated failure contract, the explorer can collaborate with either.

The behavior still needs a contract:

Planner operation Promise
choose(routes) accept a non-empty iterable of route records
success return one of the supplied records
empty input raise ValueError("routes cannot be empty")
side effects do not mutate the supplied records or explorer

A matching method name alone is not enough. This object has a choose method but violates the return contract:

class BrokenPlanner:
    def choose(self, routes):
        return "surprise"

Explorer.choose_route can call it, but later code expecting route["distance"] will fail. Behavioral substitution includes inputs, outputs, failures, and side effects.

Distinguish ownership, reference, dependency, and delegation

Object diagrams often draw the same arrow for several relationships. Name the relationship in prose so readers know what lifecycle and mutation to expect:

  • Ownership: a Tour copies and controls its internal stop sequence. The sequence should not change because the caller appends to its original list.
  • Reference: a TurnResult may refer to an immutable position value without controlling a separate lifecycle.
  • Dependency: an Explorer requires planner behavior to complete route choice.
  • Delegation: Explorer.choose_route forwards the selection request to that planner and returns its result.

Composition does not automatically imply exclusive ownership. In the current constructor, the caller creates the planner and the explorer retains a reference to it. If two explorers receive the same stateful planner, they intentionally or accidentally share that collaborator:

class CountingPlanner:
    def __init__(self):
        self.calls = 0

    def choose(self, routes):
        self.calls += 1
        return routes[0]


shared = CountingPlanner()
first_explorer = Explorer("Ari", shared)
second_explorer = Explorer("Bo", shared)

first_explorer.choose_route(routes)
second_explorer.choose_route(routes)
assert shared.calls == 2

That can be useful for shared metrics, but the constructor and documentation should make the choice visible. Copying an arbitrary collaborator is rarely a safe fix: a copy may duplicate file handles, caches, or configuration in surprising ways. Decide lifecycle at the design boundary instead.

The caller can also pass a simple fake object while investigating behavior:

class FirstRoute:
    def choose(self, routes):
        return routes[0]


predictable = Explorer("Cy", FirstRoute(), energy=10)
assert predictable.choose_route(routes) is routes[0]

No testing framework is required to benefit from a replaceable dependency. The small fake makes the explorer’s delegation observable.

Inject uncertainty instead of hiding it

This version is hard to control:

import random


class HiddenWeatherExplorer:
    def weather(self):
        return random.choice(["clear", "rain"])

The object constructs its uncertainty implicitly through the global random module. A small collaborator makes examples deterministic:

class SequenceWeather:
    def __init__(self, conditions):
        self._conditions = iter(conditions)

    def next_condition(self):
        return next(self._conditions)


class Expedition:
    def __init__(self, weather):
        self._weather = weather

    def begin_day(self):
        return self._weather.next_condition()
expedition = Expedition(SequenceWeather(["clear", "rain"]))
assert expedition.begin_day() == "clear"
assert expedition.begin_day() == "rain"

The expedition owns the workflow; the collaborator owns condition selection. Unit 13 will use this same seam for test doubles. Here, it simply makes the design predictable.

Composition places the policy beside the object that uses it. The caller chooses which compatible policy to supply.

flowchart LR
  caller[Caller] -->|injects| explorer[Explorer]
  caller --> shortest[Shortest planner]
  caller --> scenic[Scenic planner]
  explorer -->|delegates choose| policy[Planner behavior]
  shortest --> policy
  scenic --> policy

Checkpoint: compose through behavior

3. Use inheritance only for a reliable subtype

Inheritance defines a new class from an existing class. A subtype receives base behavior and can add or override behavior:

class Notification:
    def __init__(self, recipient, message):
        if not recipient.strip():
            raise ValueError("recipient cannot be blank")
        self.recipient = recipient.strip()
        self.message = message

    def render(self):
        return f"To {self.recipient}: {self.message}"


class UrgentNotification(Notification):
    def __init__(self, recipient, message, alarm="ALERT"):
        super().__init__(recipient, message)
        self.alarm = alarm

    def render(self):
        base_text = super().render()
        return f"{self.alarm}! {base_text}"

UrgentNotification(Notification) declares inheritance. super().__init__ continues to the base initializer so recipient validation and shared assignments remain in one place. Its render override extends the base result.

ordinary = Notification("Ari", "Gate opened")
urgent = UrgentNotification("Mina", "Bridge closing")

assert ordinary.render() == "To Ari: Gate opened"
assert urgent.render() == "ALERT! To Mina: Bridge closing"
assert isinstance(urgent, Notification)

A caller that accepts a Notification and relies only on the public promise can use either:

def display_notification(notification):
    """Return displayable notification text."""
    text = notification.render()
    if not isinstance(text, str) or not text:
        raise ValueError("notification must render non-empty text")
    return text


assert display_notification(ordinary) == "To Ari: Gate opened"
assert display_notification(urgent).startswith("ALERT!")

The subtype changes formatting while preserving the operation’s input and non-empty-string result. This is a small, honest hierarchy.

super() follows the class relationship

super() is not “call my parent by name.” It creates a proxy that continues method lookup after the current class according to Python’s method resolution order. In this shallow example, that reaches Notification. Using super() supports cooperative behavior better than hard-coding Notification.__init__(self, ...).

This unit stops at single, shallow inheritance. Multiple inheritance and deep method-resolution design need more context and belong in an advanced course.

4. Reject a subtype that breaks the caller’s promise

Suppose a base Storage promises that save(text) accepts any string and returns the stored character count:

class Storage:
    def __init__(self):
        self.items = []

    def save(self, text):
        self.items.append(text)
        return len(text)

This subtype is tempting but dishonest:

class NonEmptyStorage(Storage):
    def save(self, text):
        if not text:
            raise ValueError("empty text is forbidden")
        return super().save(text)

The base accepts "", but the subtype rejects it. Code written for the base contract can fail when handed the subtype:

def archive_values(storage, values):
    return [storage.save(value) for value in values]


assert archive_values(Storage(), ["map", ""]) == [3, 0]

try:
    archive_values(NonEmptyStorage(), ["map", ""])
except ValueError as error:
    print(error)

An “is-a storage” sentence is not enough. The subtype strengthened the precondition and broke substitutability. Better options include:

  • change the base contract if all storage should reject empty text;
  • validate before calling storage, where that application rule belongs; or
  • compose a filtering/validation policy with storage rather than pretending it is a subtype.

Use a substitution review:

Contract part Subtype question
accepted inputs Does it accept everything the base promises?
successful result Does it return a result callers can use the same way?
failure behavior Does it introduce surprising failure for valid base input?
state effects Does it preserve the documented postconditions?
public attributes/methods Are inherited promises still meaningful?

Inheritance is a public relationship. A subclass can technically access underscore-prefixed base state, but depending on every internal detail makes the base fragile: a harmless refactor can break subclasses. Design extension points intentionally or prefer composition.

Checkpoint: test the subtype promise

5. Replace a brittle hierarchy with a collaborator

Suppose a game models movement this way:

class MovingActor:
    def move(self, position):
        raise NotImplementedError


class WalkingActor(MovingActor):
    def move(self, position):
        return position + 1


class FlyingWalkingActor(WalkingActor):
    def move(self, position):
        return position + 3

The hierarchy mixes what the actor is with a movement rule that might change during the game. A power-up could make a walking actor fly temporarily. Changing the object’s class at runtime is awkward.

Compose a movement policy instead:

class StepMovement:
    def __init__(self, distance=1):
        self.distance = distance

    def move(self, position):
        return position + self.distance


class Actor:
    def __init__(self, name, movement):
        self.name = name
        self.movement = movement
        self.position = 0

    def advance(self):
        self.position = self.movement.move(self.position)
        return self.position
actor = Actor("Pip", StepMovement(1))
assert actor.advance() == 1

actor.movement = StepMovement(3)
assert actor.advance() == 4

The actor owns identity and current position. The policy owns how a position advances. This design supports a temporary change without a class explosion.

Composition is not always better. If UrgentNotification is a stable semantic kind of notification and preserves the contract, inheritance communicates that relationship well. Prefer composition as the default for reuse and varying behavior; accept inheritance when substitutability makes the relationship true.

Inheritance fixes a subtype relationship. Composition attaches replaceable behavior to an object that keeps its own identity.

flowchart LR
  sub[Urgent notification] -->|is substitutable for| base[Notification]
  actor[Actor] -->|has movement policy| move[Step movement]
  actor -->|policy can change| other[Other movement]

6. Use the smallest honest structure

Before creating a class, walk through this table:

Structure Strong signal Warning sign
function input becomes output; no owned evolving state hidden global mutation
dictionary/list local, flexible data shape many callers rely on magic keys and shared rules
dataclass fields define value, representation, equality unique entity is accidentally equal by fields
regular class coherent state changes through guarded behavior class only wraps one stateless function
composition separate responsibilities collaborate or vary dozens of tiny objects only forward calls
inheritance stable subtype preserves one public contract used only to borrow code or satisfy “is-a” wording

Watch for these smells:

  • every noun in a requirement becomes a class;
  • every field receives a getter and setter without a rule;
  • a Manager, Controller, or System owns every unrelated task;
  • a one-method class has no configuration, state, or replaceable behavior;
  • subclasses inspect and mutate several base underscore fields;
  • hierarchies grow a new subtype for every combination of features;
  • a class exists only to make two lines “object-oriented.”

A module function can be a public, testable abstraction. A dictionary can be the clearest record. The aim is coherent ownership, not ceremony.

Review a design with change scenarios

Static diagrams can make almost any decomposition look tidy. Test the design by asking where realistic changes land:

  1. Add a third route strategy. Ideally add one collaborator and selection configuration, not edit every explorer method.
  2. Change energy validation. Ideally edit the entity that owns energy, not each planner.
  3. Display status in another language. Ideally change a presenter or formatter, not route algorithms.
  4. Record planner calls. Ideally decorate or compose the planner behavior, not create subclasses for every explorer-policy combination.
  5. Make a planner stateful. Decide whether it is shared or independently constructed; do not let that lifecycle be accidental.

When one small requirement crosses most classes, either the requirement is inherently cross-cutting or responsibilities have been split along the wrong lines. Use the change scenario as evidence, not as proof that more classes are always needed.

7. Design a museum tour with replaceable route policies

Build these types:

from dataclasses import dataclass


@dataclass(frozen=True)
class Stop:
    name: str
    floor: int
    popularity: int


class FloorOrder:
    def arrange(self, stops):
        """Return stops ordered by floor, then name."""
        ...


class PopularFirst:
    def arrange(self, stops):
        """Return stops by descending popularity, then name."""
        ...


class Tour:
    def __init__(self, title, stops, policy):
        """Store a non-empty title, copied stops, and an arrangement policy."""
        ...

    def itinerary(self):
        """Delegate arrangement and return an independent list of stop names."""
        ...

Contracts:

  • a stop name is non-blank, floor is non-negative, and popularity is 0–5;
  • Tour requires at least one stop and stores its own list copy;
  • each policy returns a new list without mutating the supplied collection;
  • itinerary() delegates to the current policy;
  • changing tour.policy changes arrangement, not tour identity or stop data; and
  • no project-defined base class is needed for the two policies.

Checks:

stops = [
    Stop("Moon Engine", 2, 5),
    Stop("Clockwork Birds", 1, 3),
    Stop("Amber Maps", 1, 4),
]

tour = Tour("Museum Sprint", stops, FloorOrder())
assert tour.itinerary() == ["Amber Maps", "Clockwork Birds", "Moon Engine"]

tour.policy = PopularFirst()
assert tour.itinerary() == ["Moon Engine", "Amber Maps", "Clockwork Birds"]

assert [stop.name for stop in stops] == [
    "Moon Engine",
    "Clockwork Birds",
    "Amber Maps",
]

stops.append(Stop("New Exhibit", 0, 1))
assert "New Exhibit" not in tour.itinerary()

Then write a five-sentence design note:

  1. Why is Stop a value-like dataclass?
  2. Why is Tour a stateful regular class?
  3. What behavior do both policies promise?
  4. Why is composition more useful than inheriting PopularTour(Tour) here?
  5. Which part could remain a function without harming the design?

Hint 1

Validate Stop in __post_init__. In Tour.__init__, use self.stops = list(stops) so later mutation of the caller’s list does not alter the tour.

Hint 2

FloorOrder.arrange can use sorted(stops, key=lambda stop: (stop.floor, stop.name)). The popular policy can sort with (-stop.popularity, stop.name).

Hint 3

Tour.itinerary asks self.policy.arrange(self.stops) and converts the returned stops to names. It should not use isinstance to choose an algorithm; the collaborator owns that decision.

Compare a complete composed tour
from dataclasses import dataclass


@dataclass(frozen=True)
class Stop:
    name: str
    floor: int
    popularity: int

    def __post_init__(self):
        cleaned = self.name.strip()
        if not cleaned:
            raise ValueError("name cannot be blank")
        if self.floor < 0:
            raise ValueError("floor cannot be negative")
        if not 0 <= self.popularity <= 5:
            raise ValueError("popularity must be between 0 and 5")
        object.__setattr__(self, "name", cleaned)


class FloorOrder:
    def arrange(self, stops):
        return sorted(stops, key=lambda stop: (stop.floor, stop.name))


class PopularFirst:
    def arrange(self, stops):
        return sorted(stops, key=lambda stop: (-stop.popularity, stop.name))


class Tour:
    def __init__(self, title, stops, policy):
        cleaned = title.strip()
        copied_stops = list(stops)
        if not cleaned:
            raise ValueError("title cannot be blank")
        if not copied_stops:
            raise ValueError("stops cannot be empty")
        self.title = cleaned
        self.stops = copied_stops
        self.policy = policy

    def itinerary(self):
        arranged = self.policy.arrange(self.stops)
        return [stop.name for stop in arranged]


stops = [
    Stop("Moon Engine", 2, 5),
    Stop("Clockwork Birds", 1, 3),
    Stop("Amber Maps", 1, 4),
]
tour = Tour("Museum Sprint", stops, FloorOrder())
assert tour.itinerary() == ["Amber Maps", "Clockwork Birds", "Moon Engine"]
tour.policy = PopularFirst()
assert tour.itinerary() == ["Moon Engine", "Amber Maps", "Clockwork Birds"]

The two policies happen to be stateless and could be functions passed into the tour. The small policy classes are justified here because they make a named, replaceable behavior visible; a callable-function design would also be honest.

Checkpoint: make the design choice explicit

8. Key points for collaborating objects

  • Split classes by coherent responsibilities and reasons to change, not an arbitrary method-count limit.
  • Composition lets an object hold or receive another object. Delegation asks that collaborator to perform the behavior it owns.
  • Constructor injection makes dependencies visible and lets callers supply a deterministic or alternative implementation.
  • Duck typing relies on behavioral contracts: accepted inputs, results, failures, and side effects—not just matching method names.
  • Inheritance states that a subtype can be used wherever callers expect the base contract. An “is-a” sentence alone does not prove that promise.
  • Use super() to continue cooperative lookup and preserve base initialization or behavior in an honest shallow hierarchy.
  • A subtype that rejects valid base input, weakens results, or breaks postconditions is not safely substitutable.
  • Prefer composition for varying capabilities and code reuse. Accept inheritance when the semantic subtype and behavioral contract are stable.
  • Functions, collections, and dataclasses remain valid alternatives. A class should earn its state ownership or collaboration role.

References

Back to top