Intuition What this page is for
The parent note taught you the rule ("has-a → compose, is-a → inherit"). This page walks you through every kind of situation that rule can face — the clean cases, the tricky ones, the degenerate ones, and the exam traps — so that when a real design lands on your desk you have already seen its shape.
We first lay out a scenario matrix : a grid of every "case class" this topic throws at you. Then each worked example is tagged with the matrix cell it fills. By the end, no cell is empty.
Think of each row as a dimension the problem can vary along — like the sign of a number or the quadrant of an angle in geometry. Here the "dimensions" are the shapes a class relationship can take.
Cell
Case class
What makes it tricky
Filled by
C1
Clean has-a
part is obvious, delegate cleanly
Ex 1
C2
Clean is-a
true substitutability, inherit
Ex 2
C3
Fake is-a (too strong)
inheritance leaks a dangerous interface
Ex 3
C4
Both look true — decide
grammar test is ambiguous, use Liskov
Ex 4
C5
Zero / empty part (degenerate)
part missing or is a do-nothing object
Ex 5
C6
Many parts (combinatorial)
avoid 2 n class explosion
Ex 6
C7
Runtime swap (limiting behaviour)
change the part while running
Ex 7
C8
Word problem (real world)
translate English → design
Ex 8
C9
Exam twist — "is-a that should be has-a"
recognise the trap under time pressure
Ex 9
Intuition Why these dimensions and not others
In a geometry topic the axes are signs and quadrants . In an object-modelling topic the axes are: which relationship is true , how many parts , does the part exist at all (zero case), and when can it change (compile-time vs run-time). Cover those and you have covered the space.
Let me define the one measuring tool we lean on throughout, so no term is used before it's earned.
Definition Substitutability (the Liskov test)
A type B is substitutable for type A when any code that works with an A still works, unchanged, when handed a B . If substitutability holds, "B is-a A " is a true statement and inheritance is legal. If it fails, you must use composition. See Liskov Substitution Principle .
The figure above is our map: the left shape is is-a (one arrow flowing up — the child stands in for the parent), the red shape on the right is has-a (a box holding a smaller box it talks to). Every example lands on one side or the other.
Car has-a Engine
Build a Car that reports its top speed by asking its engine for horsepower. Engine of 120 hp gives top speed = 120 × 1.2 = 144 (arbitrary game formula).
Forecast: guess now — will Car inherit Engine, or hold one?
Ask the English test. "A car has an engine" ✅ / "A car is an engine" ❌.
Why this step? The grammar mirrors the type rule (parent note). Only "has-a" reads true, so we hold , not inherit.
Give the part its own class.
class Engine :
def __init__ (self, hp): self .hp = hp
def power (self): return self .hp
Why this step? Isolate the volatile concept so Car never touches hp directly — Encapsulation and Information Hiding .
Hold it and delegate.
class Car :
def __init__ (self, engine): self .engine = engine
def top_speed (self): return self .engine.power() * 1.2
c = Car(Engine( 120 ))
print (c.top_speed()) # 144.0
Why this step? top_speed delegates the power() question to the held object — that forwarding is composition.
Verify: 120 × 1.2 = 144 . Units: hp × (speed/hp factor) → speed. Car never mentions hp, so encapsulation holds. ✅
Circle is-a Shape
Every shape must answer area(). A circle of radius r = 5 has area π r 2 ≈ 78.53975 .
Forecast: inherit or compose?
Substitutability check. Any code calling shape.area() works if handed a Circle.
Why this step? That is the Liskov Substitution Principle test — it passes , so "is-a" is genuinely true.
Inherit and override.
class Shape :
def area (self): raise NotImplementedError
class Circle ( Shape ):
def __init__ (self, r): self .r = r
def area (self): return 3.14159 * self .r ** 2
print (Circle( 5 ).area()) # 78.53975
Why this step? We reuse the contract (area) and only supply the differing formula. See Inheritance — is-a relationship .
Verify: 3.14159 × 5 2 = 3.14159 × 25 = 78.53975 . A Circle can go anywhere a Shape is expected. ✅
Stack(list) leaks a dangerous interface
A Stack should only allow push and pop. Show that inheriting list breaks LIFO, then fix with has-a. Push 1, 2, 3 then a legal pop() must return 3.
Forecast: does class Stack(list) truly satisfy "a stack is a list"?
Run the substitutability test — and watch it fail. A list supports insert(0, x) and s[5] = .... A Stack must not . So a Stack is not usable everywhere a list is used in reverse — but more importantly it inherits methods that violate its own invariant .
Why this step? The is-a claim is too strong : it promises the whole list interface. See Fragile Base Class Problem .
Switch to has-a with a hidden list.
class Stack :
def __init__ (self): self ._items = []
def push (self, x): self ._items.append(x)
def pop (self): return self ._items.pop()
s = Stack(); s.push( 1 ); s.push( 2 ); s.push( 3 )
print (s.pop()) # 3
Why this step? Only push/pop are public; insert/indexing are gone, so LIFO cannot be violated.
Verify: LIFO = last in, first out. Last pushed was 3, so pop() → 3. ✅ No insert exists on Stack, so the dangerous surface is sealed.
Queue a kind of Deque?
A double-ended queue (Deque) allows add/remove at both ends. A Queue allows add at back, remove at front only. "A queue is a restricted deque" sounds plausible. Decide.
Forecast: inherit Queue(Deque) or compose?
Apply substitutability precisely. Code holding a Deque may legally call add_front(). If a Queue were substitutable, it must accept add_front() — but a queue forbids that. Substitutability fails.
Why this step? Inheritance that removes capabilities breaks Liskov — the child promises less than the parent.
Choose composition.
class Deque :
def __init__ (self): self ._d = []
def add_front (self, x): self ._d.insert( 0 , x)
def add_back (self, x): self ._d.append(x)
def remove_front (self): return self ._d.pop( 0 )
class Queue :
def __init__ (self): self ._dq = Deque() # has-a
def enqueue (self, x): self ._dq.add_back(x)
def dequeue (self): return self ._dq.remove_front()
q = Queue(); q.enqueue( 10 ); q.enqueue( 20 )
print (q.dequeue()) # 10
Why this step? Queue uses a Deque internally but exposes only its own two safe methods.
Verify: FIFO = first in, first out. First enqueued 10 → dequeue() returns 10. ✅ add_front is unreachable through Queue.
Worked example The empty engine (Null Object)
What if a Car is built with no engine yet? A crash on top_speed() is ugly. Model the "no part" case so top speed is a safe 0.
Forecast: what should Car(None).top_speed() return — a crash, or 0?
Introduce a do-nothing part (the Null Object). Instead of storing None, store an engine that reports 0 power.
class NullEngine :
def power (self): return 0
class Car :
def __init__ (self, engine = None ):
self .engine = engine or NullEngine()
def top_speed (self): return self .engine.power() * 1.2
print (Car().top_speed()) # 0.0
Why this step? Composition lets us swap in a degenerate part that satisfies the same tiny interface (power()), so Car needs zero special-case if logic. This is the zero-input case handled gracefully.
Contrast with inheritance. With Car(Engine) you could not "have no engine" — the parent's guts are always fused in.
Verify: 0 × 1.2 = 0 . No exception raised; the empty case behaves like a car that simply can't move. ✅
Worked example Robots that fly and/or swim
Robots may fly, swim, or both. With inheritance you'd need 2 n classes for n abilities. Build an amphibious robot from parts and list its moves.
Forecast: how many classes does inheritance need for 2 abilities + their combo? (Answer: fly, swim, fly-swim → 3 = 2 2 − 1 .)
Make each ability a part.
class Flyer : def move (self): return "flying"
class Swimmer : def move (self): return "swimming"
Why this step? Abilities become objects you mix , not classes you subclass. See Strategy Pattern .
Hold a list of parts and delegate to each.
class Robot :
def __init__ (self, abilities): self .abilities = abilities
def act (self): return [a.move() for a in self .abilities]
print (Robot([Flyer(), Swimmer()]).act()) # ['flying', 'swimming']
Why this step? One Robot class covers all 2 n combinations by choosing which parts to inject.
Verify: For n = 3 abilities, inheritance needs 2 3 − 1 = 7 concrete combo classes; composition needs 1 Robot + 3 ability classes = 4 . Output list length = number of abilities = 2. ✅
Worked example Swap petrol for electric mid-run
Start a car on a 120 hp petrol engine, then hot-swap a 300 hp electric engine while the program runs , and re-read top speed.
Forecast: can inheritance change the engine after construction? (No — it's frozen at compile time.)
Build with dependency injection.
class Engine :
def __init__ (self, hp): self .hp = hp
def power (self): return self .hp
class Car :
def __init__ (self, engine): self .engine = engine
def top_speed (self): return self .engine.power() * 1.2
c = Car(Engine( 120 ))
print (c.top_speed()) # 144.0
Why this step? Passing the part in is Dependency Injection — it makes the field replaceable.
Reassign the field.
c.engine = Engine( 300 )
print (c.top_speed()) # 360.0
Why this step? Composition stores the part in a variable , and variables can be reassigned at run time — the limiting case inheritance simply cannot reach.
Verify: before 120 × 1.2 = 144 ; after 300 × 1.2 = 360 . The same Car object now reports a different speed with no new class. ✅
Worked example Model a coffee shop order
"A Latte is a coffee drink. Each Order has a drink and has a payment method." A latte costs 4.50; a card payment adds a 2% surcharge. Compute the order total.
Forecast: which relationships are is-a and which are has-a?
Parse the English. "Latte is a Drink" → inherit . "Order has a drink / has a payment" → compose .
Why this step? Two different relationships in one sentence — read each aloud and let grammar pick the tool.
Code the is-a hierarchy.
class Drink :
def price (self): raise NotImplementedError
class Latte ( Drink ):
def price (self): return 4.50
Code the has-a container with two parts.
class CardPayment :
def charge (self, amount): return amount * 1.02
class Order :
def __init__ (self, drink, payment):
self .drink = drink; self .payment = payment
def total (self):
return self .payment.charge( self .drink.price())
print (Order(Latte(), CardPayment()).total()) # 4.59
Why this step? Order delegates pricing to its drink and charging to its payment — two independent parts, both swappable (Aggregation vs Composition (UML) ).
Verify: 4.50 × 1.02 = 4.59 . Units: currency × (1 + surcharge fraction) → currency. ✅
Worked example Spot the trap:
PremiumUser(dict)
An exam gives: class PremiumUser(dict): ... storing user fields. It "works." State why it's wrong and give the has-a fix. A premium user has name "Ana" and tier "gold".
Forecast: is "a premium user is a dict" a true is-a? (No.)
Run the substitutability test. A dict exposes clear(), popitem(), arbitrary key writes. Any of these can silently wipe a user's identity. A PremiumUser should not offer them.
Why this step? The inherited surface is far larger than the concept — the classic exam trap of confusing code reuse with is-a (Fragile Base Class Problem ).
Fix with has-a + a controlled interface.
class PremiumUser :
def __init__ (self, name, tier):
self ._data = { "name" : name, "tier" : tier} # has-a dict
def name (self): return self ._data[ "name" ]
def tier (self): return self ._data[ "tier" ]
u = PremiumUser( "Ana" , "gold" )
print (u.name(), u.tier()) # Ana gold
Why this step? The dict is now a private implementation detail ; only name() and tier() are public, so clear()/popitem() can't corrupt the user.
Verify: name() → "Ana", tier() → "gold". The dict's destructive methods are unreachable from outside. ✅
Recall Which cell was which?
C1 clean has-a (Car/Engine) ::: Ex 1
C2 clean is-a (Circle/Shape) ::: Ex 2
C3 fake is-a too strong (Stack/list) ::: Ex 3
C4 both look true → Liskov decides (Queue/Deque) ::: Ex 4
C5 zero/degenerate part (Null Object engine) ::: Ex 5
C6 combinatorial explosion (Robots) ::: Ex 6
C7 runtime swap (petrol → electric) ::: Ex 7
C8 real-world word problem (coffee Order) ::: Ex 8
C9 exam twist (PremiumUser/dict) ::: Ex 9
Mnemonic The decision loop
Read → Test → Choose. Read "A is a B" vs "A has a B" aloud; Test substitutability if both sound plausible; Choose inherit only when the whole parent interface is safe on the child, else compose.
2.1.15 Composition — has-a relationship vs is-a (Hinglish)
Inheritance — is-a relationship
Liskov Substitution Principle
Encapsulation and Information Hiding
Dependency Injection
Strategy Pattern
Aggregation vs Composition (UML)
Fragile Base Class Problem