Blog

Composition Over Inheritance in Python

Inheritance is the first tool most people reach for and the one that ages worst. The reason is simple: a subclass inherits everything, including the parts that do not apply to it.

The penguin problem

# inheritance for reuse: the shape that goes wrong
class Animal:
    def __init__(self, name): self.name = name
    def speak(self): return '...'
    def fly(self): return f"{self.name} flies away"

class Bird(Animal):
    def speak(self): return f"{self.name} tweets"

class Penguin(Bird):
    pass

p = Penguin('Pingu')
print(p.speak())
print(p.fly())          # inherited, and wrong

It prints:

Pingu tweets
Pingu flies away

A penguin is unambiguously a bird, and the hierarchy is unambiguously wrong. There was no mistake in the modelling — fly was put on the base class because most birds fly, which was true when it was written.

The usual patches make it worse. Override fly to raise, and you have a subclass that fails on its parent’s interface. Move fly down to a FlyingBird class, and you have to do it again the first time you meet a flightless anything else.

Composition: the capability is an object

# composition: the capability is an object, not an ancestor
class Wings:
    def fly(self, name): return f"{name} flies away"

class Bird2:
    def __init__(self, name, wings=None):
        self.name, self.wings = name, wings
    def speak(self): return f"{self.name} tweets"
    def fly(self):
        if self.wings is None:
            raise TypeError(f"{self.name} cannot fly")
        return self.wings.fly(self.name)

print(Bird2('Robin', Wings()).fly())
try:
    Bird2('Pingu').fly()
except TypeError as err:
    print(type(err).__name__ + ':', err)

It prints:

Robin flies away
TypeError: Pingu cannot fly

Flying is now something a bird has, not something it is. Adding a flightless bird takes no new class and no hierarchy change.

The payoff is swapping behaviour

The real argument is not taxonomy. It is that a composed capability can be changed at runtime and swapped in a test.

# swapping behaviour at runtime is the payoff
class JsonFormat:
    def render(self, row): return f'{{"name": "{row}"}}'
class CsvFormat:
    def render(self, row): return f'{row}'

class Report:
    def __init__(self, fmt): self.fmt = fmt
    def emit(self, rows): return [self.fmt.render(r) for r in rows]

print(Report(JsonFormat()).emit(['ada']))
print(Report(CsvFormat()).emit(['ada']))

It prints:

['{"name": "ada"}']
['ada']

One Report. As inheritance this would be JsonReport and CsvReport, and a third format means a third class — plus a real problem the day you want a report that is both.

Testing shows the difference most clearly. Pass in a fake formatter and you have tested Report without touching JSON. With inheritance you would have to subclass to intercept.

When inheritance is right

It has a job, and it is narrower than people use it for: when the relationship is genuinely “is a” and the base class is abstract — it defines an interface rather than shipping behaviour subclasses might not want.

# inheritance is right when it is genuinely 'is a' and the base is abstract
class Shape:
    def area(self): raise NotImplementedError
    def describe(self): return f"{type(self).__name__} with area {self.area():.2f}"

class Circle(Shape):
    def __init__(self, r): self.r = r
    def area(self): return 3.14159 * self.r ** 2

class Square(Shape):
    def __init__(self, s): self.s = s
    def area(self): return self.s ** 2

for s in (Circle(1), Square(2)):
    print(s.describe())

It prints:

Circle with area 3.14
Square with area 4.00

Shape promises area and offers describe built on it. No subclass inherits something it should not have, because there is nothing concrete to inherit. Part 9 covers ABCs and Protocols, which make that promise enforceable.

What to remember

  • Inheritance gives a subclass everything, including what it should not have.

  • Reach for composition when you are inheriting to reuse code rather than to declare a type.

  • The practical benefit is swapping and faking behaviour, not modelling purity.

  • Inheritance fits when the base is abstract and the relationship is genuinely “is a”.

The rule of thumb that survives contact with real code: if you are writing a subclass to get access to a method, you want composition. If you are writing one to promise an interface, inheritance is fine.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.