Python can implement Visitor with pattern matching (since 3.10) and decorators. The result has less boilerplate than the classic accept/visit form, though the trade-offs are the same.
No function overloading
Python does not support function overloading. You cannot define two methods with the same name and different parameter types. The second definition replaces the first:
class Visitor:
def visit(self, element):
# This is the only definition
pass
def visit(self, element):
# This OVERWRITES the previous definition!
pass
That rules out the classic Visitor shape, where several visit() overloads handle different element types. You need another way to dispatch on the type of the argument.
Single dispatch with functools.singledispatch
The functools.singledispatch decorator gives you single dispatch: it calls a different implementation depending on the type of the first argument:
from functools import singledispatch
class Circle:
def __init__(self, radius):
self.radius = radius
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
class AreaVisitor:
@singledispatch
def visit(self, element):
raise NotImplementedError(f"Cannot visit {type(element)}")
@visit.register(Circle)
def _(self, element: Circle):
return 3.14159 * element.radius ** 2
@visit.register(Rectangle)
def _(self, element: Rectangle):
return element.width * element.height
# Usage
visitor = AreaVisitor()
print(visitor.visit(Circle(5))) # 78.53975
print(visitor.visit(Rectangle(4, 6))) # 24
It works. The @visit.register() decorators and the _ placeholder names still make it hard to read.
Why isinstance() checks fail
The tempting approach is one method full of isinstance() checks:
class BadAreaVisitor:
def visit(self, element):
if isinstance(element, Circle):
return 3.14159 * element.radius ** 2
elif isinstance(element, Rectangle):
return element.width * element.height
else:
raise ValueError(f"Unknown element type: {type(element)}")
# Usage
visitor = BadAreaVisitor()
print(visitor.visit(Circle(5)))
print(visitor.visit(Rectangle(4, 6)))
What goes wrong:
- Tight coupling. The visitor knows every concrete element type.
- Open/Closed. A new element type means editing the visitor.
- Maintenance. Long
if/elifchains are hard to follow. - Late errors. Forget a type and you find out at runtime.
- No extension. You cannot subclass the visitor cleanly to handle new types.
- Mixed concerns. Logic for every type sits in one method.
Visitor exists to avoid exactly this.
Pattern matching
Python 3.10 added pattern matching with match/case. It replaces the explicit accept/visit methods:
from dataclasses import dataclass
@dataclass
class Circle:
radius: float
@dataclass
class Rectangle:
width: float
height: float
@dataclass
class Triangle:
base: float
height: float
def calculate_area(shape):
match shape:
case Circle(radius=r):
return 3.14159 * r ** 2
case Rectangle(width=w, height=h):
return w * h
case Triangle(base=b, height=h):
return b * h / 2
def calculate_perimeter(shape):
match shape:
case Circle(radius=r):
return 2 * 3.14159 * r
case Rectangle(width=w, height=h):
return 2 * (w + h)
case Triangle(base=b, height=h):
return 3 * b # Simplified
# Usage
shapes = [Circle(5), Rectangle(4, 6), Triangle(3, 4)]
for shape in shapes:
print(f"Area: {calculate_area(shape):.2f}, "
f"Perimeter: {calculate_perimeter(shape):.2f}")
Output:
Area: 78.54, Perimeter: 31.42
Area: 24.00, Perimeter: 20.00
Area: 6.00, Perimeter: 9.00
What it gives you over isinstance():
- No
if/elifchain. - Variables bind directly from fields.
Matching only on type is not really a pattern
Using pattern matching only to dispatch on type misses what it is for. A pattern should describe the structure of the data, not just its type.
Compare the two:
# Type-based dispatch (not really using patterns effectively)
def process(data):
match data:
case Circle(radius=r): # Matching on TYPE only
return 2 * 3.14159 * r
case Rectangle(width=w, height=h):
return 2 * (w + h)
Now match on structure:
# Structure-based matching (this is what pattern matching excels at)
def process(data):
match data:
case {"shape": "circle", "radius": r}:
return 2 * 3.14159 * r
case {"shape": "rectangle", "width": w, "height": h}:
return 2 * (w + h)
case [x, y, z]:
# Match any sequence of 3 elements
return x + y + z
case {"nested": {"value": v}} if v > 10:
# Match on structure AND guards
return v * 2
Pattern matching works best on the shape of the data. If type is all you check, singledispatch says so more plainly.
None of this makes pattern matching useless for Visitor. It means:
- Use it when element types have structure worth inspecting.
- If you dispatch on type alone, singledispatch is more honest.
- Do not treat pattern matching as a nicer isinstance().
A complete example: several operations
Python has first-class functions, so a visitor does not need to be a class. This example uses plain functions:
from dataclasses import dataclass
@dataclass
class Circle:
radius: float
@dataclass
class Rectangle:
width: float
height: float
@dataclass
class Triangle:
a: float
b: float
c: float
# Visitor functions
def area(shape):
match shape:
case Circle(radius=r):
return 3.14159 * r ** 2
case Rectangle(width=w, height=h):
return w * h
case Triangle(a=a, b=b, c=c):
# Heron's formula
s = (a + b + c) / 2
return (s * (s - a) * (s - b) * (s - c)) ** 0.5
def perimeter(shape):
match shape:
case Circle(radius=r):
return 2 * 3.14159 * r
case Rectangle(width=w, height=h):
return 2 * (w + h)
case Triangle(a=a, b=b, c=c):
return a + b + c
def describe(shape):
match shape:
case Circle(radius=r):
return f"Circle with radius {r}"
case Rectangle(width=w, height=h):
return f"Rectangle {w} × {h}"
case Triangle(a=a, b=b, c=c):
return f"Triangle with sides {a}, {b}, {c}"
# Usage
shapes = [
Circle(5),
Rectangle(4, 6),
Triangle(3, 4, 5)
]
visitors = {
'Area': area,
'Perimeter': perimeter,
}
for shape in shapes:
print(f"\n{describe(shape)}")
for name, visitor_func in visitors.items():
result = visitor_func(shape)
print(f" {name}: {result:.2f}")
Output:
Circle with radius 5
Area: 78.54
Perimeter: 31.42
Rectangle 4 × 6
Area: 24.00
Perimeter: 20.00
Triangle with sides 3, 4, 5
Area: 6.00
Perimeter: 12.00
This is shorter than the class-based version. No visitor interface, no inheritance, no self. Just functions that take a shape and dispatch on its structure.
When to use which
Use pattern matching (Python 3.10+) when: - You want simple, readable function-based visitors - Your element types have structure you need to inspect - You match on fields, not just the type
Use singledispatch when:
- You need to support Python < 3.10
- You want to extend visitors through inheritance
- You need stateful visitors (shared state across visits)
- You have many element types and want to avoid large match statements
Avoid isinstance() checks. They break the Open/Closed Principle and tie the visitor to concrete types.
Summary
- Python has no function overloading, so dispatch needs another mechanism.
isinstance()checks are an anti-pattern. They couple the visitor to concrete types.- Pattern matching (Python 3.10+) suits function-based visitors, but use it for structural matching, not plain type dispatch.
- A visitor can be a function (simplest) or a class (when you need state).
- The trade-off is unchanged: adding operations is easy, adding element types is not.