Skip to content

Visitor — Middle Level

Source: refactoring.guru/design-patterns/visitor Prerequisite: Junior


Table of Contents

  1. Beyond hello-world
  2. AST Visitor (compiler / interpreter)
  3. File system traversal
  4. Visitor + Composite
  5. Reflective Visitor (default fallback)
  6. Sealed types + pattern matching alternative
  7. Visitor with accumulator state
  8. Visitor that mutates the tree
  9. Generic / typed return values
  10. Stateful traversal: depth, parents, paths
  11. Pre-order / post-order / in-order
  12. Common refactorings
  13. Comparing Visitor with alternatives
  14. Anti-patterns at this level
  15. Diagrams

Beyond hello-world

Junior level showed shapes. In real code, Visitor appears in:

  • Compiler ASTs — type check, optimization, code generation as separate visitors.
  • DOM / XML / HTML traversal — apply transformations to nodes.
  • Configuration trees — validate, render, merge.
  • Parse trees — ANTLR generates Visitor and Listener classes.
  • Spring Expression Language — visitors evaluate expressions.

The middle-level theme: handling real trees (not flat lists) and combining Visitor with other patterns.


AST Visitor (compiler / interpreter)

A small expression language: 1 + 2 * 3 → AST → multiple operations on it.

AST

public sealed interface Expr permits NumberLit, BinaryOp, Variable {
    <R> R accept(ExprVisitor<R> visitor);
}

public record NumberLit(double value) implements Expr {
    public <R> R accept(ExprVisitor<R> v) { return v.visitNumber(this); }
}

public record BinaryOp(Expr left, String op, Expr right) implements Expr {
    public <R> R accept(ExprVisitor<R> v) { return v.visitBinary(this); }
}

public record Variable(String name) implements Expr {
    public <R> R accept(ExprVisitor<R> v) { return v.visitVariable(this); }
}

public interface ExprVisitor<R> {
    R visitNumber(NumberLit n);
    R visitBinary(BinaryOp b);
    R visitVariable(Variable v);
}

Visitor 1: Evaluator

public final class Evaluator implements ExprVisitor<Double> {
    private final Map<String, Double> env;

    public Evaluator(Map<String, Double> env) { this.env = env; }

    public Double visitNumber(NumberLit n)   { return n.value(); }
    public Double visitVariable(Variable v)  { return env.getOrDefault(v.name(), 0.0); }

    public Double visitBinary(BinaryOp b) {
        double l = b.left().accept(this);
        double r = b.right().accept(this);
        return switch (b.op()) {
            case "+" -> l + r;
            case "-" -> l - r;
            case "*" -> l * r;
            case "/" -> l / r;
            default  -> throw new IllegalStateException("op: " + b.op());
        };
    }
}

Visitor 2: Pretty-printer

public final class Printer implements ExprVisitor<String> {
    public String visitNumber(NumberLit n)   { return String.valueOf(n.value()); }
    public String visitVariable(Variable v)  { return v.name(); }
    public String visitBinary(BinaryOp b) {
        return "(" + b.left().accept(this) + " " + b.op() + " " + b.right().accept(this) + ")";
    }
}

Visitor 3: Variable collector

public final class VarCollector implements ExprVisitor<Set<String>> {
    public Set<String> visitNumber(NumberLit n)   { return Set.of(); }
    public Set<String> visitVariable(Variable v)  { return Set.of(v.name()); }
    public Set<String> visitBinary(BinaryOp b) {
        Set<String> left = b.left().accept(this);
        Set<String> right = b.right().accept(this);
        Set<String> result = new HashSet<>(left);
        result.addAll(right);
        return result;
    }
}

Demo

Expr ast = new BinaryOp(
    new NumberLit(1),
    "+",
    new BinaryOp(new Variable("x"), "*", new NumberLit(3))
);

double result = ast.accept(new Evaluator(Map.of("x", 2.0)));   // 7.0
String pretty = ast.accept(new Printer());                      // "(1.0 + (x * 3.0))"
Set<String> vars = ast.accept(new VarCollector());              // {x}

