Low-level designDeep dive8 min

Visitor

Add new operations over a fixed set of classes without editing them. Each class accepts a visitor and calls the visitor's method for its own type, so an operation is one class with a method per type, and the compiler lists what a new type must handle.

Watch it evolve1 / 10
The problem

Two reports over a park's assets

CostReport and InspectionList each loop over trees, benches and playgrounds, checking each one's type.

80
Start
Stage 1
Stage 2
Final

1 · The situation

A park's assets, and the reports about them

A city parks department keeps an inventory of its assets: Trees with a height, Benches that are wooden or metal, and Playgrounds with a number of pieces of equipment. The set of kinds rarely changes. What changes often is the reports: the budget office wants yearly upkeep costs, the safety team a list of what to inspect this year.

The first version writes each report as its own class, CostReport and InspectionList, which loops over the assets and checks each one's type to decide what to do. Python uses isinstance, C++ dynamic_cast.

The first versionJava
import java.util.ArrayList;
import java.util.List;

interface Asset {
    String name();
}

record Tree(String name, int heightM) implements Asset {}

record Bench(String name, boolean wooden) implements Asset {}

record Playground(String name, int pieces) implements Asset {}

class CostReport {
    int yearlyCost(List<Asset> assets) {
        int total = 0;
        for (Asset a : assets) {
            if (a instanceof Tree t) total += 30 * t.heightM();
            else if (a instanceof Bench b) total += b.wooden() ? 80 : 40;
            else if (a instanceof Playground p) total += 150 * p.pieces();
        }
        return total;
    }
}

class InspectionList {
    List<String> due(List<Asset> assets) {
        List<String> out = new ArrayList<>();
        for (Asset a : assets) {
            if (a instanceof Tree t) {
                if (t.heightM() > 10) out.add(t.name());
            } else if (a instanceof Bench b) {
                if (b.wooden()) out.add(b.name());
            } else if (a instanceof Playground p) {
                out.add(p.name());
            }
        }
        return out;
    }
}

public class Main {
    public static void main(String[] args) {
        List<Asset> park = List.of(
                new Tree("Old oak", 14),
                new Tree("Young maple", 6),
                new Bench("Lake bench", true),
                new Bench("Gate bench", false),
                new Playground("Hill playground", 7));
        System.out.println("Yearly upkeep: $" + new CostReport().yearlyCost(park));
        System.out.println("Inspect: " + String.join(", ", new InspectionList().due(park)));
    }
}
«interface»Asset+ name(): String«record»Tree− name: String− heightM: int«record»Bench− name: String− wooden: boolean«record»Playground− name: String− pieces: intCostReport~ yearlyCost(List<Asset>): intInspectionList~ due(List<Asset>): List<String>

The class diagram of this version, drawn from its code: each report depends on every kind of asset.

One park, two reports:

Yearly upkeep: $1770
Inspect: Old oak, Lake bench, Hill playground

2 · The change

More reports, and a new kind on the way

The mapping team wants a label for every asset on the city map, and next spring the department adds fountains. Look at how each report finds out what kind of asset it has:

Every report checks every typeJava
class CostReport {
    int yearlyCost(List<Asset> assets) {
        int total = 0;
        for (Asset a : assets) {
            if (a instanceof Tree t) total += 30 * t.heightM();
            else if (a instanceof Bench b) total += b.wooden() ? 80 : 40;
            else if (a instanceof Playground p) total += 150 * p.pieces();
        }
        return total;
    }
}

Each report repeats the same three-way type check. That has two costs, one now and one later:

Change

Cost in this design

A new report, map labels

Another class with another chain of type checks

A new kind of asset, fountains

A new branch in every report; a report that misses one silently skips fountains

Reading what one report does for one type

Easy: it's all in the report

The practice checks flag both reports: each switches on the Asset type. The usual fix, a method per operation on each asset class, would mean yearlyCost(), needsInspection() and mapLabel() on Tree, Bench and Playground, and every new report edits all three classes. The reports belong to different teams; the asset classes should stay as they are.

3 · The refactor

Let each asset call the right method of the report

Start from the first version. Keep the reports as separate classes, but let each asset tell the report which kind it is, by calling the report's method for its type:

AssetVisitor has one method per kind: visitTree(), visitBench() and visitPlayground(). Each asset gets accept(visitor), which calls the method for its own type, passing itself.

Java
interface AssetVisitor {
    void visitTree(Tree t);

    void visitBench(Bench b);

    void visitPlayground(Playground p);
}

record Tree(String name, int heightM) implements Asset {
    public void accept(AssetVisitor v) {
        v.visitTree(this);
    }
}
«interface»AssetVisitor+ visitTree(Tree)+ visitBench(Bench)+ visitPlayground(Playground)«interface»Asset+ name(): String+ accept(AssetVisitor)«record»Tree− name: String− heightM: int+ accept(AssetVisitor)«record»Bench− name: String− wooden: boolean+ accept(AssetVisitor)«record»Playground− name: String− pieces: int+ accept(AssetVisitor)YearlyCost− total: int+ visitTree(Tree)+ visitBench(Bench)+ visitPlayground(Playground)~ total(): intInspections− due: List<String>+ visitTree(Tree)+ visitBench(Bench)+ visitPlayground(Playground)~ due(): List<String>

After the second step the program prints what it printed before. After the third, the map team gets its labels:

Yearly upkeep: $1770
Inspect: Old oak, Lake bench, Hill playground
  tree: Old oak, 14 m
  tree: Young maple, 6 m
  bench: Lake bench, wood
  bench: Gate bench, metal
  play area: Hill playground, 7 pieces
