Visitor separates an operation from the types it operates on. Instead of adding a new method to every type each time you need a new operation, the operations live in a visitor struct. Each type accepts a visitor and calls the right method on it. In Go, this means every element type implements Accept(Visitor), and the visitor implements one method per element type.
Here's the honest truth: Visitor is verbose in Go and often not the right choice. The Go alternative (a type switch) is simpler and covers most use cases. Use Visitor when you need the open/closed principle for operations, meaning you want to add new operations without modifying element types. Use a type switch when you need simplicity and your element types are stable.
Scenario
You have a small expression tree: numbers, addition, multiplication. You need to evaluate it, print it, and eventually type-check it. Without Visitor, each new operation adds a method to every node type.
// bloated_nodes.go
package expr
import "fmt"
type Node interface {
Eval() float64
Print() string
// Adding TypeCheck() means modifying every implementation.
// Adding Optimise() means modifying every implementation again.
}
type Number struct{ Value float64 }
func (n *Number) Eval() float64 { return n.Value }
func (n *Number) Print() string { return fmt.Sprintf("%.0f", n.Value) }
type Add struct{ Left, Right Node }
func (a *Add) Eval() float64 { return a.Left.Eval() + a.Right.Eval() }
func (a *Add) Print() string {
return fmt.Sprintf("(%s + %s)", a.Left.Print(), a.Right.Print())
}
// Every new operation bloats every node type.Each new operation adds a method to every node. The node types become dumping grounds for unrelated operations. You can't add operations from outside the package.
Solution
Define a Visitor interface with one Visit method per node type. Each node has Accept(Visitor) that calls the appropriate Visit method. New operations are new Visitor implementations; node types don't change.
Visitor interface Node interface
├── VisitNumber(*Number) ├── Accept(Visitor)
├── VisitAdd(*Add) │
└── VisitMul(*Mul) Number.Accept(v) → v.VisitNumber(n)
Add.Accept(v) → v.VisitAdd(n)package gomark
import "fmt"
type Visitor interface {
VisitNumber(n *Number) any
VisitAdd(n *Add) any
VisitMul(n *Mul) any
}
type Node interface {
Accept(v Visitor) any
}
type Number struct{ Value float64 }
type Add struct{ Left, Right Node }
type Mul struct{ Left, Right Node }
func (n *Number) Accept(v Visitor) any { return v.VisitNumber(n) }
func (n *Add) Accept(v Visitor) any { return v.VisitAdd(n) }
func (n *Mul) Accept(v Visitor) any { return v.VisitMul(n) }
type Evaluator struct{}
func (e *Evaluator) VisitNumber(n *Number) any { return n.Value }
func (e *Evaluator) VisitAdd(n *Add) any {
return n.Left.Accept(e).(float64) + n.Right.Accept(e).(float64)
}
func (e *Evaluator) VisitMul(n *Mul) any {
return n.Left.Accept(e).(float64) * n.Right.Accept(e).(float64)
}
type Printer struct{}
func (p *Printer) VisitNumber(n *Number) any { return fmt.Sprintf("%.0f", n.Value) }
func (p *Printer) VisitAdd(n *Add) any {
return fmt.Sprintf("(%s + %s)", n.Left.Accept(p).(string), n.Right.Accept(p).(string))
}
func (p *Printer) VisitMul(n *Mul) any {
return fmt.Sprintf("(%s * %s)", n.Left.Accept(p).(string), n.Right.Accept(p).(string))
}
func main() {
// (3 + 4) * 2
tree := &Mul{
Left: &Add{Left: &Number{Value: 3}, Right: &Number{Value: 4}},
Right: &Number{Value: 2},
}
fmt.Println("Expression:", tree.Accept(&Printer{}))
fmt.Println("Result: ", tree.Accept(&Evaluator{}))
}And here's the simpler type-switch alternative for comparison:
func Eval(n Node) float64 {
switch v := n.(type) {
case *Number:
return v.Value
case *Add:
return Eval(v.Left) + Eval(v.Right)
case *Mul:
return Eval(v.Left) * Eval(v.Right)
default:
panic(fmt.Sprintf("unknown node: %T", n))
}
}Output:
Expression: ((3 + 4) * 2)
Result: 14In most Go codebases, a type switch is preferred over Visitor. It's simpler, more readable, and exhaustive-switch linters tell you when you've missed a case. Use Visitor only when you truly need the open/closed principle for operations, for example in a compiler or interpreter where new analysis passes are added frequently but the AST node types are stable.
When to Use
- You need to add many operations to a stable set of element types.
- Operations are the dimension that changes; element types are stable.
- You want operations defined outside the element types' package.
When Not to Use
- Element types change frequently. Every new type requires updating every Visitor.
- You have few operations. A type switch is simpler and more Go-idiomatic.
- The double dispatch ceremony (Accept/Visit) feels disproportionate to the problem.
The Decision
The open/closed benefit only works in one direction. Adding a new operation is usually cheap: you add one new visitor struct and leave existing node code alone. But adding a new node type is expensive, because every existing visitor must now learn that new type. This is the exact opposite tradeoff of a type switch.
In this example, the any return type is the roughest part of Visitor in Go. You lose compile-time type safety at each Accept call and depend on type assertions, which can panic at runtime if they are wrong. Generics can reduce that risk, but they also make the design harder to read and maintain. The boilerplate is real: with five node types and ten operations, you end up writing fifty visit methods. Visitor is worth it only when new operations are added often and the set of node types stays mostly stable.
Related Patterns
- Composite: Visitor works best with Composite structures: the Composite defines the tree, Visitor adds operations that traverse it without modifying the node types.
- Iterator: Iterator provides sequential access to elements; Visitor performs type-specific operations on each element. Combine them when you need to traverse a tree and apply different logic per node type.