2.1.15 · D2OOP Fundamentals

Visual walkthrough — Composition — has-a relationship vs is-a

2,038 words9 min readBack to topic

Before we draw anything, let us agree on the vocabulary so no symbol appears un-earned.


Step 1 — Two responsibilities, two boxes

WHAT. We start with one requirement: a car must be able to start(). Starting really means two separate jobs — "be a car" and "make ignition happen." We draw each job as its own empty box.

WHY. Ignition logic is a self-contained concept that changes on its own schedule (petrol today, electric tomorrow). If we mash it into the car box, then changing ignition means editing the car. Keeping them as separate boxes is the very first move of composition: one box per concept that can change independently.

PICTURE. Two boxes side by side, not yet connected. The Car box knows it needs some ignition, but does not yet know where it lives.

Figure — Composition — has-a relationship vs is-a

Step 2 — Give the sub-job its own class

WHAT. We turn the right-hand box into a real, usable class. Engine stores one number, horsepower, and offers one behaviour, ignite().

class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower          # data the engine owns
    def ignite(self):
        return f"Engine roaring at {self.horsepower}hp"

WHY. By isolating the volatile part, the Car will never reach inside the engine — it will only ever call the one public method ignite(). That single method is the entire contract between them. A small contract is a stable contract.

PICTURE. The Engine box now has an inner data slot (horsepower = 120, in yellow) and a public "port" for its method ignite() (in green). The port is the only way in.

Figure — Composition — has-a relationship vs is-a
  • horsepower — the yellow slot; it is private data, hidden behind the wall.
  • ignite() — the green port; the only public surface the outside world may touch.

Step 3 — The Car HOLDS an Engine (the "has-a" arrow appears)

WHAT. We give Car a field named engine and let an engine be passed in from outside.

class Car:
    def __init__(self, engine):        # engine handed in from outside
        self.engine = engine           # the has-a field: a slot holding the engine box
    def start(self):
        return "Car: " + self.engine.ignite()

WHY — the arrow, term by term. Look at self.engine = engine:

  • self.engine — a slot inside the Car box. This slot is what "has-a" means physically: the car literally holds a reference to another box.
  • = engine — we fill the slot with a box handed to us from outside. We did not build the engine ourselves; someone gave it to us. (This handing-in is called Dependency Injection and it is what makes swapping possible later.)

PICTURE. The solid blue has-a arrow now leaves the Car's engine slot and lands on the Engine box. This arrow is the composition relationship made visible.

Figure — Composition — has-a relationship vs is-a

Step 4 — start() delegates through the arrow

WHAT. We trace what happens when someone calls car.start(). The car does not know how to ignite — it forwards the request across the has-a arrow.

WHY. This forwarding is delegation. The car answers "how do I start?" with "I don't; I ask my engine." Term by term in "Car: " + self.engine.ignite():

  • self.engine — follow the blue arrow to the engine box.
  • .ignite() — call the engine's one public port (from Step 2).
  • "Car: " + ... — the car wraps the reply in its own words and hands it back.

PICTURE. A green call arrow curves out of start(), along the has-a link, into ignite(), and a return value flows back. The car never crosses the engine's wall — it only knocks on the green port.

Figure — Composition — has-a relationship vs is-a

Step 5 — The runtime swap (composition's superpower)

WHAT. While the program is still running, we drop a different engine into the same slot.

c.engine = Engine(300)     # pop the old engine out, click a new one in
print(c.start())           # Car: Engine roaring at 300hp

WHY. Because the connection is just an arrow to a box, we can re-point the arrow at any moment. c.engine = Engine(300) erases the old target and aims the same self.engine slot at a fresh box. The Car code did not change one character.

PICTURE. The old engine box fades (red, dashed) as the blue has-a arrow re-points to a new engine box. The slot is the same; only its target moved.

Figure — Composition — has-a relationship vs is-a

Step 6 — Edge case: the empty slot (engine is None)

WHAT. What if the slot is filled with nothingCar(None)? We must show this case; the reader will hit it eventually.

c = Car(None)
c.start()     # BOOM: AttributeError — None has no ignite()

WHY. Following the has-a arrow leads to a box that has no ports at all. Calling .ignite() on None fails because there is nothing to delegate to. Composition trades a compile-time guarantee for a runtime slot — so an empty slot is a real, catchable failure mode.

PICTURE. The has-a arrow points into a hollow red "no-box." The green call arrow leaves start() and hits empty space — the crash.

Figure — Composition — has-a relationship vs is-a

Step 7 — Edge case: two cars sharing one engine (aliasing)

WHAT. A subtle case: what if two car boxes point their slots at the same engine box?

e = Engine(120)
a = Car(e)
b = Car(e)          # both slots point to the SAME engine
e.horsepower = 999  # change it once...
# a.start() AND b.start() now both report 999

WHY. The slot holds a reference (an arrow), not a copy. Two arrows into one box means editing that box is felt by everyone pointing at it. This is the difference between "shared part" (aggregation) and "owned part" (composition) — see Aggregation vs Composition (UML).

PICTURE. Two has-a arrows from two different Car boxes converge on a single Engine box. A yellow "edit" flash on the shared engine ripples out along both arrows.

Figure — Composition — has-a relationship vs is-a

The one-picture summary

Everything above collapses into one diagram: the car box, its engine slot, the blue has-a arrow, the green delegation call, and the little "swap" loop that only composition allows — with the two edge cases (empty slot, shared slot) marked in red as the pitfalls to guard.

Figure — Composition — has-a relationship vs is-a
Recall Feynman: the whole walkthrough in plain words

We wanted a car that can start. Instead of teaching the car how to ignite, we built a separate little machine — the engine — that knows only one trick: ignite(). We gave the car a pocket (the engine field) and slipped the engine machine into it. When you tell the car "start," the car reaches into its pocket, pulls the engine's one lever, and repeats what the engine says. Because the engine sits in a pocket and not welded to the car's body, you can pull it out and drop in a stronger one while the car is still running — that pocket-swap is composition's whole gift. Two warnings: an empty pocket (None) crashes when the car reaches for the lever, and if two cars share one engine in their pockets, tuning that engine tunes both cars at once. Guard the empty pocket with a check; give each car its own engine if you don't want the sharing.


Recall

The blue arrow in every figure points from
the whole (Car) to the part (Engine) — the has-a direction.
The green arrow in the figures represents
delegation — a method calling the held object's public method.
Why can composition swap the part at runtime?
The field is just a re-pointable slot holding a reference; reassigning it aims the same slot at a new box without changing the class.
What breaks when the engine slot is None?
Delegation follows the arrow into nothing; calling .ignite() raises AttributeError. Guard with a null check.
Why do two cars sharing one engine both see edits?
The field stores a reference (arrow), not a copy; both arrows point at the same box, so mutating it ripples to all holders.

Connections

  • Parent: 2.1.15 Composition — has-a relationship vs is-a (Hinglish)
  • Contrast tool: Inheritance — is-a relationship · Liskov Substitution Principle
  • The small-surface idea: Encapsulation and Information Hiding
  • Handing the part in: Dependency Injection · Strategy Pattern
  • Shared vs owned parts: Aggregation vs Composition (UML)
  • What inheritance risks: Fragile Base Class Problem