🧾 Analogy
A tax auditor visits different businesses — a restaurant, a factory, a shop. Each business “accepts” the auditor and the auditor applies the right rules for that type. Next year a different inspector (fire safety) visits the same businesses with entirely different checks. The businesses don’t change; the visiting operations do.
The problem
You have a fixed family of types — shapes, or the nodes of a syntax tree — and you keep needing new operations over them: compute area, render, type-check, serialize. Adding each operation as a method on every type bloats the types and scatters unrelated logic. Visitor pulls each operation into its own object; elements simply Accept a visitor.
Structure
classDiagram
class Shape {
<<interface>>
+Accept(Visitor)
}
class Visitor {
<<interface>>
+VisitCircle(Circle)
+VisitRectangle(Rectangle)
}
class Circle { +Accept(Visitor) }
class Rectangle { +Accept(Visitor) }
class AreaVisitor { +VisitCircle() +VisitRectangle() }
Shape <|.. Circle
Shape <|.. Rectangle
Visitor <|.. AreaVisitor
Circle ..> Visitor : Accept calls VisitCircle
Rectangle ..> Visitor : Accept calls VisitRectangleIdiomatic Go
Adding a new operation means writing a new Visitor — the shapes never change. Edit and Run:
package main
import (
"fmt"
"math"
)
// Visitor has a method per concrete element type.
type Visitor interface {
VisitCircle(c *Circle)
VisitRectangle(r *Rectangle)
}
// Element accepts a visitor (double dispatch).
type Shape interface{ Accept(v Visitor) }
type Circle struct{ radius float64 }
func (c *Circle) Accept(v Visitor) { v.VisitCircle(c) }
type Rectangle struct{ w, h float64 }
func (r *Rectangle) Accept(v Visitor) { v.VisitRectangle(r) }
// A new operation = a new visitor, with zero changes to the shapes.
type AreaVisitor struct{ total float64 }
func (a *AreaVisitor) VisitCircle(c *Circle) { a.total += math.Pi * c.radius * c.radius }
func (a *AreaVisitor) VisitRectangle(r *Rectangle) { a.total += r.w * r.h }
func main() {
shapes := []Shape{
&Circle{radius: 2},
&Rectangle{w: 3, h: 4},
&Circle{radius: 1},
}
area := &AreaVisitor{}
for _, s := range shapes {
s.Accept(area)
}
fmt.Printf("total area: %.2f\n", area.total)
}
🐹 The type switch is Go's shortcut
Go developers often skip the Accept/Visit ceremony and use a type switch:
switch s := shape.(type) {
case *Circle: area += math.Pi * s.radius * s.radius
case *Rectangle: area += s.w * s.h
}It’s simpler, but you lose what full Visitor buys you: the compiler forcing every visitor to handle every type. Use the type switch for a few ad-hoc cases; use the Visitor interface when operations are many and you want that exhaustiveness — which is why go/ast.Walk takes an ast.Visitor.
The expression problem
Visitor is the textbook answer to the expression problem — the observation that you can make it cheap to add new types or new operations, but not both at once. Two layouts, opposite trade-offs:
| Layout | Add a new operation | Add a new type |
|---|---|---|
| Methods on each type | hard — touch every type | easy — one new type, done |
| Visitor | easy — one new visitor | hard — a new method in every visitor |
So the decision is entirely about which axis changes more. A syntax tree has a stable set of node types but an ever-growing list of passes (print, type-check, optimize, format) — operations dominate, so go/ast uses Visitor. A drawing app that keeps adding shapes but has a fixed handful of operations should put methods on the types instead. Pick the pattern that makes your frequent change the easy one.
In the standard library
go/ast.Walk+ast.Visitor— the Visitor pattern, by name, over Go syntax trees.filepath.WalkDir— applies a function to every node of a directory tree.
Pitfalls
⚠️ Easy to add operations, painful to add types
Visitor optimizes for stable types and growing operations. The moment you add a new element type (say Triangle), every existing visitor must grow a VisitTriangle — the compiler will make you. If your types churn more than your operations, Visitor fights you, and a type switch (or rethinking the design) is the better bet.
When to use it — and when not
✅ Reach for it when
- You have a stable set of types (e.g. AST nodes, shapes) and keep adding new operations over them.
- You want to keep unrelated operations (print, evaluate, type-check) out of the data types themselves.
- You want the compiler to force you to handle every type for each operation.
⛔ Think twice when
- The set of types changes often — every new type forces an update to every visitor.
- There are only a few cases — a type switch is simpler than the Accept/Visit dance.
Related patterns
Treat individual objects and compositions of objects uniformly through one common interface.
behavioralIteratorProvide a way to access the elements of a collection sequentially without exposing its underlying representation.
behavioralStrategyDefine a family of interchangeable algorithms, encapsulate each one, and select which to use at runtime.
Check your understanding
Score: 0 / 51. What does Visitor let you add without touching the element types?
Each operation becomes a visitor with a method per element type; you add operations freely. The trade-off is the reverse: adding an element type means updating every visitor.
2. Why does each element have an Accept(visitor) method?
Go (like Java) dispatches on one type at a time. Accept resolves the element type, then calls v.VisitCircle/VisitRectangle — together that's double dispatch (element type × operation).
3. What is Go's pragmatic alternative to a full Visitor?
`switch s := shape.(type) { case *Circle: … }` is simpler for a few cases, though you lose the compiler guarantee that every type is handled.
4. Visitor is a classic answer to the 'expression problem.' What trade-off does it make?
The expression problem: you can optimize for new operations or new types, not both freely. Methods-on-types make new types easy (add one type with all its methods) but new operations hard (touch every type). Visitor flips it: new operations are one new visitor; new types force a new method in every visitor.
5. What does the full Visitor interface give you that a type switch doesn't?
A type switch with a missing case just falls through (or hits default) at runtime. The Visitor interface makes the compiler reject any visitor that doesn't handle a newly-added element type — the exhaustiveness guarantee, which is why go/ast uses it.
Comments
Sign in with GitHub to join the discussion.