Skip to content
BytePatterns

Composition vs Inheritance: Why Has-A Usually Beats Is-A

8 min readBytePatterns

Composition vs inheritance with runnable Python: the subclass explosion, the fragile base class, swapping parts at runtime, and when inheritance is right.

"Favour composition over inheritance" is one of the most repeated lines in object-oriented design, and one of the least explained. Interviewers ask about it because the answer shows whether you have maintained a class hierarchy after its second year. The short version: inheritance fixes behaviour into a family tree when the class is written, while composition assembles an object from parts that can be recombined and swapped. The long version is two concrete failures of inheritance, and the one case where it is still the right tool.

The problem it solves

Both techniques reuse code, but they make different promises.

  • Inheritance says a subclass is a kind of its parent. Callers may use it anywhere the parent is accepted, and it receives the parent's code, including its internal habits.
  • Composition says an object has parts and calls them through their public methods. It promises nothing about being anything else.

The trouble starts when inheritance is used only to reuse code, with no genuine is-a claim behind it. Then every new combination of features needs a new class, and every change inside the parent can break children that relied on how it worked.

The intuition

Picture a production line that can move items, stamp them and paint them. With inheritance you write MovingLine, StampingLine, then MovingStampingLine when someone needs both, and so on. Each of k independent behaviours can be on or off, so covering every combination takes 2^k - 1 subclasses: three behaviours need seven, five need thirty-one. And the choice is made when the class is written; a running line cannot pick up a stamper.

With composition, Line holds a list of stage objects and calls apply on each. Three behaviours are three small classes, and any combination is a constructor argument. Adding a stage at runtime is a list append.

The second failure is subtler: the fragile base class. A subclass that overrides methods depends on how the parent calls its own methods, which is an implementation detail nobody promised to keep. If the parent's add_all happens to call add, an overriding subclass that counts in both methods counts everything twice, and it breaks again if the parent later stops calling add. A wrapper that holds the object and uses only its public methods cannot be caught this way.

Watch it run

The first act is the hierarchy. Inheritance says a child is its parent, decided once, when the class is defined. Two behaviours give two subclasses, and so far nothing hurts. Then you need both at once, and the tree has no seat for that without a new class. Each new behaviour doubles the combinations: four behaviours make fifteen subclasses, with behaviour frozen and scattered across ancestors. The second act asks a different question: what parts does this object hold? Line has-a list of stages; it is-a nothing and inherits from nothing. run() walks the stages it holds and hands the item along, so one stage gives one transformation, blank +moved. New behaviour is appending an object, at runtime, with no subclass anywhere, and the same call on the same object prints blank +moved +stamped. Swap one part and only that part changes, which the family tree never offered. The last frame keeps inheritance in its lane: it earns its place where the is-a claim is genuinely true, and nowhere else.

Composition vs Inheritance

Step 1 of 12

Inheritance says a child is its parent — decided once, at import time.

The same interactive animation as the lesson — step through it with the controls.

The code

The lesson's line, extended with a third stage and a swap, and the subclass count the inheritance route would need:

from itertools import combinations

class Belt:
    def apply(self, item): return item + " +moved"

class Stamper:
    def apply(self, item): return item + " +stamped"

class Painter:
    def apply(self, item): return item + " +painted"

class Line:
    """HAS-A list of stages. IS-A nothing."""
    def __init__(self, *stages):
        self.stages = list(stages)

    def run(self, item):
        for stage in self.stages:
            item = stage.apply(item)
        return item

line = Line(Belt())
print(line.run("blank"))                           # blank +moved
line.stages.append(Stamper())                      # new behaviour at runtime
print(line.run("blank"))                           # blank +moved +stamped
line.stages[1] = Painter()                         # swap one part, touch nothing else
print(line.run("blank"))                           # blank +moved +painted

# The inheritance version needs one subclass per combination of behaviours.
def subclasses_needed(behaviours):
    return [combo for r in range(1, len(behaviours) + 1) for combo in combinations(behaviours, r)]

for k in (2, 3, 4, 5):
    print(k, "behaviours:", len(subclasses_needed(range(k))), "subclasses vs", k, "parts")
# 2 behaviours: 3 subclasses vs 2 parts
# 3 behaviours: 7 subclasses vs 3 parts
# 4 behaviours: 15 subclasses vs 4 parts
# 5 behaviours: 31 subclasses vs 5 parts

