skip to content

What is the Visitor design pattern, and what problem does it solve?

level: juniorimportance: must knowfreq 55%

answer

  1. Operation extracted into its own object
  2. accept(v) then v.visit(this) = double dispatch
  3. Easy new operations, hard new element types
  4. Fits stable hierarchies: AST, shapes, DOM
  5. Open/Closed for elements, not for visitors

basics

~20 s

Visitor puts an operation over a set of object types into a separate 'visitor' object instead of inside those types. Each element has an accept method that calls back the right visit method. You can add new operations without editing the element classes.

solid answer

~50 s

Visitor represents an operation to be performed on the elements of an object structure, letting you define new operations without changing the classes of the elements. Two hierarchies exist: elements (Circle, Square, or AST nodes) each exposing accept(visitor), and visitors, each declaring one visit method per concrete element type. Calling element.accept(v) dispatches on the element's runtime type, then the element calls v.visit(this), which dispatches on the visitor's type — double dispatch. The payoff: an operation that would otherwise be scattered as one method per element class is gathered into one cohesive class (AreaVisitor, PrettyPrintVisitor, TypeCheckVisitor), and adding another operation means writing another visitor, touching zero element classes. The cost is symmetric: adding a new element type forces every existing visitor interface and implementation to change. Visitor therefore fits stable element hierarchies with a growing, open-ended set of operations — compilers, ASTs, document/shape trees, query plans.

code

typescript · 19 lines
typescript
interface Shape { accept<R>(v: ShapeVisitor<R>): R }
interface ShapeVisitor<R> { circle(c: Circle): R; square(s: Square): R }

class Circle implements Shape {
  constructor(readonly r: number) {}
  accept<R>(v: ShapeVisitor<R>) { return v.circle(this) }   // dispatch #2
}
class Square implements Shape {
  constructor(readonly side: number) {}
  accept<R>(v: ShapeVisitor<R>) { return v.square(this) }
}

// A whole new operation, zero edits to Circle/Square:
class Area implements ShapeVisitor<number> {
  circle(c: Circle) { return Math.PI * c.r * c.r }
  square(s: Square) { return s.side * s.side }
}

const total = shapes.reduce((sum, s) => sum + s.accept(new Area()), 0) // dispatch #1

go deeper

for a junior

State the intent in one sentence (operation moved out of the element classes), sketch accept/visit, and give one example such as computing area over shapes.

for a middle

Add the double-dispatch mechanism and why single dispatch is insufficient, plus the add-operation/add-element trade-off with a concrete scenario.

for a senior

Frame it as the expression problem, discuss traversal ownership, return values vs accumulated state, encapsulation cost, and name real systems (AST passes, ANTLR, annotation processing).

for a principal

Discuss when Visitor is the wrong tool versus sealed types + exhaustive pattern matching, the versioning hazard of publishing a visitor interface in a library, and mitigations (default methods, abstract base visitor, acyclic visitor).

