Behavioral Pattern Pattern Deep Dive

Visitor

A shape library starts with just calculateArea(). Then someone needs exportToSVG(). Then calculatePerimeter(). Every time, you're back editing Circle, Square, and Triangle all over again to add one more method. The Visitor pattern flips this around: new operations become new classes instead of new methods, and the shape classes themselves never need to change again.

⚠️ The Problem

You have a small hierarchy of shapes — Circle, Square, Triangle — and you keep needing new operations across all of them: calculateArea() first, then exportToSVG(), then calculatePerimeter(). If each operation is a method added directly to every shape class, adding the Nth operation means opening and editing N files every single time, even though each individual shape class has nothing to do with the others. Worse, some of these operations (like SVG export) are arguably a rendering concern that has no business living inside a geometry class in the first place — but there's nowhere else natural to put a method that needs to behave differently per shape.

✅ The Solution

Define a Visitor interface with one visit method per concrete shape — visitCircle(circle), visitSquare(square), visitTriangle(triangle). Give every shape class exactly one new method, accept(visitor), whose entire body is just calling back the matching visit method: circle.accept(visitor) calls visitor.visitCircle(this). This callback handoff is called double dispatch. Now a brand-new operation — say, AreaVisitor or SVGExportVisitor — is a whole new class implementing Visitor, with zero changes to Circle, Square, or Triangle. The tradeoff: adding a brand-new shape class still means updating every existing Visitor implementation to handle it, since each one needs a visit method per shape.

Example 1 — a shape hierarchy with area and SVG export

Primary Example — TypeScript
interface Visitor {
  visitCircle(c: Circle): any;
  visitSquare(s: Square): any;
  visitTriangle(t: Triangle): any;
}

interface Shape {
  accept(visitor: Visitor): any;
}

class Circle implements Shape {
  constructor(public radius: number) {}
  accept(visitor: Visitor) { return visitor.visitCircle(this); }
}

class Square implements Shape {
  constructor(public side: number) {}
  accept(visitor: Visitor) { return visitor.visitSquare(this); }
}

class Triangle implements Shape {
  constructor(public base: number, public height: number) {}
  accept(visitor: Visitor) { return visitor.visitTriangle(this); }
}

// Operation 1: compute area — no changes to any Shape class needed.
class AreaVisitor implements Visitor {
  visitCircle(c: Circle) { return Math.PI * c.radius ** 2; }
  visitSquare(s: Square) { return s.side ** 2; }
  visitTriangle(t: Triangle) { return 0.5 * t.base * t.height; }
}

// Operation 2: export to SVG — again, zero changes to Shape classes.
class SVGExportVisitor implements Visitor {
  visitCircle(c: Circle) { return `<circle r="${c.radius}" />`; }
  visitSquare(s: Square) { return `<rect width="${s.side}" height="${s.side}" />`; }
  visitTriangle(t: Triangle) {
    return `<polygon points="0,0 ${t.base},0 ${t.base / 2},${t.height}" />`;
  }
}

const shapes: Shape[] = [new Circle(5), new Square(4), new Triangle(6, 3)];

const areaVisitor = new AreaVisitor();
const totalArea = shapes.reduce((sum, shape) => sum + shape.accept(areaVisitor), 0);
console.log(totalArea.toFixed(2)); // 94.54

const svgVisitor = new SVGExportVisitor();
shapes.forEach((shape) => console.log(shape.accept(svgVisitor)));

Every shape's accept() method is one line and never changes again, no matter how many new visitors get written. AreaVisitor and SVGExportVisitor each carry all the logic for their own operation, spread across shape-specific visit methods, entirely separate from each other.

Example 2 — a secondary context: an AST pretty-printer

Secondary Example — Python
from abc import ABC, abstractmethod

class Visitor(ABC):
    @abstractmethod
    def visit_number(self, node): ...
    @abstractmethod
    def visit_add(self, node): ...
    @abstractmethod
    def visit_multiply(self, node): ...

class Node(ABC):
    @abstractmethod
    def accept(self, visitor): ...

class Number(Node):
    def __init__(self, value):
        self.value = value
    def accept(self, visitor):
        return visitor.visit_number(self)

class Add(Node):
    def __init__(self, left, right):
        self.left = left
        self.right = right
    def accept(self, visitor):
        return visitor.visit_add(self)

