Visitor Design Pattern
Context
Consider a compiler that represents programs as Abstract Syntax Trees (ASTs). The compiler needs to perform many distinct and unrelated operations across this tree, such as type-checking, code generation, and pretty-printing.
Problem
Distributing all these diverse operations directly across the node classes of the AST would heavily clutter the structure.
- Pollution of Elements: The core purpose of an AST node is to represent syntax, not to perform type-checking or code generation. Adding these behaviors pollutes the elements.
- Violation of Open/Closed Principle: Every time a new operation is required (e.g., a new code optimization pass), you have to modify every single node class in the hierarchy.
Solution
The Visitor Pattern represents an operation to be performed on the elements of an object structure. It lets you define a new operation without changing the classes of the elements on which it operates.
It achieves this through a technique called double-dispatch. The operation that gets executed depends on two types: the type of the Visitor and the type of the Element it visits.
The key participants are:
- Visitor: Declares a
visitoperation for each class ofConcreteElementin the object structure. - ConcreteVisitor: Implements the operations declared by the
Visitor, providing the algorithm and accumulating state as it traverses the structure. - Element: Defines an
acceptoperation that takes a visitor as an argument. - ConcreteElement: Implements the
acceptoperation by calling back to the specificvisitmethod on the visitor that corresponds to its own class. - ObjectStructure: Can enumerate its elements; it may be a composite or a collection such as a list or a set.
[!WARNING] If the element classes (the object structure) change frequently, this pattern is a poor choice. Adding a new
ConcreteElementrequires adding a corresponding operation to theVisitorinterface and updating every singleConcreteVisitor.
UML Role Diagram
UML Example Diagram
Code Example
This example adds type-checking behavior to a stable AST node hierarchy. Each node accepts a visitor and calls the overload or method that matches its concrete type.
Teaching example: These snippets are intentionally small. They show one reasonable mapping of the pattern roles, not a drop-in architecture. In production, always tailor the pattern to the concrete context: lifecycle, ownership, error handling, concurrency, dependency injection, language idioms, and team conventions.
import java.util.List;
interface Node {
void accept(NodeVisitor visitor);
}
final class AssignmentNode implements Node {
public void accept(NodeVisitor visitor) {
visitor.visitAssignment(this);
}
}
final class VariableRefNode implements Node {
public void accept(NodeVisitor visitor) {
visitor.visitVariableRef(this);
}
}
interface NodeVisitor {
void visitAssignment(AssignmentNode node);
void visitVariableRef(VariableRefNode node);
}
final class TypeCheckingVisitor implements NodeVisitor {
public void visitAssignment(AssignmentNode node) {
System.out.println("Type-check assignment");
}
public void visitVariableRef(VariableRefNode node) {
System.out.println("Type-check variable reference");
}
}
public class Demo {
public static void main(String[] args) {
List<Node> ast = List.of(new AssignmentNode(), new VariableRefNode());
NodeVisitor typeChecker = new TypeCheckingVisitor();
ast.forEach(node -> node.accept(typeChecker));
}
}
#include <iostream>
#include <memory>
#include <vector>
class AssignmentNode;
class VariableRefNode;
struct NodeVisitor {
virtual ~NodeVisitor() = default;
virtual void visit(AssignmentNode& node) = 0;
virtual void visit(VariableRefNode& node) = 0;
};
struct Node {
virtual ~Node() = default;
virtual void accept(NodeVisitor& visitor) = 0;
};
class AssignmentNode : public Node {
public:
void accept(NodeVisitor& visitor) override {
visitor.visit(*this);
}
};
class VariableRefNode : public Node {
public:
void accept(NodeVisitor& visitor) override {
visitor.visit(*this);
}
};
class TypeCheckingVisitor : public NodeVisitor {
public:
void visit(AssignmentNode&) override {
std::cout << "Type-check assignment\n";
}
void visit(VariableRefNode&) override {
std::cout << "Type-check variable reference\n";
}
};
int main() {
std::vector<std::unique_ptr<Node>> ast;
ast.push_back(std::make_unique<AssignmentNode>());
ast.push_back(std::make_unique<VariableRefNode>());
TypeCheckingVisitor typeChecker;
for (const auto& node : ast) {
node->accept(typeChecker);
}
}
from __future__ import annotations
from abc import ABC, abstractmethod
class Node(ABC):
@abstractmethod
def accept(self, visitor: NodeVisitor) -> None:
pass
class NodeVisitor(ABC):
@abstractmethod
def visit_assignment(self, node: AssignmentNode) -> None:
pass
@abstractmethod
def visit_variable_ref(self, node: VariableRefNode) -> None:
pass
class AssignmentNode(Node):
def accept(self, visitor: NodeVisitor) -> None:
visitor.visit_assignment(self)
class VariableRefNode(Node):
def accept(self, visitor: NodeVisitor) -> None:
visitor.visit_variable_ref(self)
class TypeCheckingVisitor(NodeVisitor):
def visit_assignment(self, node: AssignmentNode) -> None:
print("Type-check assignment")
def visit_variable_ref(self, node: VariableRefNode) -> None:
print("Type-check variable reference")
ast: list[Node] = [AssignmentNode(), VariableRefNode()]
type_checker = TypeCheckingVisitor()
for node in ast:
node.accept(type_checker)
interface AstNode {
accept(visitor: NodeVisitor): void;
}
interface NodeVisitor {
visitAssignment(node: AssignmentNode): void;
visitVariableRef(node: VariableRefNode): void;
}
class AssignmentNode implements AstNode {
accept(visitor: NodeVisitor): void {
visitor.visitAssignment(this);
}
}
class VariableRefNode implements AstNode {
accept(visitor: NodeVisitor): void {
visitor.visitVariableRef(this);
}
}
class TypeCheckingVisitor implements NodeVisitor {
visitAssignment(node: AssignmentNode): void {
console.log("Type-check assignment");
}
visitVariableRef(node: VariableRefNode): void {
console.log("Type-check variable reference");
}
}
const ast: AstNode[] = [new AssignmentNode(), new VariableRefNode()];
const typeChecker = new TypeCheckingVisitor();
ast.forEach((node) => node.accept(typeChecker));
Consequences
- Adding Operations is Easy: You can add a new operation over an object structure simply by adding a new visitor class.
- Gathers Related Operations: Related behavior is localized in a single visitor class rather than spread across multiple node classes; behavior unrelated to a given operation is not entangled with it.
- Adding New Elements is Hard: The element class hierarchy must be stable. Adding a new element type requires modifying the visitor interface and all concrete visitors. This trade-off — easy to add operations, hard to add types — is the dual of the trade-off in plain object-oriented inheritance, and is known as the Expression Problem (Wadler, 1998).
- Visiting Across Class Hierarchies: Unlike a virtual method on
Element, a visitor can be applied to objects whose classes do not share a common base, as long as they all implementaccept. - Accumulating State: Visitors can accumulate state as they traverse the structure (e.g., a symbol table during type checking), avoiding both global variables and extra parameters threaded through every operation.
- Breaks Encapsulation: To do their work, visitors typically need access to the internal state of the elements they visit. This often forces
ConcreteElementclasses to expose state through public accessors that would otherwise be private. - Cyclic Dependency: The
Visitorinterface depends on everyConcreteElement(via thevisit*overloads), and everyElementdepends onVisitor(viaaccept). The Acyclic Visitor variant (Martin, 1998) breaks this cycle by giving each element its own narrow visitor interface and using a runtime cast insideaccept. - Modern Alternatives: In languages with sealed types and exhaustive pattern matching — such as Scala (
sealed trait+match), Rust (enum+match), or Java 21+ (sealed interfaces +switchpattern matching) — much of the Visitor pattern’s machinery is unnecessary. Aswitchover a sealed type achieves the same separation of operations from data and is checked for exhaustiveness by the compiler. (GoF themselves note that languages supporting double or multiple dispatch, such as CLOS, lessen the need for the Visitor pattern.)