Visitor Pattern: Adding Operations Without Changing Classes
How to add new operations over a family of classes without modifying them, using double dispatch — and why it's the pattern behind every AST-walking compiler.
Visitor Pattern
"Represent an operation to be performed on the elements of an object structure. Visitor lets you define a new operation without changing the classes of the elements on which it operates." When you have a fixed family of related classes (shapes, AST nodes, file-system entries) and you keep needing to add new operations over all of them — render, serialize, validate, price — the naive approach adds a new method to every class in the family for every new operation. The Visitor pattern flips this: operations live in separate Visitor classes, and elements only need one method each, forever: accept(visitor).
This guide closes out a trio covering structure-traversal patterns — see also Iterator Pattern (sequential access) and Memento Pattern (state capture).
1. The Problem: New Operations Mean Editing Every Class
Consider a shape hierarchy that needs several unrelated operations — rendering to SVG, computing price for a manufacturing quote, and serializing to JSON — all applied across every shape type.
// VIOLATION: every new operation means adding a method to EVERY shape class
abstract class Shape {
abstract String renderSvg();
abstract double computePrice();
abstract String toJson();
// Next quarter: "add computeShippingWeight()" -> edit every subclass again
}
class Circle extends Shape {
double radius;
String renderSvg() { return "<circle r='" + radius + "'/>"; }
double computePrice() { return Math.PI * radius * radius * 0.5; }
String toJson() { return "{\"type\":\"circle\",\"radius\":" + radius + "}"; }
}
class Square extends Shape {
double side;
String renderSvg() { return "<rect width='" + side + "' height='" + side + "'/>"; }
double computePrice() { return side * side * 0.5; }
String toJson() { return "{\"type\":\"square\",\"side\":" + side + "}"; }
}This is OCP violated in the other direction from the usual example: adding a new shape is easy (one new subclass), but adding a new operation means touching every existing subclass — the inverse trade-off from Strategy/State-style polymorphism. Worse, unrelated concerns (rendering, pricing, serialization) are interleaved inside each shape class, mixing responsibilities that have nothing to do with each other.
The signal: a class hierarchy is stable (few new subclasses expected) but keeps growing new cross-cutting operations that must handle every subclass differently. That inversion — "operations vary, types are fixed" — is exactly when Visitor beats ordinary polymorphism.
2. Structure and Double Dispatch
- Visitor — an interface with one
visit(ConcreteElement)overload per element type in the family. - Concrete Visitor — implements one operation (e.g.
PriceVisitor) across all element types. - Element — declares
accept(Visitor v), implemented by every concrete element as exactlyv.visit(this). - Concrete Element — a specific type (
Circle,Square) whoseaccept()calls the visitor back with its own concrete type.
The mechanism that makes this work is double dispatch. Java method overload resolution is normally single dispatch — it picks the method based on the runtime type of the receiver but the compile-time (static) type of the arguments. Visitor engineers around this limitation using two virtual calls in sequence:
The first virtual call (shape.accept(visitor)) resolves which element's accept runs based on the shape's actual runtime type. The second call, made from inside that concrete element's own accept() method, calls visitor.visit(this) — and because this is statically typed as Circle right there in Circle.accept(), the compiler picks the visit(Circle) overload, not a generic visit(Shape). Two single-dispatch calls chained together simulate dispatching on both the element's type and the operation's type — which is what "double dispatch" means.
3. Full Java Implementation
// Element hierarchy — stable, rarely gains new subclasses
abstract class Shape {
abstract void accept(ShapeVisitor visitor);
}
class Circle extends Shape {
final double radius;
Circle(double radius) { this.radius = radius; }
@Override
void accept(ShapeVisitor visitor) {
visitor.visit(this); // "this" is statically typed Circle here — key to double dispatch
}
}
class Square extends Shape {
final double side;
Square(double side) { this.side = side; }
@Override
void accept(ShapeVisitor visitor) {
visitor.visit(this);
}
}
class Triangle extends Shape {
final double base, height;
Triangle(double base, double height) { this.base = base; this.height = height; }
@Override
void accept(ShapeVisitor visitor) {
visitor.visit(this);
}
}
// Visitor interface — one overload per concrete element type
interface ShapeVisitor {
void visit(Circle circle);
void visit(Square square);
void visit(Triangle triangle);
}
// Concrete Visitor #1 — a whole new OPERATION, zero edits to Shape/Circle/Square/Triangle
class PriceVisitor implements ShapeVisitor {
private double totalPrice = 0;
private static final double MATERIAL_COST_PER_UNIT_AREA = 0.5;
@Override
public void visit(Circle circle) {
totalPrice += Math.PI * circle.radius * circle.radius * MATERIAL_COST_PER_UNIT_AREA;
}
@Override
public void visit(Square square) {
totalPrice += square.side * square.side * MATERIAL_COST_PER_UNIT_AREA;
}
@Override
public void visit(Triangle triangle) {
totalPrice += 0.5 * triangle.base * triangle.height * MATERIAL_COST_PER_UNIT_AREA;
}
double getTotalPrice() { return totalPrice; }
}
// Concrete Visitor #2 — another operation, still zero edits to the shape classes
class SvgRenderVisitor implements ShapeVisitor {
private final StringBuilder svg = new StringBuilder();
@Override
public void visit(Circle circle) {
svg.append("<circle r='").append(circle.radius).append("'/>");
}
@Override
public void visit(Square square) {
svg.append("<rect width='").append(square.side).append("' height='").append(square.side).append("'/>");
}
@Override
public void visit(Triangle triangle) {
svg.append("<polygon points='triangle-").append(triangle.base).append("x").append(triangle.height).append("'/>");
}
String getSvg() { return svg.toString(); }
}// Usage
List<Shape> shapes = List.of(new Circle(3), new Square(4), new Triangle(5, 6));
PriceVisitor priceVisitor = new PriceVisitor();
for (Shape shape : shapes) {
shape.accept(priceVisitor); // double dispatch resolves the right visit() overload each time
}
System.out.println("Total price: " + priceVisitor.getTotalPrice());
SvgRenderVisitor renderVisitor = new SvgRenderVisitor();
for (Shape shape : shapes) {
shape.accept(renderVisitor);
}
System.out.println(renderVisitor.getSvg());
// Adding a "ShippingWeightVisitor" next quarter touches ZERO existing classes —
// only a new ShapeVisitor implementation is written.The trade-off Visitor makes explicit: adding a new element type (a new Shape subclass) now means editing the ShapeVisitor interface and every existing concrete visitor to add the new overload — the exact opposite trade-off from ordinary polymorphism. Visitor is a deliberate bet that the element hierarchy is stable and operations are what will keep growing. If that assumption is wrong, Visitor actively makes things worse.
Java doesn't have a native "double dispatch" keyword — the accept(visitor) { visitor.visit(this); } boilerplate is the double-dispatch mechanism, achieved entirely through two chained single-dispatch virtual calls. Recognizing this pattern in code (or being asked to implement it from scratch) is one of the most common Visitor interview probes.
4. Visitor for AST Traversal
The canonical production use of Visitor is walking an Abstract Syntax Tree (AST) in a compiler, linter, or interpreter — where the "shapes" are node types (BinaryExpr, Literal, Identifier, IfStatement) and the "operations" are compiler passes (type-checking, constant-folding, code generation, pretty-printing).
interface AstNode {
void accept(AstVisitor visitor);
}
class BinaryExpr implements AstNode {
AstNode left, right;
String operator;
public void accept(AstVisitor visitor) { visitor.visit(this); }
}
class Literal implements AstNode {
Object value;
public void accept(AstVisitor visitor) { visitor.visit(this); }
}
interface AstVisitor {
void visit(BinaryExpr node);
void visit(Literal node);
}
// A TypeCheckVisitor, a ConstantFoldVisitor, and a CodeGenVisitor can all be
// added later as independent classes — none of them touch BinaryExpr or Literal.
class TypeCheckVisitor implements AstVisitor {
public void visit(BinaryExpr node) {
node.left.accept(this); // Visitor recurses using the SAME accept/visit mechanism
node.right.accept(this); // — this is where Visitor and Iterator naturally meet
}
public void visit(Literal node) { /* check literal's type */ }
}This is exactly why compilers, linters (ESLint's visitor-based rule API), and IDE tooling (Java's own com.sun.source.tree.TreeVisitor, ANTLR's generated visitors) are built around Visitor: the node hierarchy is genuinely stable (a language's grammar doesn't change often), while the number of passes/tools that need to walk it grows constantly.
5. When to Use vs. When It's Overkill
| Use Visitor when... | It's overkill when... |
|---|---|
| The element hierarchy is stable, but new cross-cutting operations over it are added often | New element/subtypes are added often — Visitor makes that the expensive direction |
| You want operations that don't logically belong inside the element classes (rendering, pricing, serialization) kept out of them | There's only ever going to be one or two operations total — plain polymorphic methods are simpler |
You need to accumulate cross-element state during a traversal (e.g. totalPrice across all shapes) | The operation only needs one element in isolation with no shared state across the structure |
| Traversing a tree/AST where many independent passes need the same walk (type-check, optimize, generate code) | The structure is a flat list with no meaningful type variation — a simple loop suffices |
6. Visitor vs. Strategy
Both patterns externalize behavior instead of hard-coding it into a class, and both are frequently taught side-by-side — but they solve opposite-shaped problems:
| Visitor | Strategy | |
|---|---|---|
| What varies | Many operations, applied across one fixed structure of element types | One operation, with interchangeable implementations, chosen at runtime |
| Dispatch mechanism | Double dispatch (accept/visit) across an entire type hierarchy at once | Single dispatch — the client just calls the one method on whichever strategy it holds |
| Element awareness | Elements must cooperate — each needs an accept(visitor) method | The context is unaware of which concrete strategy it holds; no special method needed on strategies beyond their own interface |
| Adding new capability | New Visitor subclass = new operation over all elements at once | New Strategy subclass = one new variant of one operation |
| Typical shape | shape.accept(priceVisitor) walks a whole heterogeneous structure | strategy.apply(amount) runs a single algorithm once |
// Strategy: ONE operation (apply a discount), MANY interchangeable implementations
DiscountStrategy strategy = customer.isVip() ? new VipDiscount() : new RegularDiscount();
double price = strategy.apply(amount);
// Visitor: MANY operations (price, render, serialize), over ONE fixed structure of types
for (Shape shape : shapes) {
shape.accept(priceVisitor); // operation #1 across every shape
shape.accept(renderVisitor); // operation #2 across every shape
}A sharp way to tell them apart in an interview: if the axis that's supposed to grow is "new ways to do the same thing," that's Strategy. If the axis that's supposed to grow is "new things to do to the same fixed set of types," that's Visitor.
7. Real-World Examples
- Compiler/interpreter passes — type-checkers, optimizers, and code generators walking an AST, as shown above. This is the pattern's flagship use case.
- Java's
javax.lang.model/com.sun.source.tree.TreeVisitor— thejavaccompiler's own public API for tooling (annotation processors, static analyzers) is Visitor over Java source trees. - ESLint / linter rule engines — rules are visitors that implement
visit(node)for the AST node types they care about; the linter's core just walks the tree callingaccept. - Document object model processing — XML/HTML processors (e.g. a
NodeVisitorwalking a DOM tree to extract text, validate structure, or transform nodes) apply the same double-dispatch idea outside of compilers. - File system traversal tools — a
FileVisitorinterface (Java NIO's actualjava.nio.file.FileVisitor) that walks directories and dispatches tovisitFile/preVisitDirectory/postVisitDirectoryper entry type is Visitor applied to a filesystem tree.
Interview Questions
- Explain double dispatch and why
accept(visitor) { visitor.visit(this); }achieves it using only Java's ordinary (single-dispatch) method overloading. - Why does adding a new element type to a Visitor-based hierarchy require editing every existing Concrete Visitor? Is this a flaw, or an intentional trade-off — and when is it the right trade-off?
- Walk through implementing an AST type-checker using Visitor. Where does the recursive traversal actually happen?
- What's the difference between Visitor and Strategy? Give an example where using Strategy instead of Visitor (or vice versa) would be the wrong choice.
- Why might you want to accumulate state (like a running total) inside a Concrete Visitor rather than passing accumulator arguments through every
visit()call? - How does
java.nio.file.FileVisitormap onto the classic GoF Visitor pattern's participants? - What would break if a concrete element's
accept()method calledvisitor.visit((Shape) this)instead ofvisitor.visit(this)? Why does the static type of the argument matter here? - When would you choose ordinary polymorphism (a method directly on each element class) over Visitor, even though both can "add an operation across a type hierarchy"?