- Create child classes that inherit from parent classes and explain the relationship using is-a terminology
- Override methods in child classes and use super() to call the parent implementation
- Explain the method resolution order (MRO) and predict which method Python will call in a multiple-inheritance hierarchy
- Contrast inheritance (is-a) with composition (has-a) and justify when each approach is appropriate
- Define abstract base classes using the abc module to enforce interfaces on subclasses
Once you can define a class, the next question is inevitable: what happens when two classes are similar? A savings account and a current account both have owners and balances, both support deposits and withdrawals — but they differ in fees, interest, and overdraft rules. You could duplicate the shared logic in both classes, but duplication is the enemy of maintainability. Inheritance lets you define what is common once, in a parent class, and specify only what differs in each child. It is one of the most powerful — and most misused — tools in object-oriented programming.
Inheritance Basics
Inheritance means creating a new class based on an existing one. The new class (the child or subclass) automatically gets all the attributes and methods of the existing class (the parent or superclass). You specify inheritance by putting the parent class in parentheses:
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} makes a sound"
class Dog(Animal):
def fetch(self, item):
return f"{self.name} fetches the {item}"
rex = Dog("Rex")
print(rex.speak()) # Output: Rex makes a sound
print(rex.fetch("ball")) # Output: Rex fetches the ball
Dog inherits __init__ and speak from Animal without writing a single line of duplicated code. It adds its own method, fetch, that only dogs have. The relationship is described as "is-a": a Dog is an Animal.
You can verify this with isinstance:
print(isinstance(rex, Dog)) # Output: True
print(isinstance(rex, Animal)) # Output: True
The isinstance check returns True for both, because a Dog is an Animal. This is the Liskov substitution principle in action: anywhere your code expects an Animal, you can pass a Dog (or any other subclass) and it should work correctly. If a subclass breaks the assumptions of its parent, the inheritance relationship is a lie — and lies in code cause bugs.
Method Overriding and super()
A child class can override a parent method by defining a method with the same name. The child's version replaces the parent's:
class Cat(Animal):
def speak(self):
return f"{self.name} says meow"
whiskers = Cat("Whiskers")
print(whiskers.speak()) # Output: Whiskers says meow
Often, you want to extend the parent's behaviour rather than replace it entirely. The super() function gives you access to the parent class:
class Puppy(Dog):
def __init__(self, name, toy):
super().__init__(name)
self.toy = toy
def speak(self):
parent_sound = super().speak()
return f"{parent_sound}... but it's just a puppy bark"
p = Puppy("Max", "squeaky bone")
print(p.speak()) # Output: Max makes a sound... but it's just a puppy bark
print(p.toy) # Output: squeaky bone
super().__init__(name) calls Animal.__init__, ensuring the parent's initialisation logic runs before the child adds its own attributes. Forgetting to call super().__init__() is a common bug — the child works fine until someone tries to access an attribute that only the parent's __init__ sets up.
The Method Resolution Order
When a class inherits from multiple parents, Python needs a rule for deciding which method to call. This rule is the method resolution order (MRO), and Python uses an algorithm called C3 linearisation to compute it. You can inspect the MRO with the __mro__ attribute:
class A:
def greet(self):
return "Hello from A"
class B(A):
def greet(self):
return "Hello from B"
class C(A):
def greet(self):
return "Hello from C"
class D(B, C):
pass
d = D()
print(d.greet()) # Output: Hello from B
print(D.__mro__)
# Output: (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)
Python searches D first, then B, then C, then A, then object. The first class that defines greet wins. This deterministic ordering prevents the ambiguity that plagues multiple inheritance in other languages.
The Diamond Problem
The configuration above — where D inherits from both B and C, which both inherit from A — is called the diamond problem because the inheritance diagram forms a diamond shape. The danger is that A.__init__ might run twice. Python's MRO and super() solve this: when every class in the hierarchy uses super(), each class's method is called exactly once, in MRO order.
class A:
def __init__(self):
print("A.__init__")
super().__init__()
class B(A):
def __init__(self):
print("B.__init__")
super().__init__()
class C(A):
def __init__(self):
print("C.__init__")
super().__init__()
class D(B, C):
def __init__(self):
print("D.__init__")
super().__init__()
D()
# Output:
# D.__init__
# B.__init__
# C.__init__
# A.__init__
Each __init__ runs once, in MRO order. The key rule: always use super() rather than calling a parent class by name, and always accept **kwargs in __init__ if you are designing for cooperative multiple inheritance.
In practice, the diamond problem is rare in application code. Most Python classes use single inheritance or simple composition. But when you use libraries and frameworks — especially those with mixin classes — understanding the MRO becomes important. If something behaves unexpectedly, print(YourClass.__mro__) is often the first debugging step.
Composition Over Inheritance
Inheritance models "is-a" relationships. But not every relationship is "is-a." A car has an engine — it is not a kind of engine. A hospital has patients — it is not a kind of patient. When you use inheritance where composition would be more natural, you create rigid, brittle hierarchies that are painful to change.
Composition means giving an object an attribute that is itself another object:
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
def start(self):
return f"Engine ({self.horsepower}hp) started"
class Car:
def __init__(self, make, engine):
self.make = make
self.engine = engine # composition: Car HAS-A Engine
def start(self):
return f"{self.make}: {self.engine.start()}"
engine = Engine(150)
car = Car("Toyota", engine)
print(car.start()) # Output: Toyota: Engine (150hp) started
The Car class delegates engine behaviour to the Engine object. If you later need an electric motor instead of a petrol engine, you swap the component — no inheritance hierarchy needs restructuring.
A practical rule of thumb: use inheritance when the child genuinely is a specialised version of the parent and will be used wherever the parent is expected. Use composition for everything else. Most of the time, composition is the right choice.
A helpful test: can you say "X is a Y" and have it make sense? A SavingsAccount is a BankAccount — that works. A Hospital is a Patient — that is nonsensical. When the "is-a" test fails, you want composition. When it passes, inheritance is a candidate — but composition may still be simpler.
Abstract Base Classes
Sometimes you want to define a class that cannot be instantiated on its own — it exists only to provide a common interface for its subclasses. The abc module provides abstract base classes for this:
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
@abstractmethod
def perimeter(self):
pass
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
def perimeter(self):
return 2 * 3.14159 * self.radius
# shape = Shape() # TypeError: Can't instantiate abstract class
c = Circle(5)
print(c.area()) # Output: 78.53975
print(c.perimeter()) # Output: 31.4159
If a subclass does not implement all abstract methods, Python raises a TypeError when you try to instantiate it. This is a contract: the parent says "every Shape must have area() and perimeter()," and the language enforces it.
Duck Typing and Protocols
Python has a famous principle: "if it walks like a duck and quacks like a duck, it is a duck." This is duck typing — you do not check what an object is, you check what it can do:
class Duck:
def quack(self):
return "Quack!"
class Person:
def quack(self):
return "I'm quacking like a duck!"
def make_it_quack(thing):
print(thing.quack())
make_it_quack(Duck()) # Output: Quack!
make_it_quack(Person()) # Output: I'm quacking like a duck!
make_it_quack does not care whether its argument is a Duck, a Person, or anything else. It only cares that the object has a quack() method. This is Python's preferred style — flexible, practical, and unconcerned with formal type hierarchies.
Since Python 3.12, the typing.Protocol class formalises duck typing for static type checkers without requiring inheritance:
from typing import Protocol
class Quackable(Protocol):
def quack(self) -> str: ...
def make_it_quack(thing: Quackable) -> None:
print(thing.quack())
Any class with a quack() method satisfies the Quackable protocol — no inheritance required. Protocols bridge the gap between Python's dynamic duck typing and the safety of static type checking. They are Python's way of saying "I care about what you can do, not who you are" — and making that statement rigorous enough for a type checker to verify.
Mixins
A mixin is a small class designed to be inherited alongside other classes, adding a specific piece of functionality without being a standalone base class:
class JsonMixin:
def to_json(self):
import json
return json.dumps(self.__dict__)
class Patient(JsonMixin):
def __init__(self, name, age):
self.name = name
self.age = age
p = Patient("Alice", 45)
print(p.to_json()) # Output: {"name": "Alice", "age": 45}
Mixins are a form of multiple inheritance, but they follow a discipline: a mixin provides methods but no __init__ and no state of its own. It adds a capability — serialisation, logging, comparison — without imposing a hierarchy. Used well, mixins are clean and composable. Used carelessly, they create the same tangled hierarchies that composition was supposed to avoid.
The choice between inheritance and composition is not a technical one — it is a design decision about how your code models the world. Inheritance says "this thing is a kind of that thing." Composition says "this thing uses that thing." Most real-world relationships are the latter, which is why experienced Python developers reach for composition first and inheritance second. The best designs use both, each where it fits naturally — and they are honest about which is which.