Open for Extension, Closed for Modification
The Open/Closed Principle says: code should be open for extension — new behavior can be added — but closed for modification — existing, working, tested code is not edited for it.
Every edit to working code is a chance to break it. When adding a feature means only adding new code, the old code stays proven.
Python's tool for this is the protocol: a class built on typing.Protocol that lists the methods a type must have. Any class with matching methods satisfies the protocol automatically — nothing is declared — so a function that depends on a protocol never needs to change when a new class plugs in.
The match That Never Stops Growing
This area calculator uses a match on the shape kind. It works — until the next shape arrives. Triangle? Edit the match. Pentagon? Edit it again. The function is never finished, and every edit risks the shapes that already worked.
A match on the shape kind
total_area must know every kind of shape. Adding one means modifying this tested function — the exact thing the principle warns against.
Output: complete Python program
import math
from dataclasses import dataclass
@dataclass
class Shape:
kind: str
width: float = 0
height: float = 0
radius: float = 0
# total_area must know every kind — and grows with each new one.
def total_area(shapes):
total = 0.0
for s in shapes:
match s.kind:
case "circle":
total += math.pi * s.radius * s.radius
case "rectangle":
total += s.width * s.height
return total
shapes = [
Shape(kind="circle", radius=2),
Shape(kind="rectangle", width=3, height=4),
]
print(f"total area: {total_area(shapes):.2f}")
Extend Through a Protocol
The fix: each shape knows its own area. The Shape protocol asks for one method, area(), and total_area just adds up whatever it receives — it no longer cares what the shapes are.
Now watch the principle at work: a brand-new Triangle plugs in, and total_area is not touched. Extension without modification.
Each shape computes its own area
total_area depends only on the Shape protocol. The match is gone — and with it, the need to ever edit this function again.
Output: complete Python program
import math
from dataclasses import dataclass
from typing import Protocol
class Shape(Protocol):
def area(self) -> float: ...
@dataclass
class Circle:
radius: float
def area(self) -> float:
return math.pi * self.radius * self.radius
@dataclass
class Rectangle:
width: float
height: float
def area(self) -> float:
return self.width * self.height
# total_area depends only on the protocol — no match, nothing to edit.
def total_area(shapes: list[Shape]) -> float:
total = 0.0
for s in shapes:
total += s.area()
return total
shapes: list[Shape] = [
Circle(radius=2),
Rectangle(width=3, height=4),
]
print(f"total area: {total_area(shapes):.2f}")
Add a new shape, touch nothing
Triangle is pure new code: a dataclass and its area() method. total_area, Circle and Rectangle are exactly as they were — open for extension, closed for modification.
Output: complete Python program
import math
from dataclasses import dataclass
from typing import Protocol
class Shape(Protocol):
def area(self) -> float: ...
@dataclass
class Circle:
radius: float
def area(self) -> float:
return math.pi * self.radius * self.radius
@dataclass
class Rectangle:
width: float
height: float
def area(self) -> float:
return self.width * self.height
# Triangle is NEW code — everything above is untouched.
@dataclass
class Triangle:
base: float
height: float
def area(self) -> float:
return self.base * self.height / 2
def total_area(shapes: list[Shape]) -> float:
total = 0.0
for s in shapes:
total += s.area()
return total
shapes: list[Shape] = [
Circle(radius=2),
Rectangle(width=3, height=4),
Triangle(base=6, height=5),
]
print(f"total area: {total_area(shapes):.2f}")