Each operation is a separate visitor. Adding "type checker" or "optimizer" = new visitor. No edits to AST nodes.

This is exactly how compilers like javac, TypeScript Compiler, Roslyn, ANTLR-generated parsers work.


File system traversal

A file/folder tree with multiple operations: total size, file count, find duplicates, virus scan.

from abc import ABC, abstractmethod
from typing import Generic, TypeVar

R = TypeVar("R")


class FsNode(ABC):
    def __init__(self, name: str): self.name = name
    @abstractmethod
    def accept(self, visitor: "FsVisitor[R]") -> R: ...


class File(FsNode):
    def __init__(self, name: str, size: int):
        super().__init__(name)
        self.size = size
    def accept(self, visitor): return visitor.visit_file(self)


class Directory(FsNode):
    def __init__(self, name: str, children: list[FsNode]):
        super().__init__(name)
        self.children = children
    def accept(self, visitor): return visitor.visit_directory(self)


class FsVisitor(ABC, Generic[R]):
    @abstractmethod
    def visit_file(self, f: File) -> R: ...
    @abstractmethod
    def visit_directory(self, d: Directory) -> R: ...


class TotalSize(FsVisitor[int]):
    def visit_file(self, f: File) -> int:
        return f.size

    def visit_directory(self, d: Directory) -> int:
        return sum(c.accept(self) for c in d.children)


class FileCount(FsVisitor[int]):
    def visit_file(self, f: File) -> int:
        return 1

    def visit_directory(self, d: Directory) -> int:
        return sum(c.accept(self) for c in d.children)


class FindLarge(FsVisitor[list[File]]):
    def __init__(self, threshold: int):
        self.threshold = threshold

    def visit_file(self, f: File) -> list[File]:
        return [f] if f.size > self.threshold else []

    def visit_directory(self, d: Directory) -> list[File]:
        result = []
        for c in d.children:
            result.extend(c.accept(self))
        return result

Usage:

root = Directory("/", [
    File("a.txt", 100),
    File("b.bin", 5_000_000),
    Directory("docs", [
        File("readme.md", 2_000),
        File("report.pdf", 10_000_000),
    ]),
])

print(root.accept(TotalSize()))           # 15_002_100
print(root.accept(FileCount()))           # 4
print([f.name for f in root.accept(FindLarge(1_000_000))])
# ['b.bin', 'report.pdf']

The directory's visit_directory recurses by calling each child's accept(self) — passing the same visitor down. The visitor naturally walks the tree.


Visitor + Composite

The Composite pattern is a tree of objects sharing an interface. Visitor + Composite is a classic combo.

  • Composite structures the tree (file / directory; expression / sub-expression).
  • Visitor does operations on the tree.

The composite's accept(visitor) typically: 1. Calls visitor.visitGroup(this), OR 2. Forwards to children: for (Component c : children) c.accept(visitor);

Either way works. Modern preference: let the visitor decide whether to recurse, by having visitGroup call child.accept(this) itself. This gives the visitor more control (e.g., short-circuit, depth limits).

class Group implements Shape {
    private final List<Shape> children;
    public <R> R accept(ShapeVisitor<R> v) { return v.visitGroup(this); }
    public List<Shape> children() { return children; }
}

class AreaCalculator implements ShapeVisitor<Double> {
    public Double visitGroup(Group g) {
        return g.children().stream().mapToDouble(c -> c.accept(this)).sum();
    }
    // ... visitCircle, visitSquare ...
}

Reflective Visitor (default fallback)

What if you want most visitors to handle just a few node types and ignore the rest?

public abstract class DefaultVisitor<R> implements ExprVisitor<R> {
    public R visitNumber(NumberLit n)   { return defaultValue(); }
    public R visitVariable(Variable v)  { return defaultValue(); }
    public R visitBinary(BinaryOp b) {
        b.left().accept(this);
        b.right().accept(this);
        return defaultValue();
    }
    protected abstract R defaultValue();
}

// A specialized visitor only cares about variables:
public class VarLogger extends DefaultVisitor<Void> {
    @Override public Void visitVariable(Variable v) {
        System.out.println("var: " + v.name());
        return null;
    }
    @Override protected Void defaultValue() { return null; }
}

