$ emrebener

Visitor Pattern

author: emre bener read time: 7 min about: visitor pattern, java

1. What the Visitor pattern is

The Visitor pattern lets you add a new operation to a class hierarchy without modifying any of the classes in it. You write the operation once, as a separate “visitor” object, and the hierarchy hands each of its elements to that visitor in turn.

The Gang of Four put it in the behavioural bucket because the load-bearing decision is a runtime one: which piece of code runs depends on two types at once, the element being visited and the visitor doing the visiting. Java’s method dispatch only resolves one type at runtime. The pattern’s whole job is to recover the second, via a technique called double dispatch.

That recovery is the entire pattern. The rest is mechanics, plus the question of when it’s worth the trouble.

2. Single dispatch and the operation/structure split

Java is a single-dispatch language: a virtual method call selects its body from the runtime type of the receiver, and from the receiver only. The static types of the arguments are fixed at compile time. That fact is what makes the Visitor pattern necessary, so it’s worth seeing where it bites.

Take a small expression tree, the kind a calculator or interpreter walks: NumberExpr holds a literal, while AddExpr and MulExpr each hold a left and right operand. (An AST, abstract syntax tree, is exactly this: a tree of typed nodes standing in for parsed source.) Over that tree you’ll want several operations — evaluate it, pretty-print it, type-check it, maybe optimize it later. The question is where those operations live. The two obvious answers both fail.

An operation method on every element. Give each class an evaluate(), a print(), a typeCheck(). Dispatch works perfectly, but every new operation means editing every class in the hierarchy, and the node classes slowly fill with concerns that have nothing to do with being a node. A NumberExpr ends up knowing about your codegen backend.

One free function with an instanceof chain. Write evaluate(Expr e) as a standalone function: if (e instanceof AddExpr a) { ... } else if (e instanceof NumberExpr n) { ... }. The operation is now in one place, but the compiler can’t tell you when you forget a case. Add a NegExpr later and every chain silently falls through to its else, at runtime, in production.

Visitor is the third answer. It keeps each operation together in one class (like the instanceof function) but gets dynamic dispatch and compile-time completeness (like the method-per-element approach). It does that by splitting the world in two: the structure — the element classes, which rarely change — and the operations, the visitors that multiply freely.

3. The pattern in Java: accept, visit, and the visitor interface

The pattern has three roles. The element interface declares one method, accept, that takes a visitor. Concrete elements implement it. The visitor interface declares one visit overload per concrete element type. An operation is one class implementing that visitor interface.

Start with the element interface. The generic <R> lets a visit produce a typed result, so an evaluator can return a number and a printer a string without anyone reaching for a shared mutable field:

public interface Expr {
    <R> R accept(ExprVisitor<R> visitor);
}

Each concrete element implements accept with the same one-line body, and that uniformity is not an accident. The reason the bodies have to look exactly like this, down to the this, are explained in §4:

public final class NumberExpr implements Expr {
    private final double value;

    public NumberExpr(double value) {
        this.value = value;
    }

    public double value() {
        return value;
    }

    @Override
    public <R> R accept(ExprVisitor<R> visitor) {
        return visitor.visit(this);
    }
}

The binary nodes carry two child Expr references and nothing else operation-specific:

public final class AddExpr implements Expr {
    private final Expr left;
    private final Expr right;

    public AddExpr(Expr left, Expr right) {
        this.left = left;
        this.right = right;
    }

    public Expr left() {
        return left;
    }

    public Expr right() {
        return right;
    }

    @Override
    public <R> R accept(ExprVisitor<R> visitor) {
        return visitor.visit(this);
    }
}

MulExpr is identical in shape. The visitor interface lists one visit per concrete element:

public interface ExprVisitor<R> {
    R visit(NumberExpr expr);
    R visit(AddExpr expr);
    R visit(MulExpr expr);
}

Now an operation is just a class. Evaluation returns a Double and recurses by handing this back to each child’s accept:

public final class EvaluateVisitor implements ExprVisitor<Double> {
    @Override
    public Double visit(NumberExpr expr) {
        return expr.value();
    }

    @Override
    public Double visit(AddExpr expr) {
        return expr.left().accept(this) + expr.right().accept(this);
    }

    @Override
    public Double visit(MulExpr expr) {
        return expr.left().accept(this) * expr.right().accept(this);
    }
}

Pretty-printing is a second class, returning String, with no change to anything above it:

public final class PrintVisitor implements ExprVisitor<String> {
    @Override
    public String visit(NumberExpr expr) {
        return String.valueOf(expr.value());
    }

    @Override
    public String visit(AddExpr expr) {
        return "(" + expr.left().accept(this) + " + " + expr.right().accept(this) + ")";
    }

    @Override
    public String visit(MulExpr expr) {
        return "(" + expr.left().accept(this) + " * " + expr.right().accept(this) + ")";
    }
}

At the call site, you build a tree once and run any visitor over it:

Expr tree = new AddExpr(new NumberExpr(1), new MulExpr(new NumberExpr(2), new NumberExpr(3)));

double result = tree.accept(new EvaluateVisitor());   // 7.0
String text   = tree.accept(new PrintVisitor());      // (1.0 + (2.0 * 3.0))

This is the payoff from §2 made concrete. Adding a type-checker, an optimizer, or a codegen pass is one new class implementing ExprVisitor. NumberExpr, AddExpr, and MulExpr are never touched again.