class Multiply(Node):
    def __init__(self, left, right):
        self.left = left
        self.right = right
    def accept(self, visitor):
        return visitor.visit_multiply(self)

# New operation: pretty-printing — no changes to Number/Add/Multiply needed.
class PrettyPrintVisitor(Visitor):
    def visit_number(self, node):
        return str(node.value)
    def visit_add(self, node):
        return f"({node.left.accept(self)} + {node.right.accept(self)})"
    def visit_multiply(self, node):
        return f"({node.left.accept(self)} * {node.right.accept(self)})"

# Represents: (3 + 4) * 2
tree = Multiply(Add(Number(3), Number(4)), Number(2))

printer = PrettyPrintVisitor()
print(tree.accept(printer))  # (3 + 4) * 2

Same double-dispatch handoff as the shapes example: each AST node's accept() calls back into the matching visit_* method. A future EvaluateVisitor that actually computes the numeric result would be a new class, with Number, Add, and Multiply untouched.

👍 When To Use It

  • You have a relatively stable set of classes but keep adding new operations across all of them — Visitor moves the "growth axis" from the classes to the operations.
  • An operation's logic doesn't really belong inside the element classes themselves (like rendering or serialization logic sitting inside a geometry class).
  • You want each operation's logic for every type gathered in one place, rather than spread thinly across many classes' methods.

👎 When NOT To Use It

  • When the set of element classes changes often — every new class means updating every existing Visitor, which can get expensive fast if the hierarchy isn't stable.
  • When you only ever need one or two operations total — plain virtual methods on each class are simpler until operations start multiplying.
  • In languages without real method overloading/dispatch support, implementing double dispatch cleanly can be awkward enough that a simpler type-check-based approach reads more clearly.

❓ FAQ

What exactly is "double dispatch," and why does it need a whole extra method?

Normal method calls in most object-oriented languages are "single dispatch" — which method actually runs depends on just one thing, the runtime type of the object the method is called on. If you write generic code like visitor.visit(shape) where shape is typed as the general Shape interface, single dispatch can only pick a method based on visitor's type — it can't also pick based on shape's concrete type, because that decision already happened once, at the single call site, using the compile-time/declared type of the argument. Double dispatch fixes this by routing the call through two single-dispatch steps instead of one: first shape.accept(visitor) dispatches on shape's real concrete type (a normal virtual method call — this is why every concrete shape needs its own tiny accept override), and *inside* that method, visitor.visitCircle(this) dispatches a second time on which concrete Visitor it is. The combination of the two picks the exactly correct method for the actual pair of (concrete element type, concrete visitor type) — something a single method call could never do on its own.

Why not just use instanceof / type-checking instead of accept()?

You could write if (shape instanceof Circle) { ... } else if (shape instanceof Square) { ... } inside each visitor, and it would work. But then every visitor has to repeat that type-checking chain, the compiler can't help you catch a missing case (an unhandled shape subtype silently falls through instead of failing to compile), and it's exactly the kind of scattered conditional logic most patterns in this track exist to avoid. accept()/double dispatch gets you the equivalent dispatch table for free, checked by the type system in languages that support method overloading properly.

Doesn't Visitor break encapsulation, since visitors often need shape internals?

It can lean that way — a visitCircle(circle) method typically needs to read circle.radius, which means either that field has to be public/exposed via a getter, or the element class has to expose exactly the data its visitors need. This is a known, accepted tradeoff of the pattern: it optimizes for adding operations without touching element classes, at the cost of those element classes exposing a bit more of their internals than they might otherwise.

🙋 There Are No Dumb Questions

Q: If adding a new shape means updating every Visitor, isn't that just moving the same maintenance problem somewhere else?
A: In a sense, yes — Visitor doesn't eliminate the coupling between "the set of types" and "the set of operations," it just chooses which side absorbs a change. If new element types are rare but new operations are frequent (the shape/AST scenarios above), Visitor is a clear win. If it's the other way around — new element types show up constantly but the set of operations is fixed — plain virtual methods on each class are usually the better fit, since that's the axis that changes.

✅ Quick Check

What does "double dispatch" mean in the Visitor pattern?


That's all 23 patterns — time for the recap.

← Back to
Next →