Inheriting from a default visitor reduces ceremony for narrow visitors. Eclipse JDT / IntelliJ AST visitors work this way.


Sealed types + pattern matching alternative

Modern languages let you skip Visitor for many cases:

Java 17+ sealed + pattern matching

public sealed interface Shape permits Circle, Square, Triangle {}
public record Circle(double radius) implements Shape {}
public record Square(double side) implements Shape {}
public record Triangle(double base, double height) implements Shape {}

double area(Shape s) {
    return switch (s) {
        case Circle c     -> Math.PI * c.radius() * c.radius();
        case Square sq    -> sq.side() * sq.side();
        case Triangle t   -> 0.5 * t.base() * t.height();
    };
}

No accept method, no visitor interface. Compiler enforces exhaustiveness — adding a new shape = compile errors everywhere switch (Shape) lives, which is what you want.

TypeScript discriminated unions

type Shape =
    | { kind: "circle"; radius: number }
    | { kind: "square"; side: number }
    | { kind: "triangle"; base: number; height: number };

function area(s: Shape): number {
    switch (s.kind) {
        case "circle":   return Math.PI * s.radius ** 2;
        case "square":   return s.side ** 2;
        case "triangle": return 0.5 * s.base * s.height;
    }
}

s.kind discriminator drives type narrowing.

Kotlin sealed classes

sealed class Shape
data class Circle(val radius: Double) : Shape()
data class Square(val side: Double) : Shape()
data class Triangle(val base: Double, val height: Double) : Shape()

fun area(s: Shape): Double = when (s) {
    is Circle   -> Math.PI * s.radius * s.radius
    is Square   -> s.side * s.side
    is Triangle -> 0.5 * s.base * s.height
}

when is exhaustive.

When pattern matching beats Visitor

  • Element types stable.
  • Need access to all element data (visible).
  • One operation at a time, written as a function.
  • No need to share traversal logic among operations.

When Visitor is still preferable

  • Many operations sharing traversal logic (e.g., visiting BinaryOp recurses left/right; switch must repeat).
  • Need to organize operations as classes with state.
  • Visitor pre-existed (refactoring tax).
  • Code generation / template tools expect visitors (ANTLR).

In practice: modern code uses pattern matching for one-off operations and Visitor for elaborate, stateful, multi-pass traversals.


Visitor with accumulator state

A visitor can carry state to accumulate results during traversal:

public final class StatsVisitor implements ExprVisitor<Void> {
    private int numberOfVariables = 0;
    private int numberOfBinaryOps = 0;
    private double constantSum = 0;

    public Void visitNumber(NumberLit n) {
        constantSum += n.value();
        return null;
    }

    public Void visitVariable(Variable v) {
        numberOfVariables++;
        return null;
    }

    public Void visitBinary(BinaryOp b) {
        numberOfBinaryOps++;
        b.left().accept(this);
        b.right().accept(this);
        return null;
    }

    public int variables()    { return numberOfVariables; }
    public int binaryOps()    { return numberOfBinaryOps; }
    public double sum()       { return constantSum; }
}

Usage:

StatsVisitor stats = new StatsVisitor();
ast.accept(stats);
System.out.printf("vars=%d ops=%d sum=%.1f%n", stats.variables(), stats.binaryOps(), stats.sum());

Visitor is one-shot: after a traversal, read its accumulated state, then discard. Don't reuse without resetting.


Visitor that mutates the tree

Mutating visitor returns the new (or same) node:

public final class ConstFolder implements ExprVisitor<Expr> {
    public Expr visitNumber(NumberLit n)   { return n; }
    public Expr visitVariable(Variable v)  { return v; }

    public Expr visitBinary(BinaryOp b) {
        Expr l = b.left().accept(this);
        Expr r = b.right().accept(this);

        if (l instanceof NumberLit ln && r instanceof NumberLit rn) {
            // both sides constant → fold
            return new NumberLit(switch (b.op()) {
                case "+" -> ln.value() + rn.value();
                case "*" -> ln.value() * rn.value();
                default  -> throw new IllegalStateException();
            });
        }
        return new BinaryOp(l, b.op(), r);   // structurally identical, possibly with rewritten children
    }
}

