Python Part I · Module 16 · Object-Oriented Programming
Composition: Objects Containing Objects
Four chapters of inheritance have made it the obvious tool, which is why this one exists: the default answer to building a bigger thing out of smaller things is not inheritance, and reaching for a base class because it is the technique you most recently learned produces hierarchies that nobody can refactor two years later. The alternative is almost embarrassingly simple — store the smaller objects as attributes — and it has one property inheritance cannot offer at all.
Intermediate16 min readPythonPart IFundamentalsOOPDesign
Part I · Module 16 · Chapter 16.13 · Composition: Objects Containing Objects
1. Storing Objects in Objects
There is no new syntax. An attribute can hold anything, including another object:
class Job:
def __init__(self, name):
self.name = name
def run(self):
return f"ran {self.name}"
class Reporter:
def __init__(self, prefix):
self.prefix = prefix
def line(self, text):
return f"{self.prefix} {text}"
class Regression:
def __init__(self, jobs, reporter):
self.jobs = jobs # has-a, not is-a
self.reporter = reporter
def run_all(self):
out = []
for j in self.jobs:
out.append(self.reporter.line(j.run()))
return out
r = Regression(
[Job("smoke"), Job("cdc")],
Reporter("[nightly]"),
)
for line in r.run_all():
print(line)[nightly] ran smoke
[nightly] ran cdcRegression has jobs and has a reporter. It is not a job and not a
reporter. That relationship is called composition, and the attribute is the
entire mechanism.
2. The Inheritance Version, and Why It Fails
Chapter 16.9 Section 7 rejected class Regression(Job) on the substitution
test. Here is what actually goes wrong when the test is ignored:
class Job:
def __init__(self, name):
self.name = name
def run(self):
return f"ran {self.name}"
class Regression(Job):
"""Wrong: a regression is not a job."""
def __init__(self, name, jobs):
super().__init__(name)
self.jobs = jobs
def run_everything(items):
"""Polymorphic over anything with run() -- Chapter 16.12."""
for i in items:
print(" ", i.run())
r = Regression("nightly", [Job("smoke"), Job("cdc")])
print("the inherited run():")
run_everything([r])
print("what should have happened:")
run_everything(r.jobs)the inherited run():
ran nightly
what should have happened:
ran smoke
ran cdcr.run() reported one imaginary job called nightly and never touched the
two real ones. No exception, no warning — the polymorphic code from Chapter
16.12 did exactly what it was written to do, and the answer is wrong.
This is the cost of failing the substitution test: the hierarchy promised that a
Regression can stand in for a Job, and code took it at its word.
3. The Composed Version Says What It Means
class Job:
def __init__(self, name):
self.name = name
def run(self):
return f"ran {self.name}"
class Regression:
"""A regression HAS jobs."""
def __init__(self, name, jobs):
self.name = name
self.jobs = jobs
def run_all(self):
out = []
for j in self.jobs:
out.append(j.run())
return out
r = Regression("nightly", [Job("smoke"), Job("cdc")])
print("run_all():", r.run_all())
print("is a Job :", isinstance(r, Job))
print("has run():", hasattr(r, "run"))run_all(): ['ran smoke', 'ran cdc']
is a Job : False
has run(): FalseRegression has no run(), which is correct — it cannot be mistaken for a
job by the polymorphic loop, and hasattr says so. The method it does have is
named for what it actually does.
4. The Swap Test
Here is composition's unique property, and the strongest argument for the default. A component can be replaced, per object, at runtime:
class Job:
def __init__(self, name):
self.name = name
def run(self):
return f"ran {self.name}"
class PlainReporter:
def line(self, text):
return text
class TimestampReporter:
"""No relationship to PlainReporter -- duck typing (Chapter 16.12)."""
def line(self, text):
return f"[00:00:01] {text}"
class Regression:
def __init__(self, jobs, reporter):
self.jobs = jobs
self.reporter = reporter
def run_all(self):
out = []
for j in self.jobs:
out.append(self.reporter.line(j.run()))
return out
r = Regression([Job("smoke")], PlainReporter())
print("plain :", r.run_all())
r.reporter = TimestampReporter() # swapped on a live object
print("timestamp:", r.run_all())
print("same object:", type(r).__name__)
print("reporters related:", issubclass(TimestampReporter, PlainReporter))plain : ['ran smoke']
timestamp: ['[00:00:01] ran smoke']
same object: Regression
reporters related: FalseOne Regression, two behaviours, swapped with an assignment. The two
reporters share no base class at all — duck typing is doing the work.
A subclass cannot do this. type(r) is fixed when the object is created; you
cannot turn a TimestampRegression into a PlainRegression afterwards, and you
would need a separate subclass for every combination of parts.
A base class is chosen once, at creation, for the lifetime of the object. A component is chosen per object and can change.
That difference compounds: three reporters and two config styles is six subclasses, or two attributes.
5. The Cost: Delegation
Composition is not free. Inheritance gives you the parent's methods without asking; composition means forwarding the ones you want:
class Reporter:
def __init__(self, prefix):
self.prefix = prefix
def line(self, text):
return f"{self.prefix} {text}"
def header(self):
return f"{self.prefix} === run start ==="
class Regression:
def __init__(self, reporter):
self.reporter = reporter
def header(self):
"""Delegation: a two-line method that just forwards."""
return self.reporter.header()
r = Regression(Reporter("[nightly]"))
print("delegated :", r.header())
print("or reach in:", r.reporter.line("ran smoke"))delegated : [nightly] === run start ===
or reach in: [nightly] ran smokeTwo options and both are normal: delegate by writing a forwarding method, or
let callers reach the component directly as r.reporter.line(...).
Delegating costs two lines per method. In exchange, each forwarded method is a
deliberate decision about the public surface (Chapter 16.7) — with
inheritance you get the parent's entire interface whether it makes sense for
your object or not, which is how Regression(Job) acquired a meaningless
run() in the first place.
6. Both Together Is Normal
Real designs use each where it fits:
class Job:
def __init__(self, name):
self.name = name
def describe(self):
return f"job {self.name}"
class SimulationJob(Job):
"""IS-A job -- inheritance."""
def __init__(self, name, seed):
super().__init__(name)
self.seed = seed
def describe(self):
return f"simulation {self.name} seed={self.seed}"
class Regression:
"""HAS jobs -- composition."""
def __init__(self, jobs):
self.jobs = jobs
def describe_all(self):
return [j.describe() for j in self.jobs]
r = Regression([SimulationJob("smoke", 1234), Job("lint")])
for line in r.describe_all():
print(" ", line) simulation smoke seed=1234
job lintSimulationJob inherits because it is a job. Regression composes because it
has jobs. The polymorphic describe_all works over a mixed list because of
Chapter 16.12.
7. The Test
Three questions, in order:
- Is-a? Can every instance of the new class be used wherever the other is expected (Chapter 16.9 Section 7)? If no, compose.
- Swap? Might you want to change this part per object, or at runtime? If yes, compose.
- Subset? Would inheriting bring methods that make no sense for your object? If yes, compose.
Applied:
| Relationship | Choice | Why |
|---|---|---|
SimulationJob and Job | inherit | is-a; every simulation is a job |
Regression and Job | compose | not a job; inheriting run() is actively wrong |
Regression and Reporter | compose | swappable per run |
ParseError and ValueError | inherit | is-a; callers catch it as one |
Report and a list of results | compose | a report is not a list |
The default, when none of the three questions settles it clearly: compose. It is easier to turn composition into inheritance later than the reverse.
8. Predict, Run, Observe
class Engine:
def __init__(self, label):
self.label = label
def start(self):
return f"{self.label} starting"
class ByInheritance(Engine):
pass
class ByComposition:
def __init__(self, engine):
self.engine = engine
a = ByInheritance("vcs")
b = ByComposition(Engine("vcs"))
print("A:", a.start())
print("B:", hasattr(b, "start"))
print("C:", b.engine.start())
b.engine = Engine("xcelium")
print("D:", b.engine.start())
print("E:", isinstance(a, Engine), isinstance(b, Engine))A: vcs starting
B: False
C: vcs starting
D: xcelium starting
E: True FalseA and C reach the same behaviour by different routes — one inherited, one held.
B is False: the composed object has no start() unless it delegates one.
That is the cost, and it is also why b can never be mistaken for an Engine.
D is the swap, on a live object. a has no equivalent move.
E — only the inheriting object passes isinstance. Whether that is an
advantage depends entirely on whether it genuinely is one.
9. Exercises
Exercise 1 — compose two. Write a class holding two other objects as attributes and a method that uses both.
Exercise 2 — the wrong hierarchy. Write class Regression(Job) and call the
inherited run(). Say what it did and why nothing raised.
Exercise 3 — fix it. Rewrite the same design with composition. Confirm with
hasattr that the collection no longer looks like a job.
Exercise 4 — the swap. Build an object with a swappable component. Replace it at runtime and show the behaviour change.
Exercise 5 — no base class. Confirm with issubclass that two
interchangeable components share nothing. Say which chapter makes that work.
Exercise 6 — count the subclasses. For three reporters and two config styles, count the subclasses an inheritance design would need. Compare with composition.
Exercise 7 — delegate. Write a forwarding method. Then expose the component directly instead and say which you would ship.
Exercise 8 — apply the test. For each, answer all three questions: (a) a
Timeout and an Exception (b) a Suite and its Jobs (c) a CachedJob and a
Job (d) a FlowRunner and its Logger.
10. Summary
- Composition is storing objects as attributes — there is no new syntax.
Regressionhas jobs; it is not one, and the distinction is the whole chapter.- A wrong hierarchy fails silently: an inherited
run()on a collection returns a plausible wrong answer to polymorphic code. - A composed object cannot be mistaken for the thing it holds, and
hasattrproves it. - A component can be swapped per object and at runtime; a base class is fixed when the object is created.
- Duck typing means components need no shared base class.
- Combinations multiply subclasses and merely add attributes — three reporters and two configs is six classes, or two fields.
- The cost is delegation: forwarding methods inheritance would have given free.
- Each forwarded method is a deliberate public-surface decision, which is a benefit; twenty of them is a smell.
- The test is three questions — is-a, swap, subset — and the default when none settles it is to compose.
The next chapter steps back from the machinery and follows one result through four representations, to find out when any of this is worth it.
Where this fits
Part of the Python curriculum.
