Designing Flexible Code with Polymorphism

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.

ToolPurpose
typing.Protocol
class
Structural subtyping without inheritance.
@runtime_checkable
decorator
Allow isinstance checks on a Protocol.
abc.ABC
class
Nominal interface with required overrides.
@abstractmethod
decorator
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.singledispatch
decorator
Dispatch on the first argument's type.
TYPE_CHECKING
flag
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