Composition over inheritance - in Python

"Composition over inheritance" is a classic design pattern in object oriented programming. Instead of building a god class that has to know about all the variants, you build the big thing out of smaller, focused parts plus a combiner.

Already familiar with composition? Ctrl+F search for report = Report([ to jump to one benefit that might be new to you.

This post talks about a few benefits for Python:

  1. Adding a feature means writing a new part, not editing the core.
  2. Illegal combinations become unrepresentable instead of merely discouraged.
  3. Each part is small enough to understand and test on its own.
  4. If used to build UIs, the shape of the code matches the shape of the output.
  5. As with Parse, Don't Validate - in Python, the type system catches problems at check time rather than run time.

These constraints are especially nice when you have a lot of coding agents working for you.

Subclass explosions

This part you probably already know about, so I'll keep it short.

You start with a report base class. Then you want variants: sales report, sales report with charts, sales reports with charts and a footer:

class Report:
    def render(self) -> str:
        raise NotImplementedError

class SalesReport(Report):
    def render(self) -> str:
        return "Sales report"

class SalesReportWithChart(SalesReport):
    def render(self) -> str:
        return super().render() + "\nChart"

class SalesReportWithChartAndFooter(SalesReportWithChart):
    def render(self) -> str:
        return super().render() + "\nFooter"

This is a combinatorial type mess. Mixins are an improvement:

class TitleMixin: ...
class ChartMixin: ...
class FooterMixin: ...

class SalesReport(TitleMixin, ChartMixin, FooterMixin):
    ...

But now you are dealing with resolution order of the bases. Also, the classes are coupled, sometimes across different files, so now you can have long-range side effects.

Config explosions

The other extreme is a god class with many arguments:

class Report:
    def __init__(
        self,
        title: str = "",
        show_title: bool = True,
        show_summary: bool = True,
        show_table: bool = True,
        show_footer: bool = True,
        footer_text: str | None = None,
    ):
        self.title = title
        self.show_title = show_title
        self.show_summary = show_summary
        self.show_table = show_table
        self.show_footer = show_footer
        self.footer_text = footer_text

    def render(self) -> str:
        parts = []
        if self.show_title:
            parts.append(f"# {self.title}")
        if self.show_summary:
            parts.append("Summary goes here...")
        if self.show_table:
            parts.append("| Name | Value |")
        if self.show_footer:
            parts.append(self.footer_text or "Generated by system")
        return "\n\n".join(parts)

This works, but has a few issues.

  1. the number of flags and branches keeps growing.
  2. it makes illegal states representable. Report(show_footer=False, footer_text="Confidential") type-checks and runs, silently ignoring the footer text. This relates to what I wrote in Parse, Don't Validate - in Python, but from the constructor side: the type of the object doesn't reflect what's actually in it.

The fix: small parts plus a combiner

Both problems are solved by composing a bunch of parts that have just one job. The Report class becomes a combiner:

from typing import Protocol

class Renderable(Protocol):
    def render(self) -> str: ...

class Title:
    def __init__(self, text: str):
        self.text = text
    def render(self) -> str:
        return f"# {self.text}"

class Summary:
    def __init__(self, text: str):
        self.text = text
    def render(self) -> str:
        return self.text

class Table:
    def __init__(self, rows: list[tuple[str, int]]):
        self.rows = rows
    def render(self) -> str:
        lines = ["| Name | Value |", "| --- | --- |"]
        lines += [f"| {name} | {value} |" for name, value in self.rows]
        return "\n".join(lines)

class Footer:
    def __init__(self, text: str):
        self.text = text
    def render(self) -> str:
        return f"---\n{self.text}"

class Report:
    def __init__(self, sections: list[Renderable]):
        self.sections = sections
    def render(self) -> str:
        return "\n\n".join(section.render() for section in self.sections)

Now you compose a report instead of configuring or subclassing one:

report = Report([
    Title("Sales Report"),
    Summary("Revenue increased by 12% this quarter."),
    Table([("January", 100), ("February", 140), ("March", 180)]),
    Footer("Generated automatically"),
])

If you botch a part, mypy catches it at the boundary. Say you add a chart but forget its render:

class Chart:  # oops -- forgot render()
    def __init__(self, data: list[int]):
        self.data = data

Report([Title("Sales"), Chart([1, 2])])
# mypy error: List item 1 has incompatible type "Chart";
#             expected "Renderable"  [list-item]

The config and subclass explosion problem is solved. Also:

  1. the structure of this code follows what the structure of the document will look like. This makes composition a great pattern for building UIs.
  2. Report no longer knows the difference between a title and a footer. It only knows how to join renderables.
  3. Illegal states are gone because there are no flags to disagree with each other.
  4. static type checkers catch many errors

You can also apply this idea to functions when you don't need to carry state:

def title(text: str) -> str:
    return f"# {text}"

def summary(text: str) -> str:
    return text

def footer(text: str) -> str:
    return f"---\n{text}"

def report(*sections: str) -> str:
    return "\n\n".join(sections)

output = report(
    title("Sales Report"),
    summary("Revenue increased by 12%."),
    footer("Generated automatically"),
)

Strategy pattern to change behaviors

Composition is also useful for changing behaviors. This is called the strategy pattern.

Say you have a Notifier. First, with inheritance:

# BAD
class Notifier:
    def send(self, msg: str) -> None: ...

class EmailNotifier(Notifier): ...
class SmsNotifier(Notifier): ...

You can imagine how this would get out of hand once you want to add SlackNotifier, EmailNotifierWithRetry, etc.

Now with composition, just swapping out the transport:

from typing import Protocol

class Transport(Protocol):
    def deliver(self, msg: str) -> None: ...

class EmailTransport:
    def deliver(self, msg: str) -> None: ...

class SmsTransport:
    def deliver(self, msg: str) -> None: ...

class Notifier:
    def __init__(self, transport: Transport):
        self.transport = transport
    def send(self, msg: str) -> None:
        # shared logic (formatting, rate limiting) lives here, once
        self.transport.deliver(msg)

Notifier(EmailTransport()).send("hi")
Notifier(SmsTransport()).send("hi")