讲解

办房产证:你(房子)不用懂测绘、缴税、登记每个环节怎么办——测绘师来测、税务师来算税、登记员来备案,每来一个「访问者」,你配合他完成他的工作即可。访问者模式就是:把作用于某种对象结构中各元素的操作分离出来封装成独立的对象,使得增加新操作不必修改这些元素。它是 GoF 23 种里公认最绕的一个,本章示例刻意保持最小。

绕的根源在「双分派」(double dispatch)。普通多态是单分派:调哪个方法只由「对象是谁」决定。访问者里调用 visitXxx 由两件事情共同决定:元素的类型和访问者的类型。实现手法是那个著名的「回call」:元素类实现 accept(visitor) { visitor.visitNumber(this) }——元素把自己回传给访问者,访问者按元素的具体类型选择重载方法。第一次看觉得脱裤子放屁,多看两遍会发现这是在不支持多重分派的语言里模拟它的标准技巧。

它的核心取舍非常极端:对「操作」开放,对「元素种类」封闭。元素家族稳定(编译器的 AST 节点种类一年加不了一个),但操作经常新增(打印、求值、类型检查、格式化、导出成图……)——每加一种操作 = 新写一个访问者类,元素代码一行不动。反过来,元素家族加一个新成员时,所有访问者都要补一个方法——这正是接口的编译器检查帮你兜底的场景。

典型栖息地:编译器/解释器的 AST 处理、文档对象模型的多格式导出、对一组异构对象做统计汇总。坦白说,业务 CRUD 里十年难遇一次;但一旦遇到「一堆稳定异构元素 + 一批不断增长的操作」,它就是唯一优雅的答案。

示例

极简算术 AST(数字节点 + 加法节点),两个访问者:求值、打印中缀表达式:

import assert from 'node:assert/strict';

// 元素接口:接受任意访问者(回call 的入口)
interface ExprNode {
  accept(visitor: ExprVisitor): void;
}

// 访问者接口:对每种元素一个方法;新增元素种类时编译器逼所有访问者补齐
interface ExprVisitor {
  visitNumber(node: NumberNode): void;
  visitAdd(node: AddNode): void;
}

class NumberNode implements ExprNode {
  constructor(readonly value: number) {}

  accept(visitor: ExprVisitor): void {
    visitor.visitNumber(this); // 双分派的第二次分派:按元素类型选 visit 方法
  }
}

class AddNode implements ExprNode {
  constructor(
    readonly left: ExprNode,
    readonly right: ExprNode,
  ) {}

  accept(visitor: ExprVisitor): void {
    visitor.visitAdd(this);
  }
}

// 访问者一:求值
class EvalVisitor implements ExprVisitor {
  result = 0;

  visitNumber(node: NumberNode): void {
    this.result = node.value;
  }

  visitAdd(node: AddNode): void {
    node.left.accept(this);
    const left = this.result;
    node.right.accept(this);
    this.result = left + this.result;
  }
}

// 访问者二:打印
class PrintVisitor implements ExprVisitor {
  text = '';

  visitNumber(node: NumberNode): void {
    this.text = String(node.value);
  }

  visitAdd(node: AddNode): void {
    node.left.accept(this);
    const left = this.text;
    node.right.accept(this);
    this.text = '(' + left + ' + ' + this.text + ')';
  }
}

// 构造 (1 + 2) + 3 的语法树
const ast: ExprNode = new AddNode(new AddNode(new NumberNode(1), new NumberNode(2)), new NumberNode(3));

const evaluator = new EvalVisitor();
ast.accept(evaluator);
assert.equal(evaluator.result, 6);

const printer = new PrintVisitor();
ast.accept(printer);
assert.equal(printer.text, '((1 + 2) + 3)');

console.log('表达式:' + printer.text + ' = ' + evaluator.result);

两棵「遍历同一棵树」的逻辑互不纠缠:想再加「导出成 JSON」「统计节点数」,新写访问者类即可,NumberNode/AddNode 永远不用动。代价也摆在明面上:哪天加了 SubNode(减法节点),ExprVisitor 接口加方法后,EvalVisitor、PrintVisitor 全部编译报错——编译器强制你补齐,这是访问者模式少见的「报错是好消息」时刻。

常见坑

  • 元素种类不稳定还用访问者:节点家族三个月加一次新成员,每次都要改全部访问者——这种情况应该用策略或模式匹配(switch 判别联合)。
  • accept 里忘了回传 this:写成 visitor.visitAdd() 丢了节点,访问者拿不到数据;回call 的 this 是双分派的命根。
  • 用访问者状态传递中间结果时漏了栈:示例里用单个 result 字段保存递归中间值,深层嵌套时容易算错;复杂访问者用显式栈或返回值传递更稳。
  • 访问者里写元素本该有的行为:「节点自己的 toString」塞进访问者,是本末倒置;只有「横跨所有元素种类的操作」才属于访问者。
  • 为简单结构硬上双分派:只有两三种元素、操作也就一个时,一个 switch 判别联合直白得多——访问者是重武器。

小结

访问者用双分派把「操作」从「元素」中剥离:元素稳定、操作常增的场景里它无可替代;元素家族膨胀是它的死穴。最难的一章过去了,下一章回到地面:看这些模式在前端与 Node.js 里的真实身影。