Polymorphism
One call, many behaviours: the code that removes the if-else ladder.
01The intuition
The problem it solves
You are drawing shapes for a geometry homework app. Each shape has an area, and the first version keeps the shapes as tuples and works out which formula to use with an if-else ladder.
def area(shape) -> float:
kind = shape[0]
if kind == "circle":
return 3.14159 * shape[1] ** 2
elif kind == "rect":
return shape[1] * shape[2]
elif kind == "triangle":
return 0.5 * shape[1] * shape[2]
raise ValueError(kind)
def perimeter(shape) -> float:
kind = shape[0]
if kind == "circle": return 2 * 3.14159 * shape[1]
# ... the same ladder again
Adding a hexagon means finding every ladder in the program and adding a branch to each. Forgetting one gives a ValueError at runtime, in production, for one user with a hexagon. The ladders are all the same shape, and they all exist because the program is asking "what kind of thing are you" before doing anything.
Polymorphism turns that question around. Instead of the caller asking the kind, the caller makes one call, and each kind of object answers in its own way.
Build it one piece at a time
Step 1: give each kind its own class that knows its own formula. The formula moves next to the data it needs.
class Circle:
def __init__(self, r: float): self.r = r
def area(self) -> float: return 3.14159 * self.r ** 2
class Rect:
def __init__(self, w: float, h: float): self.w, self.h = w, h
def area(self) -> float: return self.w * self.h
Step 2: agree on the shared promise. Write the interface so that every shape is guaranteed to answer area and perimeter.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
@abstractmethod
def perimeter(self) -> float: ...
class Circle(Shape):
def __init__(self, r: float): self.r = r
def area(self) -> float: return 3.14159 * self.r ** 2
def perimeter(self) -> float: return 2 * 3.14159 * self.r
Step 3: delete the ladder. The caller now holds a list of Shape and makes one call. Python picks the right method by looking at the object, not at a string.
def total_area(shapes: list[Shape]) -> float:
return sum(s.area() for s in shapes)
shapes = [Circle(1), Rect(2, 3)]
print(total_area(shapes))
Step 4: add a hexagon without touching anything. One new class, no ladders edited, and the type checker tells you if you forgot perimeter.
class Hexagon(Shape):
def __init__(self, side: float): self.side = side
def area(self) -> float: return 2.598 * self.side ** 2
def perimeter(self) -> float: return 6 * self.side
| Step | Places that mention every shape kind | Adding a kind touches | Missing formula found |
|---|---|---|---|
| Start | one per operation (2 ladders) | every ladder | at runtime |
| 1 | still 2 ladders for callers | a class plus ladders | at runtime |
| 2 and 3 | 0 | one new class | when the class is defined |
Why it works
The dispatch, meaning "which code runs for this call", moves from the caller's if-else into the language. Each object carries its class, and the class carries the method, so s.area() always reaches the right formula. The invariant is the shared interface: every Shape answers the same questions. The caller's code becomes shorter and, more importantly, it becomes closed: it is correct for shapes that do not exist yet.
The cost of the ladder grows as kinds times operations; after the refactor each kind-operation pair is written exactly once, in the obvious place.
Where it breaks: when the operations vary more often than the kinds. If you add a new operation such as draw, every shape class must change. Then the Visitor pattern, or plain functions over a closed set of kinds, is the better trade. Another misuse is a subclass that overrides a method to do nothing or to raise, which is polymorphism in syntax but a ladder in disguise; see Liskov substitution.
The trade-off you must say out loud
Polymorphism spreads one operation across many files, so reading "what does area do" means opening every shape. For three kinds that will never grow, an enum and a dictionary of formulas is simpler and keeps the logic on one screen. Say the rule: when the set of kinds is open and grows, polymorphism; when the set of operations is open and grows, keep kinds closed and add functions. The follow-up is usually "and how do you add a new one", and the answer should be "one class, nothing else changes".
Where it shows up in the 100 Google problems
- Design chess:
piece.legal_moves(board)is one call; the knight, bishop and pawn each answer differently, and check detection never asks what piece it holds. - Design a boolean and arithmetic expression library:
node.evaluate(env)on a constant, a variable, an AND or a SUM; the tree walks itself. - Design Monopoly:
square.on_land(player)does different things for a property, a tax square and Go, so the turn loop has no switch on square type. - Design a JSON parser: each value node serialises itself, which is how pretty-printing stays one method per node.