Polymorphism is the ability to treat different types uniformly through a shared interface. A function written to accept any shape with an area() method will happily work with Circle, Square, Triangle — and any future shape you haven't written yet. That flexibility is the real payoff of object-oriented code: you add new types without rewriting the functions that consume them.
Python's approach is duck typing: “if it walks like a duck and quacks like a duck, it's a duck”. The interpreter does not check the class of an argument before calling .area(); it just calls the method and lets Python raise AttributeError if it isn't there. This is both liberating (no boilerplate interface declarations) and risky (the error only shows up at runtime).
For readability and static type checking, Python offers two complementary tools. abc.ABC with @abstractmethod gives you a nominal interface: classes have to subclass it explicitly. typing.Protocol gives you a structural interface: any class with the right methods satisfies it, without any inheritance. Use Protocol for lightweight ad-hoc interfaces; use ABC when you also need default methods or registration.
The Liskov substitution principle applies to polymorphism the same way it applies to inheritance: every subtype must be a drop-in replacement for the type it claims to be. Violating that turns polymorphism from a feature into a footgun.
Duck typing vs explicit interfaces
Pure duck typing: the consumer calls x.area() and moves on. Fast to write, lean on code, but the failure mode is a runtime AttributeError far from the definition.
typing.Protocol: declare the expected method signatures once; any class with matching methods satisfies the protocol without inheritance. Mypy and IDEs verify the contract at development time.
When to pick Protocol vs ABC
Protocol is best for external types you cannot change (third-party classes, built-ins) and for small, stable interfaces. ABC is best when you own the hierarchy and need shared default methods or runtime subclass checks.
Both approaches support isinstance checks (with @runtime_checkable for protocols). In both, write against the interface, not the concrete class: def total(shapes: Iterable[Shape]) -> float keeps the function reusable.
The polymorphism toolbox.
| Tool | Purpose |
|---|---|
typing.Protocolclass | Structural subtyping without inheritance. |
@runtime_checkabledecorator | Allow isinstance checks on a Protocol. |
abc.ABCclass | Nominal interface with required overrides. |
@abstractmethoddecorator | Declares a method subclasses must implement. |
Iterable[Shape]annotation | Accept any iterable of Shape-like objects. |
sum(shapes, ...)built-in | Uses + and 0 — polymorphism on operators. |
functools.singledispatchdecorator | Dispatch on the first argument's type. |
TYPE_CHECKINGflag | Imports only during static type checking. |
Designing Flexible Code with Polymorphism code example
The script defines a Shape protocol and uses it to aggregate results across a mixed list of shapes.
# Lesson: Designing Flexible Code with Polymorphism
from math import pi
from typing import Iterable, Protocol, runtime_checkable
@runtime_checkable
class Shape(Protocol):
def area(self) -> float: ...
class Circle:
def __init__(self, r: float): self.r = r
def area(self) -> float: return pi * self.r ** 2
class Square:
def __init__(self, s: float): self.s = s
def area(self) -> float: return self.s * self.s
class Triangle:
def __init__(self, base: float, height: float):
self.base, self.height = base, height
def area(self) -> float: return 0.5 * self.base * self.height
def total_area(shapes: Iterable[Shape]) -> float:
"""Works with anything that has `.area()` — no base class required."""
return sum(s.area() for s in shapes)
shapes = [Circle(1), Square(2), Triangle(3, 4)]
print("areas:", [round(s.area(), 3) for s in shapes])
print("total:", round(total_area(shapes), 3))
# Runtime Protocol check
print("Circle(1) is Shape?", isinstance(Circle(1), Shape))
print("'x' is Shape? ", isinstance("x", Shape))
# Duck typing in action: a dataclass-like object with area() qualifies
class Hexagon:
def __init__(self, side: float): self.side = side
def area(self) -> float:
return 3 * (3 ** 0.5) / 2 * self.side ** 2
print("hex area:", round(Hexagon(2).area(), 3))
print("total with hex:", round(total_area(shapes + [Hexagon(2)]), 3))
Notice the separation:
1) `Shape` is a Protocol; no class has to inherit from it.
2) `Circle`, `Square`, `Triangle` all satisfy the protocol by having `.area()`.
3) `total_area` works on any iterable of Shape-like objects.
4) Adding a new shape (Hexagon) requires no changes to existing code.
Design a simple SizeLike protocol.
from typing import Protocol
class SizeLike(Protocol):
def size(self) -> int: ...
class Photo:
def size(self) -> int: return 1024
class Video:
def size(self) -> int: return 4096
def largest(items: list[SizeLike]) -> SizeLike:
return max(items, key=lambda x: x.size())
print(largest([Photo(), Video()]).size())
Verify the structural contract.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Named(Protocol):
name: str
class A:
def __init__(self): self.name = "a"
class B:
pass
assert isinstance(A(), Named)
assert not isinstance(B(), Named)
Running prints:
areas: [3.142, 4, 6.0]
total: 13.142
Circle(1) is Shape? True
'x' is Shape? False
hex area: 10.392
total with hex: 23.534