On this page
Inheritance and Polymorphism
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)) # FalsePolymorphism
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.00tip type: info title: "ABCs enforce contracts"
Abstract base classes are a contract mechanism. If a subclass fails to implement all
@abstractmethodmethods, Python raises aTypeErrorwhen 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)) # TrueMultiple 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.methodEach 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
Sign in to track your progress