Skip to main content
Learning Center
Python Programming

Module 11: Object-Oriented Python and Maintainability

Composition, Duck Typing, and Depending on a Shape

Building behaviour by combining small parts instead of extending a base class, and depending on what a collaborator can do rather than on what it is.

Lesson 35 of 46 in the recommended order · About 25 min (estimate)

On this page

Outcome

By the end of this lesson you can assemble an object from collaborators handed to it, depend on what those collaborators can do rather than on their types, and explain why that keeps the whole thing testable.

Why it matters

There is a common way for a small project to become hard to change. A base class collects behaviour, subclasses override pieces of it, and after four of them nobody can say what any particular instance actually does without reading five files in the right order.

Composition is the alternative: an object holds the parts it needs, and those parts are supplied when it is built. It is the same idea as Module 7's injected transport, and it produces the same benefit. A part you were given is a part a test can replace.

Python makes this cheaper than most languages, because a collaborator does not have to belong to any particular type. It only has to accept the calls you make on it.

Concept

Inheritance says "is a kind of". Composition says "has a" or "uses a". The practical difference is that inheritance couples a subclass to everything about its parent, including things nobody meant to expose, while composition couples an object only to the calls it makes.

The usual advice, and it holds up: reach for composition first. Inheritance earns its place when several types genuinely share both behaviour and meaning, and even then, one level is almost always enough.

Compose by taking collaborators as constructor arguments:

class Pipeline:
    def __init__(self, loader, rule, reporter):
        self.loader = loader
        self.rule = rule
        self.reporter = reporter

Nothing here says what a loader is. It is whatever was passed, and the class's only requirement is that the calls it makes on it work.

That is duck typing: if it can be called the way you call it, it fits. The contract is the set of calls, not a declared type. The later Try it yourself version of Reporter needs something it can call with a record that returns a string, and a class instance's bound method, a plain function, and a lambda all satisfy that equally.

A protocol writes that contract down. typing.Protocol lets you declare a callable contract with a __call__(record) -> str method that a type checker can verify, without any class having to inherit from it. It is documentation with teeth, and it changes nothing at run time. For a project this size, a docstring naming the required calls is often enough; the value of the protocol appears once several implementations exist.

One rule follows directly, and it is the most useful thing in this lesson: do not check the type of a collaborator. An isinstance check inside a class is what removes the ability to substitute anything, which was the whole reason for composing. If a collaborator is wrong, the call it does not support will fail on its own, with a message naming the missing method.

Read the code

class CsvWriter:
    def write(self, lines):
        return "\n".join(lines)


class MarkdownWriter:
    def write(self, lines):
        return "\n".join(f"- {line}" for line in lines)


class Reporter:
    """Renders records. `writer` is anything with write(lines) -> str."""

    def __init__(self, writer):
        self.writer = writer
        self.rendered = 0

    def report(self, records):
        lines = [f"{record['notice_id']},{record['amount']}" for record in records]
        self.rendered = len(lines)
        return self.writer.write(lines)


records = [{"notice_id": "A-1", "amount": 310000}, {"notice_id": "A-2", "amount": 128000}]

print(Reporter(CsvWriter()).report(records))
print(Reporter(MarkdownWriter()).report(records))


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

    def write(self, lines):
        self.calls += 1
        return f"{len(lines)} lines"


spy = CountingWriter()
print(Reporter(spy).report(records), spy.calls)

Two writers with nothing in common except a write method taking a list of lines. Neither inherits from anything, and there is no shared base class at all.

Reporter never asks which writer it has. Its docstring states the contract in one line, which is the lightest version of a protocol.

CountingWriter is the point of the design. It is a test double: it satisfies the contract, records that it was called, and returns something trivially checkable. Writing it took three lines and needed no cooperation from Reporter.

Inspect the writer substitutions after making your prediction

Same Reporter, one supplied writer at a time

Caller of report(records)

Construct Reporter with one chosen writer, then call report with the records.

CallReturn: str

Reporter.report(records)

Build the two text lines; set rendered to 2; call self.writer.write(lines).

CallReturn: str

The supplied writer object

Accept the list of lines and return a string. No shared custom base class is required.

Reporter prepares the same two lines for each choice

A-1
A-1,310000
A-2
A-2,128000

CsvWriter()

write(lines)

Join the lines with newline characters

Returned line 1
A-1,310000
Returned line 2
A-2,128000

This small writer returns text; it does not create a file.

MarkdownWriter()

write(lines)

Prefix each line with "- ", then join

Returned line 1
- A-1,310000
Returned line 2
- A-2,128000

Reporter uses the same call. The writer changes the representation.

CountingWriter() test double

write(lines)

Increment calls; return the line count as text

Returned text
2 lines
calls after one report
1

A fresh test double fits the same call shape and records that it was used.

