What is double dispatch, and how does the Visitor pattern's accept/visit pair achieve it in a single-dispatch language?
answer
- Single dispatch = receiver only; args resolved statically
- accept recovers the concrete type as a static type
- Two virtual calls chained, not reflection
- Never move accept to the base class — `this` collapses
- CLOS/Julia have real multimethods; Visitor emulates them
basics
~20 sDouble dispatch means the method that runs depends on the runtime types of two objects. Most languages only dispatch on one (the receiver). Visitor chains two such calls: element.accept(v) picks by element type, then v.visit(this) picks by visitor type.
solid answer
~50 sMainstream OO languages use single dispatch: in `x.m(y)`, the runtime type of `x` selects the method body, while `y` only participates through compile-time overload resolution on its *static* type. An operation like `render(shape, renderer)` genuinely needs both runtime types, which single dispatch can't express directly. Visitor composes two single dispatches. Step 1: `element.accept(visitor)` — virtual dispatch on the element's runtime type lands in, say, `Circle.accept`. Step 2: inside that body, `this` has static type `Circle`, so the compiler binds the call `visitor.visit(this)` to the `visit(Circle)` overload, and virtual dispatch on the visitor's runtime type selects `AreaVisitor.visit(Circle)` versus `PrintVisitor.visit(Circle)`. The two runtime types have now jointly determined the executed code. The `accept` body is deliberately trivial and identical in shape across elements — that one line is the entire mechanism, which is why it cannot be pulled up into a shared base class (the static type of `this` would collapse to the base type and always bind the wrong overload).
code
java · 11 lines// Single dispatch demo: which visit() runs?
void handle(Shape s, Visitor v) {
// v.visit(s); // ILLEGAL/WRONG: binds on static type Shape only
s.accept(v); // dispatch 1: runtime type of s
}
class Circle implements Shape {
// Must live HERE, not in a base class: `this` is statically Circle,
// so the compiler binds visit(Circle); the runtime then picks the visitor impl.
public void accept(Visitor v) { v.visit(this); } // dispatch 2
}go deeper
Say that the method chosen normally depends only on the object before the dot, and that Visitor uses two calls so both the element and the visitor decide the code that runs.
Trace the four steps precisely and explain why the concrete accept must be duplicated per element class.
Contrast with instanceof chains and native multimethods, mention performance/inlining, generic Visitor<R> for return values, and non-Visitor uses of double dispatch such as collision or binary-operator dispatch.
Position Visitor as an emulation of a missing language feature, discuss when to prefer sealed types + exhaustive matching, and how code generation (parser generators, macros, annotation processors) removes the boilerplate at scale.
### Dispatch, defined **Dispatch** is the act of choosing which code runs for a call. - **Static (compile-time) dispatch / overload resolution**: the compiler picks among same-named methods using the *declared* types of the arguments. `print(int)` vs `print(String)` is chosen at compile time. - **Dynamic (runtime) dispatch / virtual call**: the runtime picks the implementation using the *actual* type of the receiver object. `animal.speak()` runs `Dog.speak` if `animal` currently holds a `Dog`. **Single dispatch** = exactly one participant (the receiver) is resolved dynamically. Java, C#, Kotlin, C++, TypeScript, Python (in the ordinary case), Swift all work this way. **Multiple dispatch / multimethods** = the runtime considers *several* argument types. Common Lisp (CLOS), Julia, Clojure's `defmulti`, and Groovy do this natively; C# does it dynamically with the `dynamic` keyword. **Double dispatch** is multiple dispatch with two participants. ### Why one dispatch isn't enough Consider collision handling: `collide(a, b)` where each of `Asteroid`, `Ship`, `Station` collides differently with each other. The behavior is a function of *two* runtime types — a 3×3 table. In a single-dispatch language, writing `a.collide(b)` picks the row by `a`'s runtime type, but the column is chosen at compile time from `b`'s declared type, which is usually the base type `SpaceObject`. So every call lands in the same generic cell. Same story for `render(shape, renderer)`, `serialize(node, format)`, `intersect(shapeA, shapeB)`. ### The two-step trick, traced precisely ``` interface Shape { accept(v: Visitor) } class Circle implements Shape { accept(v) { v.visit(this) } } class Square implements Shape { accept(v) { v.visit(this) } } interface Visitor { visit(c: Circle); visit(s: Square) } class Area implements Visitor { visit(c: Circle){…} visit(s: Square){…} } ``` Call site: `Shape s = …; Visitor v = …; s.accept(v);` 1. **First dispatch (dynamic, on the element).** The compiler only knows `s : Shape`. At runtime the vtable of the actual object is used; if `s` is a `Circle`, control enters `Circle.accept`. 2. **Type is now recovered.** Inside `Circle.accept`, the compiler *statically* knows `this : Circle`. This is the crucial step: the runtime type from step 1 has been converted into compile-time knowledge. 3. **Overload resolution (static).** `v.visit(this)` binds to the signature `visit(Circle)` at compile time — no reflection, no `instanceof`. 4. **Second dispatch (dynamic, on the visitor).** `v` is declared `Visitor`; at runtime it may be `Area` or `Perimeter`, so the vtable picks `Area.visit(Circle)`. Both runtime types have now been consumed. Cost: two virtual calls, both cheap and JIT-inlinable in hot loops. ### Common gotchas - **Never hoist `accept` into a base class.** If `AbstractShape` defines `accept(v) { v.visit(this) }`, then `this` is statically `AbstractShape`; the call binds to `visit(AbstractShape)` (or fails to compile). Every concrete element must repeat its own one-line `accept`. This apparent duplication is not an oversight — it's load-bearing. In languages with reified self-types or macros the boilerplate can be generated, but it must exist. - **`this` vs the parameter.** Passing anything other than `this` (e.g. a field, or a variable declared as the base type) loses the static type and defeats the mechanism. - **Overloading vs distinct names.** `visit(Circle)` / `visit(Square)` relies on overloading. Distinct names (`visitCircle`, `visitSquare`) work identically and are clearer in languages with weak overload rules or when you want to avoid accidental widening; the mechanism is unchanged. - **Reflection / `instanceof` chains** are an alternative that also uses two runtime types, but they push the type switch into every visitor, lose compile-time exhaustiveness, and are slower and easier to get wrong (order-sensitivity with subtypes). - **Languages with native multiple dispatch don't need `accept` at all.** In Julia or CLOS you define `visit(::Circle, ::Area)` directly. Visitor is, at bottom, a workaround for a missing language feature. - **Double dispatch ≠ Visitor.** Double dispatch is the mechanism; Visitor is one pattern built from it. Collision handling and binary-operator dispatch (`Number + Number`) use double dispatch without any Visitor structure. ### Return values The classic GoF form returns `void` and accumulates in visitor fields. A generic `accept<R>(v: Visitor<R>): R` threads results back through both dispatches, letting you write pure, recursive, stateless visitors — usually preferable in modern code.
- Why can't accept() be implemented once in an abstract base element class?Because inside the base class `this` has the base's static type, so `v.visit(this)` binds to the base overload (or doesn't compile). The whole point of accept is to expose the concrete static type at the call site, so each concrete element must declare it.
- How would you get the same effect in a language with native multiple dispatch?Define the operation as a multimethod keyed on both argument types — e.g. Julia's `area(c::Circle)` per method, or CLOS `defmethod`. No accept/visit boilerplate is needed; Visitor is an emulation of that missing feature.
- Is instanceof-chaining inside a single visit(Node) method equivalent?Functionally close, but you lose compile-time overload selection and any exhaustiveness signal, you repeat the switch in every visitor, and subtype ordering bugs become possible. Modern sealed types with exhaustive pattern matching restore the safety without accept boilerplate.
Sorting mail with two labels but a machine that only reads one. First pass sorts by destination city (element type); each city bin then runs a second pass sorting by service class (visitor type). Two single-criterion passes give you two-criterion routing.
saying these in an interview costs you the question
- Saying Java supports double dispatch natively via method overloading — overloads are resolved statically
- Trying to put accept() in a shared abstract base class to 'remove duplication'
- Calling visitor.visit(element) directly from the client and expecting the right overload
- Describing double dispatch as 'calling two methods' without explaining that both are resolved on runtime types
- Claiming accept/visit requires reflection or runtime type checks