🎯 Learning Objectives
- Use inheritance to share and extend behaviour between classes
- Call parent methods correctly with
super() - Override methods in subclasses
- Understand polymorphism and duck typing
- Use multiple inheritance and understand the MRO
- Define abstract base classes with
abc.ABC - Apply composition as an alternative to deep inheritance
What is Inheritance?
Inheritance lets one class (subclass / child) acquire
the attributes and methods of another class (superclass / parent) — and
then extend or override them. It models an "is-a" relationship:
a Dog is an Animal.
class Animal:
"""Base class for all animals."""
def __init__(self, name, sound):
self.name = name
self.sound = sound
def speak(self):
return f"{self.name} says {self.sound}!"
def __repr__(self):
return f"{type(self).__name__}({self.name!r})"
class Dog(Animal): # Dog inherits from Animal
def fetch(self):
return f"{self.name} fetches the ball!"
class Cat(Animal):
def purr(self):
return f"{self.name} purrs..."
fido = Dog("Fido", "Woof")
kitty = Cat("Kitty", "Meow")
print(fido.speak()) # Fido says Woof! — inherited method
print(fido.fetch()) # Fido fetches the ball! — own method
print(kitty.speak()) # Kitty says Meow! — inherited method
print(kitty.purr()) # Kitty purrs...
# isinstance checks the inheritance chain
print(isinstance(fido, Dog)) # True
print(isinstance(fido, Animal)) # True — Dog IS an Animal
print(isinstance(kitty, Dog)) # False
basic_inheritance.py
object — the root of
the entire class hierarchy. That's where __repr__, __eq__,
and other default dunders come from.
Calling the Parent with super()
super() returns a proxy that delegates method calls to the parent
class. Use it to extend — rather than replace — parent behaviour:
class Animal:
def __init__(self, name):
self.name = name
self.alive = True
def describe(self):
return f"I am {self.name}"
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name) # call Animal.__init__ first
self.breed = breed # then add Dog-specific attribute
def describe(self):
base = super().describe() # reuse parent method
return f"{base}, a {self.breed}"
class GuideDog(Dog):
def __init__(self, name, breed, owner):
super().__init__(name, breed) # calls Dog.__init__
self.owner = owner
def describe(self):
base = super().describe() # calls Dog.describe
return f"{base}, guiding {self.owner}"
g = GuideDog("Rex", "Labrador", "Alice")
print(g.describe())
# I am Rex, a Labrador, guiding Alice
print(g.alive) # True — set by Animal.__init__ via super() chain
super_call.py
super().__init__(…) in a subclass
__init__ unless you have a deliberate reason not to. Skipping it
means the parent's initialisation never runs, leaving the object in a broken state.
Method Overriding
A subclass can override any parent method by redefining it. The child version replaces the parent version for instances of that subclass:
class Shape:
def area(self):
raise NotImplementedError(f"{type(self).__name__} must implement area()")
def describe(self):
return f"I am a {type(self).__name__} with area {self.area():.2f}"
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self): # ← overrides Shape.area
import math
return math.pi * self.radius ** 2
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self): # ← overrides Shape.area
return self.width * self.height
class Square(Rectangle):
def __init__(self, side):
super().__init__(side, side) # reuse Rectangle.__init__
shapes = [Circle(5), Rectangle(4, 6), Square(3)]
for s in shapes:
print(s.describe())
# I am a Circle with area 78.54
# I am a Rectangle with area 24.00
# I am a Square with area 9.00
overriding.py
Polymorphism
Polymorphism means "many forms" — the same interface works
with different underlying types. Code that calls shape.area()
doesn't need to know whether it's a Circle or Rectangle:
def total_area(shapes):
"""Works with any object that has an area() method."""
return sum(s.area() for s in shapes)
shapes = [Circle(3), Rectangle(4, 5), Square(2), Circle(1)]
print(f"Total area: {total_area(shapes):.2f}")
# Total area: 73.27
# Polymorphism in action: same call, different behaviour
for s in shapes:
print(f"{type(s).__name__:12} → {s.area():.2f}")
polymorphism.py
Duck Typing
Python's flavour of polymorphism is called duck typing: "If it walks like a duck and quacks like a duck, it's a duck." No shared base class is required — only the right methods:
class File:
def read(self):
return "data from file"
class NetworkStream:
def read(self):
return "data from network"
class MockStream:
"""Fake stream for testing — not related to File or NetworkStream."""
def read(self):
return "mock data"
def process(source):
"""Accepts anything with a read() method — no inheritance needed."""
data = source.read()
return data.upper()
print(process(File())) # DATA FROM FILE
print(process(NetworkStream())) # DATA FROM NETWORK
print(process(MockStream())) # MOCK DATA
duck_typing.py
Abstract Base Classes
When you want to enforce that all subclasses implement certain methods,
use abc.ABC and @abstractmethod:
from abc import ABC, abstractmethod
class Shape(ABC):
"""Abstract base — cannot be instantiated directly."""
@abstractmethod
def area(self) -> float:
"""Return the area of the shape."""
@abstractmethod
def perimeter(self) -> float:
"""Return the perimeter of the shape."""
def describe(self):
# Concrete method — available to all subclasses
return (f"{type(self).__name__}: "
f"area={self.area():.2f}, perimeter={self.perimeter():.2f}")
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
import math
return math.pi * self.radius ** 2
def perimeter(self):
import math
return 2 * math.pi * self.radius
class Rectangle(Shape):
def __init__(self, w, h):
self.w, self.h = w, h
def area(self):
return self.w * self.h
def perimeter(self):
return 2 * (self.w + self.h)
# Shape() # TypeError: Can't instantiate abstract class Shape
c = Circle(5)
r = Rectangle(4, 6)
print(c.describe()) # Circle: area=78.54, perimeter=31.42
print(r.describe()) # Rectangle: area=24.00, perimeter=20.00
abstract_base.py
raise NotImplementedError is often sufficient
and simpler.
Multiple Inheritance & the MRO
Python supports multiple inheritance — a class can inherit from more than one parent. When the same method name exists in multiple parents, Python uses the Method Resolution Order (MRO) to decide which to call:
class Flyable:
def move(self):
return "flying"
def describe(self):
return "I can fly"
class Swimmable:
def move(self):
return "swimming"
def describe(self):
return "I can swim"
class Duck(Flyable, Swimmable): # inherits from both
def quack(self):
return "Quack!"
d = Duck()
print(d.move()) # "flying" — Flyable is first in MRO
print(d.quack()) # Quack!
# Inspect the MRO
print(Duck.__mro__)
# (<class 'Duck'>, <class 'Flyable'>, <class 'Swimmable'>, <class 'object'>)
multiple_inheritance.py
# The MRO is computed by Python's C3 linearisation algorithm.
# A simple rule: left-to-right, depth-first, each class appears only once.
class A:
def hello(self):
return "A"
class B(A):
def hello(self):
return "B → " + super().hello()
class C(A):
def hello(self):
return "C → " + super().hello()
class D(B, C): # MRO: D → B → C → A → object
pass
print(D().hello()) # B → C → A
print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)
mro.py
super() follows the MRO — it doesn't just call the direct parent.
This is why the cooperative super() pattern works in diamond
inheritance: each class in the chain calls super() and the MRO
ensures every class is called exactly once.
Mixins — the safe use of multiple inheritance
class JSONMixin:
"""Add JSON serialisation to any class."""
def to_json(self):
import json
return json.dumps(self.__dict__, default=str)
class LogMixin:
"""Add simple logging to any class."""
def log(self, message):
print(f"[{type(self).__name__}] {message}")
class User(JSONMixin, LogMixin):
def __init__(self, name, email):
self.name = name
self.email = email
u = User("Alice", "alice@example.com")
print(u.to_json()) # {"name": "Alice", "email": "alice@example.com"}
u.log("logged in") # [User] logged in
mixins.py
__init__,
hold no independent state, and not inherit from anything (or just object).
Composition over Inheritance
Inheritance models "is-a" relationships. Composition models "has-a" relationships — an object contains other objects and delegates work to them. Prefer composition when the relationship isn't a true "is-a":
# ── Inheritance approach — fragile ──
class Logger:
def log(self, message):
print(f"LOG: {message}")
class UserService(Logger): # UserService IS a Logger? No — it HAS logging.
def create_user(self, name):
self.log(f"Creating user: {name}")
return {"name": name}
# ── Composition approach — flexible ──
class Logger:
def log(self, message):
print(f"LOG: {message}")
class UserService:
def __init__(self, logger=None):
self._logger = logger or Logger() # HAS a logger
def create_user(self, name):
self._logger.log(f"Creating user: {name}")
return {"name": name}
# Now you can inject any logger (real, mock, silent)
class SilentLogger:
def log(self, message):
pass # do nothing
svc = UserService(logger=SilentLogger())
svc.create_user("Alice") # no output — easy to test!
composition.py
| Inheritance | Composition | |
|---|---|---|
| Relationship | is-a | has-a |
| Coupling | Tight — subclass depends on parent internals | Loose — object depends on an interface |
| Flexibility | Lower — hard to swap parent | Higher — easy to inject different objects |
| Depth | Can grow complex with deep hierarchies | Stays flat |
| Use when | True "is-a" + want to reuse/extend behaviour | Want to reuse behaviour without "is-a" |
isinstance() and issubclass()
class Vehicle:
pass
class Car(Vehicle):
pass
class ElectricCar(Car):
pass
tesla = ElectricCar()
# isinstance — checks the full inheritance chain
print(isinstance(tesla, ElectricCar)) # True
print(isinstance(tesla, Car)) # True
print(isinstance(tesla, Vehicle)) # True
print(isinstance(tesla, str)) # False
# issubclass — checks the class hierarchy
print(issubclass(ElectricCar, Car)) # True
print(issubclass(ElectricCar, Vehicle)) # True
print(issubclass(Car, ElectricCar)) # False
# isinstance with a tuple of types
print(isinstance(tesla, (Car, str, int))) # True — matches Car
isinstance.py
isinstance() over type(obj) == SomeClass — it
respects the inheritance chain and is more Pythonic. Use it to write functions
that handle a family of types gracefully.
Primary sources: Python Docs — Inheritance · Python Docs — abc module · Python Docs — super() · Python C3 MRO explanation
Ask your AI tutor! Not sure whether your design calls for inheritance or composition? Confused about the MRO in a diamond hierarchy? Want to see how mixins are used in Django or Flask? Great topics to explore with a concrete example.
💻 Exercises
Build an abstract Shape base class (using abc.ABC) with
abstract methods area() and perimeter(), and a concrete
describe() method. Then implement three subclasses:
Circle, Rectangle, and Triangle
(given three sides). Demonstrate polymorphism by sorting a mixed list of shapes
by area.
Show solution
import math
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
@abstractmethod
def perimeter(self) -> float: ...
def describe(self):
return (f"{type(self).__name__}: "
f"area={self.area():.2f}, perimeter={self.perimeter():.2f}")
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
def perimeter(self):
return 2 * math.pi * self.radius
class Rectangle(Shape):
def __init__(self, w, h):
self.w, self.h = w, h
def area(self):
return self.w * self.h
def perimeter(self):
return 2 * (self.w + self.h)
class Triangle(Shape):
def __init__(self, a, b, c):
self.a, self.b, self.c = a, b, c
def area(self):
s = self.perimeter() / 2
return math.sqrt(s * (s-self.a) * (s-self.b) * (s-self.c))
def perimeter(self):
return self.a + self.b + self.c
shapes = [Circle(3), Rectangle(4, 5), Triangle(3, 4, 5), Circle(1), Rectangle(2, 2)]
for s in sorted(shapes, key=lambda x: x.area()):
print(s.describe())
Model an employee payroll system:
Employee(name, base_salary)— base class withpay()returningbase_salaryManager(name, base_salary, bonus)—pay()returns salary + bonusContractor(name, hourly_rate, hours_worked)—pay()returns rate × hoursSeniorManager(name, base_salary, bonus, stock_units, unit_price)— extends Manager, adds stock compensation
Write a payroll(employees) function that prints a payslip for each
employee and returns the total payroll cost.
Show solution
class Employee:
def __init__(self, name, base_salary):
self.name = name
self.base_salary = base_salary
def pay(self):
return self.base_salary
def __repr__(self):
return f"{type(self).__name__}({self.name!r})"
class Manager(Employee):
def __init__(self, name, base_salary, bonus):
super().__init__(name, base_salary)
self.bonus = bonus
def pay(self):
return super().pay() + self.bonus
class Contractor(Employee):
def __init__(self, name, hourly_rate, hours_worked):
super().__init__(name, base_salary=0)
self.hourly_rate = hourly_rate
self.hours_worked = hours_worked
def pay(self):
return self.hourly_rate * self.hours_worked
class SeniorManager(Manager):
def __init__(self, name, base_salary, bonus, stock_units, unit_price):
super().__init__(name, base_salary, bonus)
self.stock_units = stock_units
self.unit_price = unit_price
def pay(self):
return super().pay() + self.stock_units * self.unit_price
def payroll(employees):
total = 0
print(f"{'Name':<20} {'Role':<16} {'Pay':>10}")
print("-" * 48)
for e in employees:
p = e.pay()
total += p
print(f"{e.name:<20} {type(e).__name__:<16} £{p:>9,.2f}")
print("-" * 48)
print(f"{'Total payroll':<36} £{total:>9,.2f}")
return total
staff = [
Employee("Dave", 50_000),
Manager("Alice", 70_000, 15_000),
Contractor("Bob", 75, 160),
SeniorManager("Carol", 90_000, 25_000, 100, 50),
]
payroll(staff)
Write a SerialisableMixin that adds two methods to any class:
to_dict()— returnsself.__dict__with a"_type"key set to the class nameto_json()— returns a JSON string ofto_dict()from_dict(cls, data)— class method that creates an instance from a dict (excluding"_type")
Apply it to a Book class and round-trip an object through JSON.
Show solution
import json
class SerialisableMixin:
def to_dict(self):
d = dict(self.__dict__)
d["_type"] = type(self).__name__
return d
def to_json(self):
return json.dumps(self.to_dict(), indent=2)
@classmethod
def from_dict(cls, data):
d = {k: v for k, v in data.items() if k != "_type"}
return cls(**d)
class Book(SerialisableMixin):
def __init__(self, title, author, year):
self.title = title
self.author = author
self.year = year
def __repr__(self):
return f"Book({self.title!r}, {self.author!r}, {self.year})"
b1 = Book("Fluent Python", "Luciano Ramalho", 2022)
print(b1.to_json())
# {
# "title": "Fluent Python",
# "author": "Luciano Ramalho",
# "year": 2022,
# "_type": "Book"
# }
json_str = b1.to_json()
data = json.loads(json_str)
b2 = Book.from_dict(data)
print(b2) # Book('Fluent Python', 'Luciano Ramalho', 2022)
print(b1.to_dict() == b2.to_dict()) # True (excluding _type differences)