These are alternatives, not an inheritance tree or a chain of three writers. This worked Reporter expects write(lines); the later exercise instead accepts a callable formatter(record). The toy CsvWriter does not implement general CSV quoting.

Predict the output

Predict every printed line.

Check your prediction
A-1,310000
A-2,128000
- A-1,310000
- A-2,128000
2 lines 1

The first two blocks are the same data rendered by two writers. The last line shows the spy's return value and its call count on one line, because print received two arguments.

Reporter is identical in all three cases. Everything that varies was supplied from outside, which is what makes the third case, a test, no harder to arrange than the first two.

Modify the code

Add a guard to the top of Reporter.__init__: if not isinstance(writer, CsvWriter): raise TypeError("writer must be a CsvWriter"). Predict what still works.

What changes, and why

Only the first call survives. MarkdownWriter and CountingWriter both raise TypeError, despite satisfying the contract completely.

Nothing about the code's behaviour improved. The check cannot make a wrong collaborator right; a CsvWriter subclass with a broken write would pass it happily. What it does is remove every substitution, including the test double, which was the strongest reason to compose in the first place.

The honest way to state a requirement is a Protocol and a type checker, which verifies the shape without constraining the type, or a docstring naming the calls. A run-time isinstance check on a collaborator is worth questioning every time it appears in a review.

Debug the bug

An assistant was asked to add Markdown output to a reporter. It produced this.

class BaseReporter:
    def __init__(self, records):
        self.records = records
        self.lines = self.build_lines()

    def build_lines(self):
        return [record["notice_id"] for record in self.records]

    def report(self):
        return "\n".join(self.lines)


class MarkdownReporter(BaseReporter):
    def __init__(self, records, bullet="-"):
        self.bullet = bullet
        super().__init__(records)

    def build_lines(self):
        return [f"{self.bullet} {record['notice_id']}" for record in self.records]


print(MarkdownReporter([{"notice_id": "A-1"}]).report())
What's actually wrong

This particular version works, and it works by accident. Move self.bullet = bullet to after super().__init__(records), which is the more natural order and what most people write, and it raises:

AttributeError: 'MarkdownReporter' object has no attribute 'bullet'

The base class's __init__ calls self.build_lines(), which the subclass has overridden, so subclass code runs before the subclass has finished initialising. Correctness now depends on the order of two lines in a file the subclass author may not have read.

That is the coupling this lesson is about. BaseReporter never intended to constrain the subclass's constructor, and it does, invisibly.

Composition removes the problem rather than working around it:

class Reporter:
    def __init__(self, records, build_lines):
        self.records = records
        self.lines = build_lines(records)

    def report(self):
        return "\n".join(self.lines)


def markdown_lines(records, bullet="-"):
    return [f"{bullet} {record['notice_id']}" for record in records]


print(Reporter([{"notice_id": "A-1"}], markdown_lines).report())

No base class, no override, no ordering requirement, and markdown_lines is a plain function that can be tested on its own. If you ever do need a base class that calls an overridable method, do not call it from __init__.

Try it yourself

Complete the reporter so it uses whatever formatter it was handed. Two very different collaborators must both work, unchanged.

Loading this exercise…

Practical challenge (optional)

Optional: return to the Read the code writer example. Write its contract down as a typing.Protocol with a single write(lines) -> str method, and annotate that Reporter constructor with it, and confirm the code still runs unchanged, because protocols are not enforced at run time. Then write one sentence on what the protocol gives you that the docstring did not. The answer, that a type checker can now verify every call site without any class inheriting anything, is the whole case for them.

Sign in to track your progress on this exercise.

AI collaboration

Checkpoint

  1. What does composition couple an object to, compared with inheritance?
  2. What is duck typing, in terms of contracts?
  3. Why is an isinstance check on an injected collaborator usually a mistake?
  4. Why should a base class avoid calling an overridable method from __init__?
Answers
  1. Only the calls it makes on its collaborators. Inheritance couples a subclass to everything about its parent, including behaviour nobody meant to expose.
  2. The contract is the set of calls an object must support, not a declared type. Anything that responds to those calls fits.
  3. It removes every substitution, including test doubles, without making a wrong collaborator right. A missing method fails on its own with a clear message.
  4. The subclass's override runs before the subclass's own __init__ has finished, so correctness depends on statement order in a file the subclass author may never read.

Sign in to track your progress on this exercise.

Summary and next step

Prefer composition, hand collaborators in at construction, depend on the calls rather than the type, write the contract in a docstring or a protocol, and resist type checks that only remove substitutability. Next: putting a functional and an object-oriented version of the same feature side by side and choosing between them on evidence.

learning.goultergroup.com

The interactive parts of this page have not loaded. Reading and links still work; reload the page to try again.