Contents

Understanding Visitor Pattern: the art of double dispatch

In the age of AI coding, writing code is no longer the primary bottleneck. The harder challenge is guiding coding agents to produce high-quality implementations. This usually means providing high-level constraints—such as software architecture and design patterns—instead of low-level implementation details.

Because of this shift, I believe understanding these concepts has become more important than ever. Take design patterns as an example. Before you can ask a coding agent to apply a particular pattern, you need to understand what the pattern is, what problem it solves, and when it should be used. Only then can you describe your intent precisely in natural language.

In this article, we’ll explore one of the classic design patterns in object-oriented programming: the Visitor Pattern.

Before diving into the Visitor Pattern, it’s worth understanding dispatch in object-oriented programming (OOP), since the Visitor Pattern is fundamentally a technique for achieving double dispatch in languages that only support single dispatch.

Simply put, dispatch is the process of determining which method implementation should be executed when a method is invoked.

In most object-oriented languages, method calls follow a syntax similar to the following (using Python as an example):

obj.method()

Here, obj is commonly referred to as the receiver, while method is the member function being invoked.

Most mainstream OOP languages implement single dispatch, meaning the invoked method is determined solely by the receiver’s runtime type. By extension, double dispatch selects the target method based on the runtime types of two objects.

In practice, single dispatch is sufficient for the vast majority of programming tasks. Situations that genuinely require double dispatch are relatively uncommon, which is probably why few mainstream languages support it natively. (Julia is one notable exception, supporting multiple dispatch as a core language feature.)

To better illustrate what double dispatch means, consider a classic example: an analyzer built on top of an Abstract Syntax Tree (AST).

If we model the system using OOP, we’ll typically end up with two class hierarchies:

  • An AST node hierarchy, where every node type inherits (directly or indirectly) from a common base class.
  • An analyzer hierarchy, where every analyzer inherits from a common analyzer base class.

Why does this scenario call for double dispatch? Because the behavior depends on both the type of the AST node and the type of the analyzer.

For example, suppose we have a PrettyPrinter that prints an AST:

  • When visiting a Number node, it simply prints the numeric value.
  • When visiting an Add node, it recursively prints the left subtree, outputs +, and then recursively prints the right subtree.

Now consider an Interpreter that evaluates the same AST:

  • When visiting a Number node, it returns the corresponding integer.
  • When visiting an Add node, it recursively evaluates both subtrees and returns their sum.

Notice that every combination of (analyzer type, AST node type) corresponds to different behavior. In other words, we need a distinct implementation for every possible combination, and at runtime we must dispatch to the correct one.

Whenever you find yourself needing double dispatch in a language that only supports single dispatch, the Visitor Pattern is worth considering.

The core idea behind the Visitor Pattern is surprisingly simple: it transforms one double dispatch into two consecutive single dispatches, producing the same effect.

We’re already familiar with single dispatch—it’s simply a virtual method call such as obj.method(). What’s less obvious is how two single dispatches can be composed to simulate double dispatch.

The pattern revolves around two groups of classes:

  • Visitable objects, which are the objects being operated on. In our example, these are the AST nodes.
  • Visitors, which encapsulate the operations performed on those objects. In our example, these are the analyzers.

To keep the discussion concrete, we’ll continue using an AST for simple arithmetic expressions. Our class hierarchy consists of:

  • Expr, the base class of all AST nodes
    • Number: for integer
    • Add
    • Sub
    • Mul
    • Div
  • Visitor, the abstract base class for all analyzers

We’ll postpone the implementation of concrete visitors for now and instead focus on the structure of the visitor pattern itself.

The corresponding class diagram looks like this:

classDiagram
    direction TB
    
    class Expr {
        +accept(v: Visitor)*
    }
    
    class Number {
        +value: int
    }
    
    class Add {
        +lhs: Expr
        +rhs: Expr
    }
    
    class Sub {
        +lhs: Expr
        +rhs: Expr
    }
    
    class Mul {
        +lhs: Expr
        +rhs: Expr
    }
    
    class Div {
        +lhs: Expr
        +rhs: Expr
    }
    
    class Visitor {
        +visitNumber(Number)*
        +visitAdd(Add)*
        +visitSub(Sub)*
        +visitMul(Mul)*
        +visitDiv(Div)*
    }
    
    Expr <|-- Number
    Expr <|-- Add
    Expr <|-- Sub
    Expr <|-- Mul
    Expr <|-- Div
    
    Expr ..> Visitor : accept()

A few important observations can be made from this design:

  • The Expr base class declares an abstract accept(visitor) method.
  • The Visitor base class declares a corresponding visit_* method for every concrete AST node type.

One important detail, however, is missing from the class diagram: how should each Expr subclass implement accept()?

The answer is remarkably straightforward.

@dataclass
class Expr(ABC):
    @abstractmethod
    def accept(self, visitor: Visitor):
        raise NotImplementedError

@dataclass
class Number(Expr):
    val: int

    def accept(self, visitor: Visitor):
        return visitor.visit_number(self)


@dataclass
class Add(Expr):
    lhs: Expr
    rhs: Expr

    def accept(self, visitor: Visitor):
        return visitor.visit_add(self)


@dataclass
class Sub(Expr):
    lhs: Expr
    rhs: Expr

    def accept(self, visitor: Visitor):
        return visitor.visit_sub(self)


@dataclass
class Mul(Expr):
    lhs: Expr
    rhs: Expr

    def accept(self, visitor: Visitor):
        return visitor.visit_mul(self)


