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.
Two reports over a park's assets
CostReport and InspectionList each loop over trees, benches and playgrounds, checking each one's type.
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.
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)));
}
}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:
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.
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);
}
}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 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.
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 |
| A |
Concrete visitor |
| One operation, implemented per type |
Element |
| Declares |
Concrete element |
| 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.
YearlyCostreads a tree's height, so heights must be readable from outside the class. - The machinery is indirect.
accept()callingvisitTree()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 inaccept()defeats the point.- Name visit methods by type, as
visitTree(), or overload onevisit()per type in languages with overloading. Separate names read better in languages without it. - Decide who walks the structure. Here
mainloops over the assets. For a tree of elements, a composite'saccept()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::visitover astd::variantgive the same exhaustiveness check with less ceremony.
Language | Double dispatch, or its replacement |
|---|---|
Java |
|
C++ |
|
JavaScript |
|
Python |
|
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
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.