Skip to content
VLSI Mentor

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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
[nightly] ran smoke
[nightly] ran cdc

Regression 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.

Four blocks. On the left, a Regression. Three arrows labelled has-a lead from it to the parts it holds on the right: a list of jobs, a reporter that formats lines, and a config holding the settings. The Regression is not a kind of any of them; it contains them.Regressionowns the whole runA list of jobseach one runs itselfA reporterformats each lineA configseeds, timeouts, pathshas-ahas-ahas-a12
Figure 2 — a Regression holding three parts. Compare Figure 1 of Chapter 16.9, where the arrows meant inherits and pointed the other way: here nothing is being extended, only held.

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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
the inherited run():
  ran nightly
what should have happened:
  ran smoke
  ran cdc

r.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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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"))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
run_all(): ['ran smoke', 'ran cdc']
is a Job : False
has run(): False

Regression 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
plain    : ['ran smoke']
timestamp: ['[00:00:01] ran smoke']
same object: Regression
reporters related: False

One 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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"))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
delegated : [nightly] === run start ===
or reach in: [nightly] ran smoke

Two 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  simulation smoke seed=1234
  job lint

SimulationJob 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:

  1. Is-a? Can every instance of the new class be used wherever the other is expected (Chapter 16.9 Section 7)? If no, compose.
  2. Swap? Might you want to change this part per object, or at runtime? If yes, compose.
  3. Subset? Would inheriting bring methods that make no sense for your object? If yes, compose.

Applied:

RelationshipChoiceWhy
SimulationJob and Jobinheritis-a; every simulation is a job
Regression and Jobcomposenot a job; inheriting run() is actively wrong
Regression and Reportercomposeswappable per run
ParseError and ValueErrorinheritis-a; callers catch it as one
Report and a list of resultscomposea 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: vcs starting
B: False
C: vcs starting
D: xcelium starting
E: True False

A 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.
  • Regression has 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 hasattr proves 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.