skip to content

The Visitor pattern makes adding new operations easy but adding new element types hard. Explain this trade-off and how it should drive your decision to use Visitor.

level: seniorimportance: must knowfreq 40%

answer

  1. Matrix: rows = types, columns = operations
  2. Group by row = OO methods; by column = Visitor
  3. Wadler's expression problem
  4. Stable types + growing ops → Visitor (compilers)
  5. Growing types (plugins) → plain polymorphism

basics

~20 s

Visitor gathers one operation across all element types into one class, so a new operation is a new class and no edits elsewhere. But a new element type means adding a visit method to the visitor interface, which breaks every existing visitor. Use it when types are stable and operations grow.

solid answer

~50 s

This is the expression problem: data is a matrix of (element type × operation), and mainstream languages let you extend cheaply along only one axis. Putting methods on element classes makes adding a type cheap (one new class, nothing else edited) and adding an operation expensive (edit every class). Visitor rotates that: a new operation is one new visitor class with zero edits to elements, while a new element type forces a new `visit` method on the visitor interface plus an implementation in every concrete visitor — an O(number of visitors) change that a compiler will flag but that may span code you don't own. Decision rule: estimate the churn rate of each axis. Stable, closed element hierarchies with an open-ended, growing set of operations — AST/compiler passes, query plans, document trees, IRs, scene graphs — favour Visitor. Volatile hierarchies, or only one or two operations, favour plain polymorphic methods. Also weigh the encapsulation cost: external visitors need wider public access to element state.

go deeper

for a junior

State the two-way trade in plain terms and give one example each of a good and a bad fit.

for a middle

Draw the type × operation matrix, explain grouping by row vs column, and give the churn-rate decision rule.

for a senior

Name the expression problem, quantify the cost of adding an element type (O(visitors), compile errors as a checklist), and cover secondary costs: encapsulation, state, boilerplate.

for a principal

Discuss escape hatches and their costs (default methods, acyclic visitor, dynamic fallback), languages that solve the problem outright via type classes/multimethods, and the governance angle of publishing a visitor interface across team or library boundaries.

### The matrix view Write your design as a table: rows are **element types**, columns are **operations**. | | evaluate | print | typeCheck | |---|---|---|---| | **NumberLiteral** | ✔ | ✔ | ✔ | | **Addition** | ✔ | ✔ | ✔ | | **VariableRef** | ✔ | ✔ | ✔ | Every cell must exist. The only design freedom is **how you group the cells into compilation/modification units**. - **Group by row (classic OO / methods on elements).** Each element class holds all its operations. Adding a *row* (new element type) = write one new class; nothing else changes. Adding a *column* (new operation) = open and edit every existing class. - **Group by column (Visitor / functional style).** Each visitor class holds one operation for all elements. Adding a *column* = write one new class; nothing else changes. Adding a *row* = extend the visitor interface and edit every visitor. This symmetry is Philip Wadler's **expression problem**: extend a datatype with new cases *and* new functions over it, without recompiling existing code and while retaining static type safety. Ordinary OO and ordinary functional (`match`/`switch` over a sum type) each solve half. ### What the pain actually feels like Adding an element type to a Visitor design: 1. Add `visit(NewNode)` to the `Visitor` interface. 2. Every implementing class — possibly dozens of compiler passes — now fails to compile until it implements the new method. 3. If the interface is **published** in a library, every downstream implementer breaks on upgrade. That's a binary- and source-compatibility break. The compiler error is, importantly, a **feature**: it enumerates exactly the passes that must consider the new node. Silent omission would be worse. Mitigations exist — a default method that throws or delegates to a generic `defaultVisit`, an abstract `BaseVisitor` with sensible fallbacks, or the acyclic-visitor variant — but each trades the compile-time exhaustiveness guarantee for source stability. That trade should be made deliberately, not by reflex. ### The decision rule Ask: **which axis churns?** - **Element types stable, operations growing → Visitor.** Compilers and interpreters are the archetype: the set of AST node kinds changes maybe once per language release; passes (parse, resolve, type-check, borrow-check, constant-fold, inline, lower, emit) are added constantly. Same for query planners, HTML/XML DOM processing, scene graphs, IR optimizers, static analyzers, and generated APIs (ANTLR emits `Visitor`/`Listener`; protobuf emits per-message accessors for the same reason). - **Element types growing, operations stable → plain polymorphic methods.** A plugin system where third parties contribute new `Widget` types but the framework only ever calls `render()` and `measure()`. Forcing Visitor here means every plugin author's new widget breaks the framework's visitor interface — a design disaster. - **Both growing → you have the expression problem for real.** Options: sealed hierarchies with exhaustive pattern matching (compiler still flags every non-exhaustive match, but you don't own third-party matches), open multimethods, type classes / traits with coherence rules (Haskell, Rust, Scala implicits) which genuinely solve it at the cost of complexity, or accept a dynamic fallback (a `defaultVisit`, registry lookup, or reflective dispatch) and lose static exhaustiveness. - **Only one or two operations → don't use Visitor at all.** The accept/visit boilerplate, the extra indirection, and the widened element APIs are not worth it. YAGNI applies. ### Secondary costs to weigh alongside the axis question - **Encapsulation erosion.** Since the operation lives outside, elements must expose their internals. A `Circle` that only needed `area()` now needs a public `radius`. Over time the element hierarchy becomes an anemic data holder — sometimes exactly what you want (an AST *should* be plain data), sometimes a real loss. - **Cognitive indirection.** Reading `element.accept(v)` tells you nothing about what happens; you must know the visitor. Debuggers and stack traces get one extra frame per node. Newcomers find it opaque. - **Boilerplate.** N elements × M visitors methods, plus N `accept` bodies. Code generation or language support (sealed types, records, macros) reduces this substantially. - **State and reentrancy.** Void-returning visitors accumulate mutable state, which makes them non-reentrant and non-thread-safe by default; a generic `Visitor<R>` returning values fixes this. ### The honest summary line for an interview "Visitor doesn't remove the cost of change — it *moves* it. I use it when I'm confident the element hierarchy is the stable axis, and I explicitly note that a new element type is then an O(visitors) refactor that the compiler will point at."

  • You've adopted Visitor and now genuinely must add an element type. What do you do?
    Add the visit method to the interface and let the compiler enumerate every visitor that must be updated — treat those errors as the review checklist. If the interface is published, add it as a default method delegating to a defaultVisit/unsupported fallback and version/deprecate deliberately, accepting the loss of compile-time exhaustiveness.
  • Which language features solve the expression problem outright?
    Type classes / traits with coherent instance resolution (Haskell, Rust), Scala implicits, Clojure protocols and multimethods, and open multimethods in CLOS/Julia — they let you add both new cases and new operations without editing existing code, at the cost of extra conceptual machinery.
  • Does using Visitor violate the Open/Closed Principle?
    It satisfies OCP for the element hierarchy (closed to modification, open to new operations) and violates it for the visitor hierarchy. OCP is always relative to a chosen axis of change; Visitor is a deliberate choice of which axis to protect.

A spreadsheet stored as rows versus columns. Row storage makes appending a record cheap and adding a field expensive; column storage flips it. Neither is 'better' — you pick based on which dimension actually grows.

saying these in an interview costs you the question

  • Presenting Visitor as strictly superior to methods-on-elements rather than a trade
  • Adopting Visitor for a hierarchy that third parties extend (plugins), guaranteeing breakage
  • Introducing Visitor for a single operation because 'it's a design pattern'
  • Claiming the compile errors on adding an element type are purely a downside — they are the exhaustiveness guarantee
  • Not mentioning the encapsulation cost of exposing element state to external visitors

context