🔵 Intermediate

Inheritance & Polymorphism

📖 Lesson 21 ⏱ 40 min 🧪 5 questions 💻 3 exercises

🎯 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
Every class in Python implicitly inherits from 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
Always call 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
Duck typing is why Python rarely needs explicit interfaces or abstract base classes for everyday code. Write functions that expect behaviour, not types, and your code becomes far more flexible and testable.

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
ABCs are great for library and framework code where you want to guarantee that plugin authors implement required methods. For internal application code, duck typing with 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
A mixin is a class designed to add a specific, focused capability to other classes via multiple inheritance. Mixins should: have no __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
InheritanceComposition
Relationshipis-ahas-a
CouplingTight — subclass depends on parent internalsLoose — object depends on an interface
FlexibilityLower — hard to swap parentHigher — easy to inject different objects
DepthCan grow complex with deep hierarchiesStays flat
Use whenTrue "is-a" + want to reuse/extend behaviourWant 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
Prefer 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.
🤖

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

01 Shape Hierarchy

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())
02 Employee Hierarchy

Model an employee payroll system:

  • Employee(name, base_salary) — base class with pay() returning base_salary
  • Manager(name, base_salary, bonus)pay() returns salary + bonus
  • Contractor(name, hourly_rate, hours_worked)pay() returns rate × hours
  • SeniorManager(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)
03 Serialisable Mixin

Write a SerialisableMixin that adds two methods to any class:

  • to_dict() — returns self.__dict__ with a "_type" key set to the class name
  • to_json() — returns a JSON string of to_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)