Hermes Wiki

Visitor Pattern

Concept

Visitor lets you add new operations to a family of related classes (an "object structure," often a tree — an AST, a document object model, a file-system representation) without modifying those classes themselves. Each element class exposes a single accept(visitor) method that calls back into the visitor with itself (visitor.visitCircle(this), visitor.visitSquare(this)), a technique called double dispatch: the actual method invoked depends on both the runtime type of the element and the runtime type of the visitor, which plain single-dispatch method calls (or a simple if/instanceof chain) can't express directly. A new operation — export to SVG, compute total area, serialize to JSON — becomes a new Visitor implementation with one visitX() method per element type, added without touching any element class.

The tradeoff Visitor makes explicit is the "expression problem": in an object-oriented model, it's easy to add a new type (a new shape subclass) but hard to add a new operation across all types without editing every one of them; Visitor flips this so adding a new operation is easy (a new Visitor class) but adding a new type is hard (every existing Visitor implementation needs a new visitNewType() method). This inversion is deliberate, not a side effect — Visitor is specifically for object structures whose set of element types is stable but whose set of operations keeps growing, such as a compiler's AST (node types rarely change once the grammar stabilizes; new passes — type-checking, optimization, code-gen — are added constantly).

Tradeoffs

Approach Benefit Cost
Visitor (double dispatch, operations live outside element classes) New operations added without touching any element class; keeps operation-specific logic together in one visitor instead of scattered across element classes Adding a new element type requires updating every existing visitor's interface and implementation; the double-dispatch mechanics (accept/visitX) are non-obvious to a reader unfamiliar with the pattern
Operation as a method on each element class (shape.computeArea()) Simple, idiomatic OOP; no extra indirection to understand Adding a new operation means editing every element class to add the new method — the exact opposite tradeoff from Visitor
instanceof/type-switch dispatch inside a single free function No interfaces to design at all; trivial for a small, rarely-changing set of types Every operation re-implements its own type-switch, and the compiler/type-checker can't guarantee every case is handled when a new type is added (unless the language has exhaustive pattern matching)

Visitor and per-class methods are the two canonical answers to the expression problem, and neither dominates — the choice depends on which axis (types or operations) is actually expected to grow in the specific codebase. A codebase that gets this wrong ends up either bolting more and more unrelated methods onto element classes, or maintaining a Visitor interface with dozens of near-identical visitX() stubs for element types that rarely change.

When to use / when not to

  • Use when there's a genuinely stable, closed set of element types and a growing, open set of operations over them — AST/parser passes, document-format converters (Markdown → HTML, HTML → PDF), file-system tree walkers computing different aggregates (size, permission audit, dedup).
  • Especially valuable when different operations need very different data/state accumulated across the traversal (a linter visitor accumulating diagnostics vs. a formatter visitor accumulating output text) — keeping each concern in its own visitor class avoids cramming unrelated accumulator fields onto the element classes.
  • Don't use it when the element type hierarchy changes often relative to the operations — the maintenance cost flips onto every visitor needing a new method for every new type, which is worse than just adding a method to the (rarely touched) new type itself.
  • In languages with pattern matching over sum types/enums (Rust, Kotlin, Scala, Swift), an exhaustive match/when expression often replaces Visitor entirely — the compiler enforces exhaustiveness directly, which is most of what Visitor's double-dispatch machinery exists to approximate in languages without it.

Common pitfall

Forgetting to add the new visitX() method to every existing Visitor implementation when a new element type is introduced, and having the omission surface as a silent no-op or a generic fallback branch rather than a compile error — in a dynamically-typed language especially, a Visitor base class with a default visit(node) that just does nothing will happily "handle" an unrecognized node type by ignoring it, producing a formatter that silently drops content or a linter that silently misses a whole class of bugs. Statically-typed languages that make the Visitor interface an actual interface (not an abstract class with defaults) turn this into a compile error instead — worth the extra verbosity specifically because it converts a silent correctness bug into a build failure.

Engineering Lens

Visitor is a good diagnostic for the underlying expression-problem question a design review should be asking explicitly rather than leaving implicit: "over this hierarchy's lifetime, which grows more — the types or the operations over them?" Teams that reach for Visitor reflexively for every tree-like structure often haven't actually verified that assumption, and end up maintaining a heavyweight double-dispatch mechanism for a type hierarchy that in practice churns just as much as the operations do. The pattern earns its complexity specifically in compiler/interpreter-adjacent code (ASTs, IR passes) where "stable types, growing operations" is a genuinely reliable long-term property of the domain, not just an assumption at design time.

  • Iterator Pattern — both are common companions when traversing a structure, but Iterator abstracts how you walk a collection while Visitor abstracts what you do at each stop
  • Template Method Pattern — a Visitor's traversal is often itself implemented as a Template Method (fixed traversal order, overridable per-node behavior)

Sources

Hermes Wiki