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.
report = Report([ to jump to one benefit that might be new to you.This post talks about a few benefits for Python:
- Adding a feature means writing a new part, not editing the core.
- Illegal combinations become unrepresentable instead of merely discouraged.
- Each part is small enough to understand and test on its own.
- If used to build UIs, the shape of the code matches the shape of the output.
- 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.
- the number of flags and branches keeps growing.
- 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:
- the structure of this code follows what the structure of the document will look like. This makes composition a great pattern for building UIs.
Reportno longer knows the difference between a title and a footer. It only knows how to join renderables.- Illegal states are gone because there are no flags to disagree with each other.
- 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")