### The problem Visitor addresses Suppose you have a family of related types — say the nodes of an abstract syntax tree (AST): `NumberLiteral`, `Addition`, `Multiplication`, `VariableRef`. Now you need several operations over that tree: evaluate it, print it as source text, type-check it, compute its depth, optimize it, serialize it to JSON. The **default object-oriented answer** is to put each operation on each class: every node gets `evaluate()`, `print()`, `typeCheck()`, `depth()`… This works but has two problems: 1. **Scattering.** The logic of "type checking" is spread as fragments across every node class. To read or change type checking you open ten files. Related code that changes together does not live together (low cohesion). 2. **Modification pressure.** Every new operation edits *every* element class. If those classes live in a library you don't own, or are meant to be stable, you can't. This is a direct conflict with the **Open/Closed Principle** ("open for extension, closed for modification") for the element hierarchy. Visitor inverts this. The operation becomes a first-class object. ### Structure (define every term) - **Element** — a type in the object structure being operated on (`NumberLiteral`, `Addition`). Each element implements a single method, conventionally `accept(visitor)`. - **Visitor** — an interface with one method per concrete element type: `visitNumber(NumberLiteral n)`, `visitAddition(Addition a)`, and so on. (Many languages just overload the name `visit`.) - **Concrete visitor** — one implementation of that interface per operation: `EvaluateVisitor`, `PrintVisitor`, `TypeCheckVisitor`. - **Object structure** — the collection or tree holding the elements, which is walked so that `accept` is called on each element. The canonical shape: ``` interface Node { accept(v: Visitor) } class Addition implements Node { left: Node; right: Node accept(v) { v.visitAddition(this) } // one line, forever unchanged } interface Visitor { visitNumber(n: NumberLiteral) visitAddition(a: Addition) } class PrintVisitor implements Visitor { visitNumber(n) { emit(n.value) } visitAddition(a) { a.left.accept(this); emit(" + "); a.right.accept(this) } } ``` Usage: `tree.accept(new PrintVisitor())`. ### Why the `accept` indirection exists A naive alternative is a visitor with a single `visit(Node n)` that does `if (n is Addition) … else if (n is NumberLiteral) …`. That works, but it re-introduces a type switch in every visitor, and the compiler cannot tell you when you've forgotten a case after a new node type appears. Most mainstream OO languages perform **single dispatch**: at a call `x.m(y)`, the method chosen depends on the runtime type of `x` only; the runtime type of `y` is ignored (overload resolution on `y` happens at compile time using its *static* type). Visitor needs the chosen behavior to depend on **two** runtime types: which element, and which operation. The trick is two chained single dispatches: `element.accept(v)` resolves on the element's runtime type; inside, `v.visitAddition(this)` resolves on the visitor's runtime type, and `this` is statically known to be `Addition`, so the correct overload is picked. This composition is called **double dispatch**. ### The trade-off, stated once and honestly Visitor **rotates** the extensibility axis: | | Add a new operation | Add a new element type | |---|---|---| | Methods on elements (classic OO) | edit every element class | add one class, edit nothing | | Visitor | add one class, edit nothing | edit visitor interface + every visitor | This is the **expression problem**: with ordinary language features you can have easy extension along one axis, not both. Choose Visitor when the **element hierarchy is stable** and the **set of operations grows** — compilers/interpreters (an AST's node kinds change rarely; passes are added constantly), document object models, shape/scene graphs, IR/query-plan optimizers, static-analysis tools, and generated code from parser generators (ANTLR, protobuf) which ship visitor/listener interfaces exactly for this reason. Avoid it when the element hierarchy is volatile, when there are only one or two operations (a plain method is simpler), or when the operation needs data that elements legitimately keep private — Visitor tends to force elements to widen their public surface (accessors) so external visitors can do their work, weakening encapsulation. ### Practical notes - **Return values / accumulated state.** The classic form has `visit` return nothing; the visitor accumulates results in its own fields (`result`, a stack, a `StringBuilder`) and you read them after the walk. A generic visitor `Visitor<R>` whose `visit` methods return `R` is common in modern code and avoids mutable state. - **Traversal.** Somebody must walk the structure. Either the element's `accept` recurses into children, or the visitor recurses, or an external iterator drives it. Each choice has consequences (order control, ability to skip subtrees, reuse). - **Naming.** "Visitor" is sometimes loosely used for any callback that gets handed each item (e.g. file-tree walkers). That's the spirit, but without the per-type `visit` methods and double dispatch it's really a callback/observer, not the GoF Visitor.

  • Why can't the visitor just have one method visit(Node n) and do a type check inside?
    You can, but each visitor then hand-rolls a type switch, the compiler can't warn you about missing cases when a node type is added, and dispatch cost/readability degrade. The per-type visit methods move that switch into the language's dispatch mechanism once.
  • Does Visitor break encapsulation?
    Often yes, partially: because the operation lives outside the element, elements must expose enough state (public accessors) for visitors to work. That's a real cost you weigh against the cohesion gain.
  • Name a place you've seen Visitor in the wild.
    Compiler/AST passes; ANTLR-generated Visitor/Listener classes; Java's FileVisitor for Files.walkFileTree; javax.lang.model ElementVisitor in annotation processing; Jackson/serialization tree walkers; query-plan optimizers.

A museum has a fixed set of exhibits (elements). Different guides — a history guide, an art-technique guide, a children's guide — each walk the same exhibits and say different things. Adding a new guide is trivial. Adding a whole new exhibit means every guide must learn a new script.

saying these in an interview costs you the question

  • Saying Visitor's purpose is 'to iterate a collection' — that's Iterator; Visitor is about the operation, not the walk
  • Claiming Visitor makes both new operations and new element types easy — it explicitly trades one for the other
  • Believing accept/visit is boilerplate with no purpose, rather than the mechanism for double dispatch
  • Using Visitor on a hierarchy that is still churning, then being surprised every change touches N visitor classes
  • Confusing Visitor with Strategy: Strategy swaps one algorithm behind one interface; Visitor spreads one algorithm across many element types

context