(1 + 2) * 3 becomes 3 * 3 after one pass; another pass yields 9.

This is the core of AST rewriting: each visitor returns a new tree (or unchanged) — a foundational compiler technique.

For mutable trees, an alternative: visitor modifies nodes in-place and returns void. Less safe (visitor with side effects); pure rewriting visitors are usually clearer.


Generic / typed return values

Visitor's return type is parameterized:

public interface ExprVisitor<R> {
    R visitNumber(NumberLit n);
    R visitBinary(BinaryOp b);
    R visitVariable(Variable v);
}

Different visitors return different types: - Evaluator returns Double. - Printer returns String. - TypeChecker returns Type. - Optimizer returns Expr.

If your visitor needs no return value, use Void (Java) / null (Python) / void (TypeScript). For void visitors that mutate state internally, type is Void and accept always returns null.


Stateful traversal: depth, parents, paths

Sometimes you need context as you walk the tree.

Depth

public final class DepthAwarePrinter implements ExprVisitor<Void> {
    private int depth = 0;

    public Void visitNumber(NumberLit n) {
        System.out.println("  ".repeat(depth) + "Number(" + n.value() + ")");
        return null;
    }

    public Void visitVariable(Variable v) {
        System.out.println("  ".repeat(depth) + "Variable(" + v.name() + ")");
        return null;
    }

    public Void visitBinary(BinaryOp b) {
        System.out.println("  ".repeat(depth) + "Binary(" + b.op() + ")");
        depth++;
        b.left().accept(this);
        b.right().accept(this);
        depth--;
        return null;
    }
}

Parent tracking

public final class ParentTrackingVisitor implements ExprVisitor<Void> {
    private final Deque<Expr> parents = new ArrayDeque<>();

    public Void visitBinary(BinaryOp b) {
        parents.push(b);
        b.left().accept(this);
        b.right().accept(this);
        parents.pop();
        return null;
    }

    public Void visitVariable(Variable v) {
        Expr parent = parents.peek();
        System.out.println("var " + v.name() + " under: " + parent);
        return null;
    }

    public Void visitNumber(NumberLit n) { return null; }
}

ANTLR's ParseTreeVisitor and Eclipse JDT visitors track parents this way. The deque pattern is universal for traversal context.


Pre-order / post-order / in-order

Different operations want different traversal orders:

Pre-order: visit parent before children

public Void visitBinary(BinaryOp b) {
    handle(b);                       // pre-order
    b.left().accept(this);
    b.right().accept(this);
    return null;
}

Used for: emitting code (open tag, then children), counting nodes from root down.

Post-order: visit children before parent

public Void visitBinary(BinaryOp b) {
    b.left().accept(this);
    b.right().accept(this);
    handle(b);                       // post-order
    return null;
}

Used for: evaluation (compute children first, then combine), constant folding, type checking from leaves up.

In-order: between left and right (for binary trees)

public Void visitBinary(BinaryOp b) {
    b.left().accept(this);
    handle(b);                       // in-order
    b.right().accept(this);
    return null;
}

Used for: pretty-printing, iterating sorted BSTs.

The visitor controls order. Don't bake order into accept methods — let visitors choose.


Common refactorings

Refactoring 1: From instanceof chain to Visitor

Before:

double area(Shape s) {
    if (s instanceof Circle c) return Math.PI * c.radius * c.radius;
    if (s instanceof Square sq) return sq.side * sq.side;
    throw new IllegalArgumentException("unknown: " + s);
}

double perimeter(Shape s) {
    if (s instanceof Circle c) return 2 * Math.PI * c.radius;
    if (s instanceof Square sq) return 4 * sq.side;
    throw new IllegalArgumentException("unknown: " + s);
}

Repeated structure; both can be Visitor.

After: AreaVisitor implements ShapeVisitor<Double>, PerimeterVisitor implements ShapeVisitor<Double>.