Structure (elements)Operations (visitors)«interface» ExprNumberExprAddExprMulExpr«interface»ExprVisitor<R>EvaluateVisitorPrintVisitoraccept(v) →v.visit(this)Structure (elements)Operations (visitors)«interface» ExprNumberExprAddExprMulExpr«interface»ExprVisitor<R>EvaluateVisitorPrintVisitoraccept(v) →v.visit(this)

4. The accept/visit handshake

The accept method looks like pointless indirection (tree.accept(v) only to have accept immediately call v.visit(this)), but it is the mechanism, and it cannot be skipped. It works in two hops.

Hop one is the call tree.accept(visitor). tree is statically typed Expr, so this is a virtual call, and the JVM picks the accept body from the runtime type of the element. If tree is really an AddExpr, AddExpr.accept runs.

Hop two happens inside that body: visitor.visit(this). Here this is statically typed as exactly AddExpr, because we are inside AddExpr. The compiler does overload resolution on that static type and picks visit(AddExpr). Then a second virtual call selects the body of that visit from the runtime type of the visitor.

Put together, the method that finally runs is chosen by the combination of element type and visitor type. Double dispatch, assembled out of two ordinary single-dispatch calls.

Skipping accept does not work, and seeing why nails the point down. Suppose you call visitor.visit(tree) directly. tree is typed Expr, overload resolution runs at compile time on that static type, and there is no visit(Expr) method, so the code does not compile. Add one and it would compile, but every call would bind to visit(Expr) forever, because the static type never narrows. The accept detour exists for one reason: inside accept, and only there, this carries the exact concrete type. The pattern borrows the JVM’s single dispatch twice to manufacture the double dispatch the language won’t give you directly.

Double dispatch — two single-dispatch hopstree: static type Expr,runtime type AddExprthis: static type AddExprcallertree.accept(v)AddExpr.accept(v){ v.visit(this) }EvaluateVisitor.visit(AddExpr)Hop 1Hop 2resolves accept() on theelement's runtime typeresolves visit() on thevisitor's runtime typeThe running method is chosen by element type × visitor type.Double dispatch — two single-dispatch hopstree: static type Expr,runtime type AddExprthis: static type AddExprcallertree.accept(v)AddExpr.accept(v){ v.visit(this) }EvaluateVisitor.visit(AddExpr)Hop 1Hop 2resolves accept() on theelement's runtime typeresolves visit() on thevisitor's runtime typeThe running method is chosen by element type × visitor type.

5. The expression problem, sealed types, and pattern matching

Visitor makes one kind of change cheap and the opposite kind expensive. Adding an operation is cheap: one new visitor class. Adding an element is expensive: a new NegExpr forces a new visit(NegExpr) method onto ExprVisitor and therefore onto every visitor that already implements it. The compiler flags each missing method, so nothing breaks silently, but the edit still ripples across every visitor in the codebase.

This asymmetry is the expression problem: no single arrangement of code makes both “add a new type” and “add a new operation” cheap at the same time. Visitor deliberately picks a side. It bets on a stable set of element types and a growing set of operations. That bet is exactly right for an AST, where the node kinds are essentially fixed once the language grammar settles but the passes over them keep multiplying: evaluate, type-check, constant-fold, optimize, emit code. It’s the wrong bet for a hierarchy you expect to keep extending.

The expression problem — rows versus columnsColumns are operations (one Visitor class each); rows are element types.evaluateprinttypeCheckoptimizeNumberExprAddExprMulExprNegExprNew column =one new Visitor.Cheap.New row = a new method in every existing Visitor. Expensive.The expression problem — rows versus columnsColumns are operations (one Visitor class each); rows are element types.evaluateprinttypeCheckoptimizeNumberExprAddExprMulExprNegExprNew column =one new Visitor.Cheap.New row = a new method in every existing Visitor. Expensive.

Since Java 21, there is a second way to get the same compile-time safety with far less code: sealed interfaces plus pattern matching for switch. Sealing the interface tells the compiler the complete list of implementers, and records make each node a one-liner:

public sealed interface Expr permits NumberExpr, AddExpr, MulExpr {}

public record NumberExpr(double value) implements Expr {}
public record AddExpr(Expr left, Expr right) implements Expr {}
public record MulExpr(Expr left, Expr right) implements Expr {}

An operation becomes a plain method with a switch over type patterns, no accept, no visitor interface, no visit overloads:

static double evaluate(Expr expr) {
    return switch (expr) {
        case NumberExpr n -> n.value();
        case AddExpr a -> evaluate(a.left()) + evaluate(a.right());
        case MulExpr m -> evaluate(m.left()) * evaluate(m.right());
    };
}

The switch needs no default branch, and that is the whole point. Because Expr is sealed, the compiler knows the three cases are exhaustive. Add NegExpr to the permits clause and every switch that has not been updated fails to compile, the same safety classic Visitor’s interface gives you, at a fraction of the line count.

So when is the classic pattern still the right call? Pre-Java-21 codebases have no choice. Beyond that, reach for classic Visitor when the visitor genuinely needs to be an object: something you construct with configuration, hold state across a traversal, pass into a framework, or select polymorphically at runtime. A static switch method is none of those. For the ordinary closed-hierarchy case, though, sealed types plus pattern matching are the modern default, and the pattern’s boilerplate is no longer a cost worth paying.

There’s a third case where neither belongs: a small hierarchy with one or two operations. Two element types and a single operation over them do not need a visitor or a sealed switch, they need two methods or one instanceof check. Visitor earns its keep when the structure is a real, stable tree and the operations over it genuinely multiply, which is precisely the situation the interpreter pattern creates: Interpreter builds the AST and gives it one operation, and Visitor is how every operation after the first gets added without the node classes ever knowing they exist.