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:

  1. Tight coupling. The visitor knows every concrete element type.
  2. Open/Closed. A new element type means editing the visitor.
  3. Maintenance. Long if/elif chains are hard to follow.
  4. Late errors. Forget a type and you find out at runtime.
  5. No extension. You cannot subclass the visitor cleanly to handle new types.
  6. 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/elif chain.
  • 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.