Inheritance & Composition

Explore · optional
The Course
•
January 10, 2026
•
8 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 issubclass Python
# `pass` means "nothing here" - Python needs a body after the colon, and
# these three classes exist only to show the relationship between them.
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))

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

Where Python looks: the MRO

When you call pup.speak(), Python does not search the whole family tree hoping to find something. Each class carries a fixed, ordered list of the classes to look in — its method resolution order, almost always written MRO — and the first one that has a speak wins. __mro__ prints that list.

The first match wins Python
class Animal:
    def speak(self):
        return "..."

    def breathe(self):
        return "in and out"

class Dog(Animal):
    def speak(self):
        return "Woof"          # overrides Animal's

class Puppy(Dog):
    pass                       # adds nothing of its own

pup = Puppy()

print(pup.speak())             # Dog's - the first speak in the list
print(pup.breathe())           # Animal's - nothing nearer defines one

# __mro__ holds the classes themselves, which print as <class '__main__.Dog'>.
# This comprehension swaps each one for its .__name__ so the order is readable.
print(Puppy.__mro__)
print([cls.__name__ for cls in Puppy.__mro__])

The order is the one you would have guessed: the class itself, then its parent, then its parent, ending at object — the class every Python object inherits from, and the reason print() and == work on a class you just wrote.

This list is also what super() walks, which is why super().describe() inside Manager found Employee.describe earlier: Employee is simply the next name after Manager in the MRO.

Why it has such a formal name

With one parent per class the order is just a chain, and __mro__ tells you little you could not work out. It earns the name when a class has two parents — class C(A, B) — where “look upwards” is ambiguous and Python computes a single consistent order for you. You are unlikely to need those rules soon, but ClassName.__mro__ will explain almost any “which method actually ran?” surprise you ever hit.

Before you write your own

Predict. One subclass overrides send; the other doesn’t.

What will this print? Python
class Notification:
    def __init__(self, message):
        self.message = message

    def send(self):
        return f"sending: {self.message}"

class Email(Notification):
    def send(self):
        return "email " + super().send()

class SMS(Notification):
    pass

print(Email("hi").send())
print(SMS("hey").send())
print(isinstance(Email("x"), Notification))
Check your prediction
email sending: hi
sending: hey
True

SMS has no send of its own, so Python finds the parent’s. Email has one, and super().send() calls the parent’s version from inside it. An Email is a Notification, which is what isinstance confirms.

Fix. This should print Ada 0 0.02.

The forgotten parent Python
class Account:
    def __init__(self, owner):
        self.owner = owner
        self.balance = 0

class Savings(Account):
    def __init__(self, owner, rate):
        self.rate = rate

s = Savings("Ada", 0.02)
print(s.owner, s.balance, s.rate)
Check your fix
class Account:
    def __init__(self, owner):
        self.owner = owner
        self.balance = 0

class Savings(Account):
    def __init__(self, owner, rate):
        super().__init__(owner)
        self.rate = rate

s = Savings("Ada", 0.02)
print(s.owner, s.balance, s.rate)
Ada 0 0.02

Defining __init__ in the subclass replaces the parent’s, so the parent’s setup never ran and there was no owner. super().__init__(owner) runs it first, then the subclass adds what’s new.

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. The numbers, to two decimal places:

Rectangle: 12.00
Circle: 12.57
Rectangle: 4.00
Total: 28.57
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)]
Hint 1: the idea

Each subclass stores its own measurements in __init__ and overrides area() with its own formula. The base class only promises that every shape has an area. Because they share that one method, a single loop can ask any shape for its area without caring which kind it is.

Hint 2: a first step

Write class Rectangle(Shape): with an __init__ that stores width and height and an area that returns their product. Check print(Rectangle(3, 4).area()) shows 12 before writing Circle the same way (pi * radius ** 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.
  • __mro__ is the ordered list of classes Python searches for an attribute; super() walks the same list.
Last updated on January 10, 2026

Was this article helpful?

Your response is saved on this device.