Type checks in the reports
6
With visitors
0

Type checks across the two reports: three branches each. With visitors there are none; each asset's accept() picks the method.

4 · The pattern

Visitor: double dispatch over a fixed set of types

Visitor represents an operation on the elements of a structure as a separate object, so new operations can be added without changing the element classes. Each element has accept(visitor); each visitor has a visit method per element type. A call picks its code by two types at once, the element's and the visitor's: double dispatch.

«interface»AssetVisitor+ visitTree(Tree)+ visitBench(Bench)+ visitPlayground(Playground)«interface»Asset+ name(): String+ accept(AssetVisitor)«record»Tree− name: String− heightM: int+ accept(AssetVisitor)«record»Bench− name: String− wooden: boolean+ accept(AssetVisitor)«record»Playground− name: String− pieces: int+ accept(AssetVisitor)YearlyCost− total: int+ visitTree(Tree)+ visitBench(Bench)+ visitPlayground(Playground)~ total(): intInspections− due: List<String>+ visitTree(Tree)+ visitBench(Bench)+ visitPlayground(Playground)~ due(): List<String>MapLabels− labels: List<String>+ visitTree(Tree)+ visitBench(Bench)+ visitPlayground(Playground)~ labels(): List<String>

The roles in the parks code: AssetVisitor is the visitor, each report a concrete visitor, and each asset an element that accepts visitors.

Role

In the parks code

Its job

Visitor

AssetVisitor

A visit method for every element type

Concrete visitor

YearlyCost, Inspections, MapLabels

One operation, implemented per type

Element

Asset

Declares accept(visitor)

Concrete element

Tree, Bench, Playground

Calls the visitor's method for its own type

The first dispatch picks the tree's accept(); the second picks the visitor's visitTree().

The trade is the one from Open/closed: visitors make new operations cheap and new element types dear. Adding fountains means adding visitFountain() to AssetVisitor, and then every visitor must implement it. In Java and C++ the compiler lists each one, so none is silently skipped; a statically typed language turns the forgotten branch of the first version into a build error.

5 · What it costs

New types are expensive, and encapsulation bends

  • New element types touch every visitor. Fountains add a method to the visitor interface and to every concrete visitor. Visitor fits when the types are stable.
  • Elements must expose what visitors need. YearlyCost reads a tree's height, so heights must be readable from outside the class.
  • The machinery is indirect. accept() calling visitTree() is boilerplate a reader has to see through, and the flow of one operation jumps between classes.
  • Results have to be collected. Here visitors accumulate state, so each one is used for one walk; a generic return type is the other common shape.

Situation

Visitor or something else?

Fixed types, operations added often, by other teams

Visitor

Types added often, operations stable

A method per operation on each class

A closed set of types and a language that checks exhaustive switches

A sealed type with a pattern-matching switch, as in Enums and sealed hierarchies

One operation, two types

Just write the type check

6 · Getting it right

Keep accept() trivial

  • accept() does one thing: call the visitor's method for its type, passing itself. Logic in accept() defeats the point.
  • Name visit methods by type, as visitTree(), or overload one visit() per type in languages with overloading. Separate names read better in languages without it.
  • Decide who walks the structure. Here main loops over the assets. For a tree of elements, a composite's accept() can pass the visitor to its children.
  • Prefer the language's tool when it has one. Java's switch over a sealed interface and C++'s std::visit over a std::variant give the same exhaustiveness check with less ceremony.

Language

Double dispatch, or its replacement

Java

accept() and an interface of visit methods; or a switch with patterns over a sealed interface, checked for exhaustiveness

C++

accept() and an abstract visitor; or std::variant with std::visit (C++17), checked at compile time

JavaScript

accept() calling a named method; nothing checks that a visitor handles every type

Python

accept() calling a named method; or match on the dataclass types, which a type checker can check with assert_never

7 · Look-alikes

Operations over many types

Visitor sits among other ways to apply an operation across types or across a structure:

Approach

Where an operation lives

Cheap to add

Visitor

In one class, a method per type

Operations

Polymorphism

In each type, a method per operation

Types

Type switch

In one function, a branch per type

Operations, but missed types fail silently

Iterator

Nowhere: it only walks the elements

Traversals

Composite

In each node, combining its children

Node types

Visitor and Composite are a classic pair: a composite tree accepts a visitor and passes it down to every node, so new operations over the whole tree, such as an allergen report over a menu of bundles, need no change to the node classes.

8 · In practice

Visitors in compilers and tools

Compilers and code analyzers are the home of the pattern: a syntax tree has a fixed set of node types, and new passes, type checking, optimization, formatting, are added all the time, each as a visitor.

The practice checks have no Visitor detector. They flag the first version's type checks, and the visitor versions read as clean: no type switch, no empty methods, since every visitor handles every asset type.

Check yourself

4 questions

1. The department adds fountains. What changes in the visitor design?
2. The budget office wants a second cost report with different rates. What changes?
3. Why does Tree.accept() call v.visitTree(this) rather than a generic v.visit(this) in JavaScript?
4. When is a method per operation on each class better than Visitor?

Takeaways

Remember this

  • Visitor puts an operation over many types into one class, with a visit method per type.
  • Each element's accept() calls the visitor's method for its own type: double dispatch.
  • New operations are new visitors; the element classes never change.
  • New element types are expensive: every visitor must handle them, and the compiler says which.
  • Use it when types are stable and operations keep coming; otherwise prefer methods, or a checked switch over a sealed type.