Beginner · Lesson 41 of 94

Composition and object design

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

Inheritance answers "is a". Composition answers "has a". A car has an engine; it is not a kind of engine. A report has a formatter; it is not a kind of formatter. Composition means an object holds other objects as attributes and delegates work to them. It is the more common relationship in real code by a wide margin, and the rule "prefer composition over inheritance" exists because inheritance is the tool people reach for first and regret most.

Composition and object design: Report → formatter: CsvFormatter → source: DatabaseSource → writer: FileWriter → MarkdownFormatter
Composition and object design · Figure 1 · Open for full size

Why prefer it? Because parts can be swapped. A Report that inherits from CsvFormatter is a CSV report forever; a Report that holds a formatter can be given a Markdown one, or a fake one in a test, by passing a different object to its constructor. This is dependency injection, and it sounds grander than it is: the object receives what it depends on rather than creating it. The result is a class that can be tested without a database, a network, or a filesystem, because those collaborators are handed in.

Composition and object design: test → Report(FakeSource(), CsvFormatter(), MemoryWriter()) → production → Report(DatabaseSource(url), CsvFormatter(), FileWriter(path)) → same Report class, no changes
Composition and object design · Figure 2 · Open for full size

Single responsibility is the rule that decides where to draw class boundaries: a class should have one reason to change. If Report fetches data, formats it, and writes files, then a change to any of those three forces a change to Report, and the class grows until nobody understands it. Split along the reasons: a source, a formatter, a writer, and a small Report that coordinates them. Each piece is short, each is testable, and each can be replaced.

Smell Rule it breaks Fix
a class over ~200 lines single responsibility split along the reasons it changes
Class(OtherClass) only to reuse a method prefer composition hold an instance and delegate
a class creates its own database/HTTP client dependency injection receive it in __init__
an isinstance chain polymorphism a method with one name on each class
a hierarchy four levels deep prefer composition flatten; compose behaviors

Delegation is the mechanical heart of composition: a method on the outer object calls a method on an inner one. report.render() calls self.formatter.format(rows). Sometimes the outer class exposes several inner methods one by one; if it exposes all of them, ask whether the outer class is needed at all. Keep the interface of the composite small and meaningful: callers should talk to Report, not reach into report.formatter.

These rules are the practical residue of the four pillars. Encapsulation keeps each part's state private. Abstraction keeps each interface small. Inheritance is reserved for true "is a" families, ideally one level deep under an ABC. Polymorphism lets the composite hold any part that speaks the interface. When code follows them, adding a feature means adding a class, not editing five.

Code

Inheritance versus composition for the same need. The inherited version is stuck with one formatter; the composed version accepts any object with a format method.

# compose.py
class CsvFormatter:
    def format(self, rows):
        return "\n".join(",".join(map(str, r)) for r in rows)


class MarkdownFormatter:
    def format(self, rows):
        return "\n".join("| " + " | ".join(map(str, r)) + " |" for r in rows)


class InheritedReport(CsvFormatter):          # "is a" CSV formatter: stuck with it
    def __init__(self, rows):
        self.rows = rows

    def render(self):
        return self.format(self.rows)


class Report:                                 # "has a" formatter: swappable
    def __init__(self, rows, formatter):
        self.rows = rows
        self.formatter = formatter

    def render(self):
        return self.formatter.format(self.rows)


rows = [("name", "score"), ("ada", 91)]
print(InheritedReport(rows).render())
print(Report(rows, CsvFormatter()).render())
print(Report(rows, MarkdownFormatter()).render())
Output
name,score
ada,91
name,score
ada,91
| name | score |
| ada | 91 |

Single responsibility in practice: a source, a formatter, a writer, and a coordinator. Each class is a few lines, and each can change without touching the others.

# responsibilities.py
from pathlib import Path


class ListSource:
    def __init__(self, rows):
        self._rows = rows

    def rows(self):
        return list(self._rows)


class CsvFormatter:
    def format(self, rows):
        return "\n".join(",".join(map(str, r)) for r in rows)


class FileWriter:
    def __init__(self, folder):
        self.folder = Path(folder)
        self.folder.mkdir(exist_ok=True)

    def write(self, name, text):
        path = self.folder / name
        path.write_text(text, encoding="utf-8")
        return path


class Report:
    def __init__(self, source, formatter, writer):
        self.source, self.formatter, self.writer = source, formatter, writer

    def publish(self, name):
        return self.writer.write(name, self.formatter.format(self.source.rows()))


report = Report(ListSource([("ada", 91), ("linus", 67)]), CsvFormatter(), FileWriter("out"))
path = report.publish("scores.csv")
print(path)
print(path.read_text(encoding="utf-8"))
Output
out/scores.csv
ada,91
linus,67

Dependency injection paying off: the same Report runs against a fake writer that keeps text in memory, so a test needs no filesystem.

# inject.py
class CsvFormatter:
    def format(self, rows):
        return "\n".join(",".join(map(str, r)) for r in rows)


class ListSource:
    def __init__(self, rows):
        self._rows = rows

    def rows(self):
        return list(self._rows)


class MemoryWriter:
    def __init__(self):
        self.files = {}

    def write(self, name, text):
        self.files[name] = text
        return name


class Report:
    def __init__(self, source, formatter, writer):
        self.source, self.formatter, self.writer = source, formatter, writer

    def publish(self, name):
        return self.writer.write(name, self.formatter.format(self.source.rows()))


writer = MemoryWriter()
Report(ListSource([("ada", 91)]), CsvFormatter(), writer).publish("test.csv")
print(writer.files)
print("ada,91" in writer.files["test.csv"])
Output
{'test.csv': 'ada,91'}
True

Delegation with a small public surface. Playlist holds a list but exposes only the operations that make sense for a playlist, not the list's forty methods.

# delegate.py
class Playlist:
    def __init__(self, name):
        self.name = name
        self._songs = []

    def add(self, song):
        self._songs.append(song)
        return self

    def __len__(self):
        return len(self._songs)

    def __iter__(self):
        return iter(self._songs)

    def __repr__(self):
        return f"Playlist({self.name!r}, {len(self)} songs)"


p = Playlist("focus").add("intro").add("deep work").add("outro")
print(p)
print(list(p))
print(hasattr(p, "append"), hasattr(p, "add"))
Output
Playlist('focus', 3 songs)
['intro', 'deep work', 'outro']
False True

Try it yourself

Create notifier.py with a Notifier class that takes a channel object in its constructor and has one method, send(user, message), which formats the message with the user's name and delegates to channel.deliver(text). Write ConsoleChannel that prints, and MemoryChannel that stores messages in a list. Run the same Notifier with both, then add a throttle collaborator that refuses more than two messages per user, injected the same way.

Common mistakes

What's next

The OOP module is complete: you can model, protect, extend, and compose objects. Next you return to data, with the standard library's tools for dates and times.

Course overview