Inheritance & Composition

The Course
January 10, 2026
4 min read

When two classes share behaviour, you have two ways to avoid writing it twice: inheritance, where one class is a kind of another, and composition, where one class has a helper inside it.

Inheritance

A subclass gets everything the parent has, and can add or replace parts.

A parent and two children Python
class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return "..."

    def introduce(self):
        return f"{self.name} says {self.speak()}"

class Dog(Animal):
    def speak(self):
        return "Woof"

class Cat(Animal):
    def speak(self):
        return "Meow"

for animal in [Dog("Rex"), Cat("Momo"), Animal("Thing")]:
    print(animal.introduce())

introduce is written once, in Animal, but calls self.speak() — and self is whichever animal it actually is. That is polymorphism: the same call, different behaviour, decided by the object.

super()

When a subclass needs its own __init__ as well as the parent’s, super() calls up the chain.

Extending, not replacing Python
class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    def describe(self):
        return f"{self.name} earns {self.salary}"

class Manager(Employee):
    def __init__(self, name, salary, reports):
        super().__init__(name, salary)      # run the parent's setup first
        self.reports = reports              # then add our own

    def describe(self):
        base = super().describe()           # reuse the parent's logic
        return f"{base} and manages {len(self.reports)} people"

ada = Employee("Ada", 90_000)
grace = Manager("Grace", 120_000, ["Ada", "Alan"])

print(ada.describe())
print(grace.describe())
print(isinstance(grace, Employee), isinstance(ada, Manager))

isinstance(grace, Employee) is True: a Manager is an Employee. That relationship is the test for whether inheritance is the right tool.

When inheritance is wrong

The classic mistake

A Car is not a kind of Engine, so class Car(Engine) is wrong however much code it saves. A car has an engine. Inheritance models what something is; composition models what it has.

Composition Python
class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower

    def start(self):
        return f"engine ({self.horsepower}hp) running"

class GPS:
    def route(self, destination):
        return f"routing to {destination}"

class Car:
    def __init__(self, model, horsepower):
        self.model = model
        self.engine = Engine(horsepower)     # has-a
        self.gps = GPS()                     # has-a

    def start(self):
        return f"{self.model}: {self.engine.start()}"

car = Car("Saloon", 140)
print(car.start())
print(car.gps.route("Lisbon"))

Composition keeps the pieces independently understandable and independently testable. Modern Python code uses it far more than inheritance.

Duck typing

Python does not require a shared base class for polymorphism. If an object has the method you call, it works — “if it walks like a duck and quacks like a duck”.

No common ancestor needed Python
class Duck:
    def speak(self):
        return "Quack"

class Robot:
    def speak(self):
        return "Beep"

class Person:
    def speak(self):
        return "Hello"

# Nothing here inherits from anything, and it makes no difference:
for thing in [Duck(), Robot(), Person()]:
    print(thing.speak())

This is why Python code often has shallow class hierarchies: you rarely need a common parent just to treat objects the same way.

Checking types

isinstance and the MRO Python
class Animal: pass
class Dog(Animal): pass
class Puppy(Dog): pass

pup = Puppy()

print(isinstance(pup, Puppy), isinstance(pup, Dog), isinstance(pup, Animal))
print(type(pup) is Dog)              # exact type - usually too strict
print(issubclass(Puppy, Animal))

# The method resolution order: where Python looks, in order.
print([cls.__name__ for cls in Puppy.__mro__])

Prefer isinstance over type(x) is C — it accepts subclasses, which is almost always what you meant.

Exercise

Shapes

Write a base class Shape with an area() that raises NotImplementedError, then Rectangle(width, height) and Circle(radius) subclasses that implement it. Print each shape’s area, then the total.

from math import pi

class Shape:
    def area(self):
        raise NotImplementedError("subclasses must implement area()")

# Write Rectangle(width, height) and Circle(radius) here, then:
# shapes = [Rectangle(3, 4), Circle(2), Rectangle(2, 2)]
Show one solution
from math import pi

class Shape:
    def area(self):
        raise NotImplementedError("subclasses must implement area()")

    def describe(self):
        return f"{type(self).__name__}: {self.area():.2f}"

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return pi * self.radius ** 2

shapes = [Rectangle(3, 4), Circle(2), Rectangle(2, 2)]

for shape in shapes:
    print(shape.describe())

print(f"Total: {sum(shape.area() for shape in shapes):.2f}")

raise NotImplementedError in the base turns “I forgot to implement area” from a silent wrong answer into an immediate, clearly named error. type(self).__name__ gives each subclass its own label without repeating describe three times.

What you learned

  • class Child(Parent): inherits every attribute and method.
  • Overriding a method changes behaviour for that subclass only.
  • super() calls the parent’s version, typically inside __init__.
  • Use inheritance for “is a”, composition for “has a” — and prefer composition.
  • Duck typing means a shared base class is often unnecessary.
Last updated on January 10, 2026

Was this article helpful?

Your response is saved on this device.