The fragile base class, and the wrapper that is immune to it. Then the case where inheritance is exactly right: Python's own exception hierarchy, where KeyError really is a LookupError and callers rely on that:

class Bag:
    def __init__(self):
        self.items = []

    def add(self, x):
        self.items.append(x)

    def add_all(self, xs):                         # an implementation detail: calls add()
        for x in xs:
            self.add(x)

class CountingBag(Bag):                            # inheritance: depends on Bag's internals
    def __init__(self):
        super().__init__()
        self.added = 0

    def add(self, x):
        self.added += 1
        super().add(x)

    def add_all(self, xs):
        self.added += len(xs)
        super().add_all(xs)                        # ... which calls our add() again

class CountingWrapper:                             # composition: only uses Bag's public API
    def __init__(self, bag):
        self.bag, self.added = bag, 0

    def add(self, x):
        self.added += 1
        self.bag.add(x)

    def add_all(self, xs):
        self.added += len(xs)
        self.bag.add_all(xs)

a, b = CountingBag(), CountingWrapper(Bag())
a.add_all(["x", "y", "z"])
b.add_all(["x", "y", "z"])
print(a.added, b.added)                            # 6 3  the subclass counted twice

# Where inheritance is right: a genuine is-a that callers rely on.
print(issubclass(KeyError, LookupError), issubclass(IndexError, LookupError))   # True True
try:
    {}["missing"]
except LookupError as e:                           # one handler for every lookup failure
    print(type(e).__name__)                        # KeyError

A refactor check: build the whole family tree the inheritance way, one class per combination via cooperative mixins, and compare every member with the composed Line on 2,000 seeded random inputs:

import random

class Base:
    def run(self, item):
        return item

def mixin(stage):
    class Mixin(Base):
        def run(self, item):
            return stage.apply(super().run(item))
    return Mixin

parts = [Belt(), Stamper(), Painter()]
mixins = [mixin(p) for p in parts]
family = {}
for combo in subclasses_needed(range(len(parts))):
    bases = tuple(mixins[i] for i in reversed(combo)) # MRO: the last base runs first
    family[combo] = type("Line_" + "_".join(map(str, combo)), bases, {})

rng = random.Random(30)
ok = len(family) == 7
for _ in range(2_000):
    combo = rng.choice(list(family))
    item = "".join(rng.choice("abc") for _ in range(rng.randint(0, 5)))
    ok &= family[combo]().run(item) == Line(*(parts[i] for i in combo)).run(item)
print(len(family), ok)                             # 7 True

Seven generated classes do what three small parts and one Line already did, and the mixin version depends on the method resolution order to run stages in the right sequence.

The complexity

  • Classes to write: 2^k - 1 subclasses for k independent behaviours with inheritance, against k parts plus one host with composition.
  • Runtime cost: one extra method call per delegated operation, negligible next to the work the parts do.
  • Change cost: editing a parent can affect every descendant; editing a part affects only the objects that hold it.

Where it goes wrong

  • Inheriting for reuse alone. If the subclass cannot honestly be used everywhere the parent is, it breaks the substitution rule; hold the class instead.
  • Overriding methods the parent calls internally. That couples you to its implementation, as the double count shows.
  • Composition taken to extremes. Wrapping every method by hand is tedious; delegate only what the wrapper needs to expose.
  • Deep hierarchies. Reading one method means walking five ancestors. Two levels is usually plenty.

When it shows up in interviews

As a direct question ("composition or inheritance, and why?"), in SOLID discussions, and inside low-level design rounds such as a parking lot, where the tempting hierarchy of vehicle types meets a pricing rule that varies separately. Patterns built on composition include the strategy pattern and the observer pattern.

How to say it in an interview

"I reach for composition by default. Inheritance is an is-a claim that's fixed when the class is written, and it couples the child to the parent's internals, so a change to how the base calls its own methods can break subclasses. It also multiplies: independent behaviours need a subclass per combination. With composition the object holds parts behind small interfaces, so combinations are constructor arguments and parts can be swapped at runtime or in tests. I still use inheritance when the is-a relationship is real and callers depend on it, like an exception hierarchy, and I keep it shallow."