On this page

Inheritance and Polymorphism

14 min read TextCh. 4 — OOP and Files

Inheritance and Polymorphism

Inheritance is the mechanism that lets a class reuse and extend the behavior of another class. Polymorphism is the ability to write code that works with objects of different types, as long as they share a common interface. Together, these two concepts allow you to model complex domain hierarchies and write code that is open for extension without modification.

Basic Inheritance

A class inherits from a parent (also called base or super) class by listing it in parentheses:

class Animal:
    """Base class for all animals."""

    def __init__(self, name: str, sound: str) -> None:
        self.name = name
        self.sound = sound
        self.energy = 100

    def speak(self) -> str:
        return f"{self.name} says: {self.sound}!"

    def eat(self, food: str) -> None:
        self.energy += 10
        print(f"{self.name} eats {food}. Energy: {self.energy}")

    def __str__(self) -> str:
        return f"{type(self).__name__}(name={self.name!r})"


class Dog(Animal):
    """A dog that can also fetch."""

    def __init__(self, name: str, breed: str) -> None:
        super().__init__(name, sound="Woof")   # call parent __init__
        self.breed = breed

    def fetch(self, item: str) -> str:
        return f"{self.name} fetches the {item}!"

    def __str__(self) -> str:
        return f"Dog(name={self.name!r}, breed={self.breed!r})"


class Cat(Animal):
    """A cat that can also purr."""

    def __init__(self, name: str, indoor: bool = True) -> None:
        super().__init__(name, sound="Meow")
        self.indoor = indoor
        self.lives = 9

    def purr(self) -> str:
        return f"{self.name} purrs contentedly."

    def speak(self) -> str:
        # Override the parent method
        base = super().speak()
        return f"{base} (and then ignores you)"


rex = Dog("Rex", "German Shepherd")
whiskers = Cat("Whiskers")

print(rex)             # Dog(name='Rex', breed='German Shepherd')
print(rex.speak())     # Rex says: Woof!
print(rex.fetch("ball"))  # Rex fetches the ball!
rex.eat("kibble")      # Rex eats kibble. Energy: 110

print(whiskers.speak())  # Whiskers says: Meow! (and then ignores you)
print(whiskers.purr())   # Whiskers purrs contentedly.

Method Resolution Order (MRO)

Python uses the C3 linearization algorithm to determine the order in which it searches classes for a method. You can inspect it with .__mro__:

print(Dog.__mro__)
# (<class 'Dog'>, <class 'Animal'>, <class 'object'>)

# When you call rex.speak():
# 1. Python looks in Dog — not found
# 2. Python looks in Animal — found! Calls Animal.speak()

# isinstance() checks the entire hierarchy
print(isinstance(rex, Dog))     # True
print(isinstance(rex, Animal))  # True (Dog is a subclass of Animal)
print(isinstance(rex, Cat))     # False

print(issubclass(Dog, Animal))  # True
print(issubclass(Cat, Dog))     # False

Polymorphism

Polymorphism means you can write code that operates on the parent type, and it works correctly with any subclass:

def make_noise(animals: list[Animal]) -> None:
    """Works with any Animal subclass."""
    for animal in animals:
        print(animal.speak())   # Calls the correct speak() for each type

animals: list[Animal] = [
    Dog("Rex", "German Shepherd"),
    Cat("Whiskers"),
    Dog("Buddy", "Labrador"),
    Cat("Shadow", indoor=False),
]

make_noise(animals)
# Rex says: Woof!
# Whiskers says: Meow! (and then ignores you)
# Buddy says: Woof!
# Shadow says: Meow! (and then ignores you)

This is duck typing combined with polymorphism: Python does not care about the specific type, only that the object has a speak() method.

Abstract Base Classes (ABCs)

An abstract class is a class that cannot be instantiated directly — it exists only to be subclassed. Abstract methods define an interface that all subclasses must implement:

from abc import ABC, abstractmethod

class Shape(ABC):
    """Abstract base class for all geometric shapes."""

    @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) -> str:
        """Concrete method — available to all subclasses."""
        return (
            f"{type(self).__name__}: "
            f"area={self.area():.2f}, perimeter={self.perimeter():.2f}"
        )


class Circle(Shape):
    import math as _math

    def __init__(self, radius: float) -> None:
        self.radius = radius

    def area(self) -> float:
        import math
        return math.pi * self.radius ** 2

    def perimeter(self) -> float:
        import math
        return 2 * math.pi * self.radius


class Rectangle(Shape):
    def __init__(self, width: float, height: float) -> None:
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height

    def perimeter(self) -> float:
        return 2 * (self.width + self.height)


class Triangle(Shape):
    def __init__(self, a: float, b: float, c: float) -> None:
        self.a, self.b, self.c = a, b, c

    def area(self) -> float:
        # Heron's formula
        s = (self.a + self.b + self.c) / 2
        return (s * (s - self.a) * (s - self.b) * (s - self.c)) ** 0.5

    def perimeter(self) -> float:
        return self.a + self.b + self.c


