{} The Go Reference

Behavioral pattern · Gang of Four · Advanced

Visitor

Add new operations to a set of object types without modifying those types, by moving each operation into a visitor.

Behavioral Advanced ⏱ 4 min read Complete

🧾 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 VisitRectangle

Idiomatic Go

Adding a new operation means writing a new Visitor — the shapes never change. Edit and Run:

visitor.go — editable & runnable
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:

LayoutAdd a new operationAdd a new type
Methods on each typehard — touch every typeeasy — one new type, done
Visitoreasy — one new visitorhard — 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.

Check your understanding

Score: 0 / 5

1. 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.