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.
Reporter.report(records)
Build the two text lines; set rendered to 2; call self.writer.write(lines).
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.
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
- What does composition couple an object to, compared with inheritance?
- What is duck typing, in terms of contracts?
- Why is an
isinstancecheck on an injected collaborator usually a mistake? - Why should a base class avoid calling an overridable method from
__init__?
Answers
- Only the calls it makes on its collaborators. Inheritance couples a subclass to everything about its parent, including behaviour nobody meant to expose.
- The contract is the set of calls an object must support, not a declared type. Anything that responds to those calls fits.
- It removes every substitution, including test doubles, without making a wrong collaborator right. A missing method fails on its own with a clear message.
- 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.