Visitor
Adding operations over a class family without touching the classes.
01The intuition
The problem it solves
A calculator program stores an expression as a tree: numbers, additions, multiplications. First it needs evaluate. Then it needs to_string, then count_nodes, then simplify. The obvious way adds a method to every node class for every operation, so each new operation edits every class, and the node classes fill up with printing and simplifying code that has nothing to do with being a node.
class Num:
def __init__(self, v: float) -> None: self.v = v
def evaluate(self) -> float: return self.v
def to_string(self) -> str: return str(self.v)
def count(self) -> int: return 1
class Add:
def __init__(self, l, r) -> None: self.l, self.r = l, r
def evaluate(self) -> float: return self.l.evaluate() + self.r.evaluate()
def to_string(self) -> str: return f"({self.l.to_string()} + {self.r.to_string()})"
def count(self) -> int: return 1 + self.l.count() + self.r.count()
# every new operation touches Num, Add, Mul, Neg, Var ...
Build it one piece at a time
Step one: keep the node classes tiny, data only. Take one operation and move it into a class of its own with one method per node type. That class is a visitor.
class Num:
def __init__(self, v: float) -> None: self.v = v
class Add:
def __init__(self, l, r) -> None: self.l, self.r = l, r
class Mul:
def __init__(self, l, r) -> None: self.l, self.r = l, r
class Evaluator:
def visit_num(self, n: Num) -> float: return n.v
def visit_add(self, n: Add) -> float: return self.visit(n.l) + self.visit(n.r)
def visit_mul(self, n: Mul) -> float: return self.visit(n.l) * self.visit(n.r)
Step two: the node needs a way to send the visitor to the right method. Each node gets one small method, accept, that calls back the visitor's method for its own type. This is the whole trick: the node knows its type, so it picks the method.
class Num:
def accept(self, v: "Visitor"): return v.visit_num(self)
class Add:
def accept(self, v: "Visitor"): return v.visit_add(self)
class Mul:
def accept(self, v: "Visitor"): return v.visit_mul(self)
class Visitor:
def visit(self, node): return node.accept(self)
def visit_num(self, n: Num): raise NotImplementedError
def visit_add(self, n: Add): raise NotImplementedError
def visit_mul(self, n: Mul): raise NotImplementedError
Step three: every new operation is one new visitor class and zero edits to the nodes.
class Printer(Visitor):
def visit_num(self, n: Num) -> str: return str(n.v)
def visit_add(self, n: Add) -> str: return f"({self.visit(n.l)} + {self.visit(n.r)})"
def visit_mul(self, n: Mul) -> str: return f"{self.visit(n.l)} * {self.visit(n.r)}"
tree = Add(Num(2), Mul(Num(3), Num(4)))
print(Evaluator().visit(tree), Printer().visit(tree)) # 14.0 (2 + 3 * 4)
Why it works
The principle: there are two directions a program can grow, new kinds of node or new operations, and you must choose which one is cheap. Methods on classes make new node kinds cheap and new operations expensive; visitors make new operations cheap and new node kinds expensive. The invariant is that every visitor implements a method for every node type, so a tree walk never falls through. Where it breaks: adding a node type now means editing every visitor, and a visitor that stores state between visits must be reset or created fresh per walk. Interviewers test by asking you to add a node type, to see whether you know the cost, and by asking why not isinstance chains (they work, and are fine for three types, but they are the ladder you were avoiding).
The trade-off you must say out loud
Visitor is the most indirect pattern on the list: a call bounces from visitor to node and back. For a tree with two node types and one operation, write the method. Reach for a visitor when the node family is stable and the operations keep coming, which is exactly the shape of expression trees, spreadsheet formulas and document models. The follow-up question is the double dispatch itself: why accept exists at all. The answer is that the language picks a method by the receiver's type only, and you need to pick by two types.
Where it shows up in the 100 Google problems
- Boolean and arithmetic expression library: evaluate, print, simplify and collect variables are four visitors over one node family.
- Spreadsheet with formulas: a formula tree is evaluated by one visitor and its cell references found by another for dependency tracking.
- Extensible spreadsheet aggregation API: each aggregate is a visitor over the cell range; adding max does not touch the cells.