# Cannot instantiate the abstract class directly
try:
    s = Shape()
except TypeError as e:
    print(f"Error: {e}")
# Error: Can't instantiate abstract class Shape with abstract methods area, perimeter

shapes: list[Shape] = [Circle(5), Rectangle(4, 6), Triangle(3, 4, 5)]
for shape in shapes:
    print(shape.describe())
# Circle: area=78.54, perimeter=31.42
# Rectangle: area=24.00, perimeter=20.00
# Triangle: area=6.00, perimeter=12.00

tip type: info title: "ABCs enforce contracts"

Abstract base classes are a contract mechanism. If a subclass fails to implement all @abstractmethod methods, Python raises a TypeError when you try to instantiate it. This catches missing implementations at object creation time rather than at runtime when a missing method is called.

Protocols — Structural Typing

Python 3.8+ introduced Protocol from typing. Unlike ABCs (which use inheritance), Protocols define interfaces through structural compatibility (duck typing with type-checker support):

from typing import Protocol, runtime_checkable

@runtime_checkable
class Drawable(Protocol):
    """Anything that can be drawn."""

    def draw(self) -> str:
        ...

    def bounding_box(self) -> tuple[float, float, float, float]:
        """Return (x, y, width, height)."""
        ...


class Circle:
    def __init__(self, cx: float, cy: float, radius: float) -> None:
        self.cx = cx
        self.cy = cy
        self.radius = radius

    def draw(self) -> str:
        return f"Drawing circle at ({self.cx}, {self.cy}) r={self.radius}"

    def bounding_box(self) -> tuple[float, float, float, float]:
        return (self.cx - self.radius, self.cy - self.radius,
                self.radius * 2, self.radius * 2)


class Image:
    def __init__(self, path: str, x: float, y: float, w: float, h: float) -> None:
        self.path = path
        self.x, self.y, self.w, self.h = x, y, w, h

    def draw(self) -> str:
        return f"Drawing image {self.path!r} at ({self.x}, {self.y})"

    def bounding_box(self) -> tuple[float, float, float, float]:
        return (self.x, self.y, self.w, self.h)


def render_all(drawables: list[Drawable]) -> None:
    for d in drawables:
        print(d.draw())
        bb = d.bounding_box()
        print(f"  Bounding box: {bb}")


# Neither Circle nor Image inherits from Drawable — they just match the structure
canvas: list[Drawable] = [
    Circle(100, 100, 50),
    Image("photo.png", 10, 10, 200, 150),
]
render_all(canvas)

# runtime_checkable allows isinstance() checks
print(isinstance(Circle(0, 0, 1), Drawable))   # True

Multiple Inheritance and Mixins

Python supports multiple inheritance. A common pattern is to use mixins — small classes that provide a single reusable behavior:

class JSONMixin:
    """Adds JSON serialization to any class."""

    def to_json(self) -> str:
        import json
        return json.dumps(self.__dict__, default=str)

    @classmethod
    def from_json(cls, json_str: str) -> "JSONMixin":
        import json
        data = json.loads(json_str)
        obj = cls.__new__(cls)
        obj.__dict__.update(data)
        return obj


class LogMixin:
    """Adds simple logging to any class."""

    def log(self, message: str) -> None:
        print(f"[{type(self).__name__}] {message}")


class Product(JSONMixin, LogMixin):
    def __init__(self, name: str, price: float, stock: int) -> None:
        self.name = name
        self.price = price
        self.stock = stock

    def sell(self, quantity: int) -> None:
        if quantity > self.stock:
            raise ValueError(f"Insufficient stock: {self.stock} available")
        self.stock -= quantity
        self.log(f"Sold {quantity} units of {self.name}. Remaining: {self.stock}")


laptop = Product("Laptop Pro", 999.99, 50)
laptop.sell(3)
print(laptop.to_json())
# {"name": "Laptop Pro", "price": 999.99, "stock": 47}

tip type: tip title: "Favor composition over inheritance"

Deep inheritance hierarchies become brittle and hard to reason about. When possible, prefer composition (containing objects) over inheritance, and use mixins for small, orthogonal behaviors. A good rule of thumb: if you need more than 2 levels of inheritance, reconsider your design.

`super()` and Cooperative Multiple Inheritance

When using multiple inheritance, super() ensures each class in the MRO is called exactly once:

class A:
    def method(self) -> None:
        print("A.method")
        super().method()   # type: ignore[misc]

class B(A):
    def method(self) -> None:
        print("B.method")
        super().method()

class C(A):
    def method(self) -> None:
        print("C.method")
        super().method()

class D(B, C):
    def method(self) -> None:
        print("D.method")
        super().method()

print(D.__mro__)
# [D, B, C, A, object]

D().method()
# D.method
# B.method
# C.method
# A.method

Each class calls super().method(), and the MRO ensures each class in the chain is called exactly once, in the right order. This is called cooperative multiple inheritance.

nextSteps

  • files-and-json