@dataclass
class Div(Expr):
    lhs: Expr
    rhs: Expr

    def accept(self, visitor: Visitor):
        return visitor.visit_div(self)

Every concrete AST node simply forwards itself to the corresponding visit_* method on the visitor. For example, Number.accept() calls visitor.visit_number(self), while Add.accept() calls visitor.visit_add(self).

This seemingly trivial forwarding logic is what makes the Visitor Pattern work. Now let’s see what actually happens when we execute instance.accept:

  1. First dispatch. The runtime type of instance determines which implementation of accept() is invoked.
  2. Second dispatch. Inside accept(), the corresponding visitor.visit_*() method is called. The runtime type of visitor then determines which implementation of visit_*() is executed.

Taken together, these two ordinary virtual method calls effectively simulate double dispatch.

To make this mechanism more concrete, we’ll implement two different visitors in the following sections.

Before looking at concrete visitors, let’s first define the abstract Visitor interface for our example.

class Visitor(ABC):
    @abstractmethod
    def visit_number(self, e: "Number"): ...

    @abstractmethod
    def visit_add(self, e: "Add"): ...

    @abstractmethod
    def visit_sub(self, e: "Sub"): ...

    @abstractmethod
    def visit_mul(self, e: "Mul"): ...

    @abstractmethod
    def visit_div(self, e: "Div"): ...

As expected, the interface mirrors the class diagram: every concrete AST node has a corresponding visit_* method.

Note

PrettyPrinter is a stateful visitor whose methods produce side effects rather than return values. In this example, the side effect is printing the AST.

For our arithmetic expression AST, each visitor method has a straightforward responsibility:

  • visit_number prints the numeric value.
  • visit_add recursively prints the left subtree, outputs +, and then recursively prints the right subtree.
  • visit_sub recursively prints the left subtree, outputs -, and then recursively prints the right subtree.
  • visit_mul recursively prints the left subtree, outputs *, and then recursively prints the right subtree.
  • visit_div recursively prints the left subtree, outputs /, and then recursively prints the right subtree.

The implementation is shown below.

class PrintVisitor(Visitor):
    def visit_number(self, e: Number):
        print(e.val, end="")

    def visit_add(self, e: Add):
        e.lhs.accept(self)
        print(" + ", end="")
        e.rhs.accept(self)

    def visit_sub(self, e: Sub):
        e.lhs.accept(self)
        print(" - ", end="")
        e.rhs.accept(self)

    def visit_mul(self, e: Mul):
        e.lhs.accept(self)
        print(" * ", end="")
        e.rhs.accept(self)

    def visit_div(self, e: Div):
        e.lhs.accept(self)
        print(" / ", end="")
        e.rhs.accept(self)

Given the following AST:

expr = Add(Number(1), Mul(Number(2), Number(3)))

We can print the corresponding expression simply by calling:

expr.accept(PrintVisitor())
# output: 1 + 2 * 3
Note

Interpreter is a stateless visitor whose methods return values instead of producing side effects.

Next, let’s implement a simple interpreter that evaluates arithmetic expressions while traversing the AST.

The logic closely mirrors the previous example:

  • visit_number returns the integer value.
  • visit_add recursively evaluates both operands and returns their sum.
  • visit_sub recursively evaluates both operands and returns their difference.
  • visit_mul recursively evaluates both operands and returns their product.
  • visit_div recursively evaluates both operands and returns their quotient.

The implementation is shown below.

class Interpreter(Visitor):
    def visit_number(self, e: Number) -> int:
        return e.val

    def visit_add(self, e: Add) -> int:
        return e.lhs.accept(self) + e.rhs.accept(self)

    def visit_sub(self, e: Sub) -> int:
        return e.lhs.accept(self) - e.rhs.accept(self)

    def visit_mul(self, e: Mul) -> int:
        return e.lhs.accept(self) * e.rhs.accept(self)

    def visit_div(self, e: Div) -> int:
        return e.lhs.accept(self) / e.rhs.accept(self)

Applying the visitor to the same AST:

expr = Add(Number(1), Mul(Number(2), Number(3)))
print(expr.accept(Interpreter()))
# output: 7

The strengths of the Visitor Pattern are fairly clear.

  • It adheres to the Open/Closed Principle. As we’ve seen throughout the examples, we can introduce as many new visitors as we like without modifying the AST itself.
  • It provides an elegant way to achieve double dispatch in languages that only support single dispatch.

Its downside is less obvious at first glance. Suppose we decide to introduce a new AST node type, say NewNode. What happens?

Every existing visitor must be updated to implement a new visit_newnode() method. In other words, adding a new element type requires modifying every visitor.

This leads us to a useful rule of thumb: the Visitor Pattern is a good fit when the underlying object structure changes infrequently, but you still need double dispatch. This also explains why the Visitor Pattern is so common in program analysis tools. The AST of a programming language is typically stable, while different analyses often need to implement distinct behaviors for each kind of AST node. The Visitor Pattern makes it easy to add new analyses without modifying the AST itself.

For most applications, the built-in single dispatch mechanism provided by object-oriented languages is more than sufficient. Chances are you’ll never need the Visitor Pattern unless you’re working in one of its classic application domains, such as compiler construction, program analysis, or AST manipulation.

At its core, the Visitor Pattern is simply a way of simulating double dispatch using two consecutive single dispatches.

Once you understand this idea, the pattern becomes much less mysterious. The next time you encounter a problem that naturally calls for double dispatch—but your programming language doesn’t support it—you’ll know that the Visitor Pattern is probably the right tool for the job. Just tell the coding agent :)