Or, in modern Java/Kotlin: sealed types + exhaustive switch (no Visitor needed).

Refactoring 2: Operation moved out of element

Before:

class Circle {
    double area() { ... }
    String toJson() { ... }
    String toSvg() { ... }
    Color dominantColor() { ... }   // AI-extracted color
    boolean intersects(Circle other) { ... }
    boolean isInside(Rect bounds) { ... }
}

Bloated.

After: Operations become visitors: - AreaVisitor - JsonVisitor - SvgVisitor - IntersectionTester (specialized — visitor for one operation between two specific elements; double dispatch overkill).

Keep on the class only what is intrinsic to being a Circle (radius, equality, hashing). Lift extrinsic operations.

Refactoring 3: Visitor → Strategy

If you have one operation but it varies, that's Strategy not Visitor:

interface AreaStrategy { double compute(Shape s); }
class StandardArea implements AreaStrategy { ... }
class SignedArea implements AreaStrategy { ... }   // negative for clockwise polygons

Visitor handles many operations; Strategy handles many implementations of one operation.


Comparing Visitor with alternatives

Approach When Pros Cons
Visitor Stable hierarchy, growing operations Clean separation; type-safe Hierarchy locked
instanceof / pattern match Simple, one-off operations Lightweight; modern syntax Repeated chains; missed cases
Method on element Operation intrinsic; few operations Encapsulation; OO-natural Element bloats
Sealed + switch Modern languages; stable hierarchy Compiler exhaustiveness; less ceremony Element-side operations only
Strategy One operation; many algorithms Fine-grained; pluggable Doesn't solve hierarchy problem
Reflection / dispatch table Runtime-only types Most flexible No compile-time safety

Anti-patterns at this level

Anti-pattern 1: God visitor

class GiantVisitor implements ShapeVisitor {
    Double area;
    String svg;
    String json;
    Color dominantColor;
    // 20 fields, 10 mixed concerns
}

Visitor is supposed to do one thing. If your visitor has multiple unrelated responsibilities, split it.

Anti-pattern 2: Visitor for elements that change often

If your team adds a new element class every week, every visitor breaks every week. Stop using Visitor; use pattern matching, methods on elements, or a hash-map dispatch.

Anti-pattern 3: Visitor that bypasses encapsulation

class Circle {
    public double radius;   // exposed for Visitor!
    public Point center;    // exposed!
    public Color borderColor;   // exposed!
    public LineStyle borderStyle;   // exposed!
}

Visitor needs some public access, but if you're exposing 20 fields, your Visitor is too tightly coupled. Maybe operations belong on the class.

Anti-pattern 4: Visitor in a non-stable hierarchy

If your hierarchy is MammalVisitor and you're constantly adding new species, you'll never finish. Visitor is only for stable hierarchies.

Anti-pattern 5: Visitor that returns mismatched types per element

class WeirdVisitor implements ShapeVisitor<Object> {
    public Object visitCircle(Circle c) { return c.radius; }   // Double
    public Object visitSquare(Square s) { return s.toString(); }   // String
    public Object visitTriangle(Triangle t) { return new Date(); }   // !?
}

Use Object only as last resort. If return types vary, the visitor is doing multiple things — split.


Diagrams

Visitor + Composite tree walk

graph TD G[Group accept v] G --> C1[Circle accept v] G --> C2[Group accept v] C2 --> C3[Square accept v] C2 --> C4[Triangle accept v]

v is the same visitor throughout. Each accept calls visitX(this).

Pattern matching alternative

flowchart TB s[Shape s] s --> sw{switch s} sw -->|Circle c| a1[π × r²] sw -->|Square sq| a2[side²] sw -->|Triangle t| a3[½ × base × h]

Compiler proves exhaustiveness — adding a Hexagon to the sealed hierarchy makes this switch fail to compile.

Two-pass compiler with visitors

graph LR AST[AST root] AST --> V1[TypeChecker visitor] V1 --> AST2[AST with type annotations] AST2 --> V2[CodeGen visitor] V2 --> Bytecode

Each pass is a visitor. Order matters; output of one becomes input of next.


← Junior · Senior →