一、为什么需要访问者
你正在写一个编译器,AST(抽象语法树)有 20 种节点:数字字面量、二元运算、函数调用、变量引用……每种节点都需要多种处理:求值、类型检查、代码生成、格式化输出。你把求值逻辑写在节点类里,类型检查也写在节点类里,代码生成还是写在节点类里。每个节点类膨胀成几百行,任何一种操作的改动都可能影响其他操作。
来看看膨胀的节点类长什么样:
// 每种操作都塞进节点类——越来越臃肿class NumberNode { double value; double eval() { return value; } String typeCheck() { return "number"; } String codeGen() { return "PUSH " + value; } String format() { return String.valueOf(value); } // 每加一种操作,就要改所有节点类}
class AddNode { Expr left, right; double eval() { return left.eval() + right.eval(); } String typeCheck() { if (!left.typeCheck().equals("number")) return "error"; if (!right.typeCheck().equals("number")) return "error"; return "number"; } String codeGen() { return left.codeGen() + "\n" + right.codeGen() + "\nADD"; } String format() { return "(" + left.format() + " + " + right.format() + ")"; }}更糟糕的是,你需要加一个新的优化遍历。你得修改所有 20 个节点类,每个加一个 optimize() 方法。这是经典的表达式问题(Expression Problem):数据类型(节点种类)和操作(求值、类型检查……)是两个正交维度。把操作写在数据类型里,新增操作就要改所有类型。
用一张表来看这个问题的两个维度:
| 求值 | 类型检查 | 代码生成 | 新增优化 | |
|---|---|---|---|---|
| NumberNode | eval | typeCheck | codeGen | 需修改 |
| AddNode | eval | typeCheck | codeGen | 需修改 |
| MulNode | eval | typeCheck | codeGen | 需修改 |
| 新增 SubNode | 需新增 | 需新增 | 需新增 | 需新增 |
纵轴是数据类型,横轴是操作。每加一列(新操作),所有行都要改。每加一行(新类型),所有列都要补。传统 OOP 把操作放在行里,新增列就痛不欲生。
访问者模式翻转了这个关系:把操作从节点类里抽出来,每种操作是一个独立的访问者。节点类只需要一个 accept(visitor) 方法,把自身传给访问者,由访问者根据节点类型分发到对应的处理逻辑。新增操作?写个新访问者就行,不用改节点。代价是新增节点类型时,所有访问者都要加一个方法——但编译器的节点类型通常比操作更稳定。
二、现实类比
建筑检查员巡查不同类型的房间。检查员对每种房间有不同的检查清单——厨房查消防和卫生,浴室查防水和通风。房间不需要知道怎么检查自己,它们只需开门,让检查员根据房间类型做正确的事。换个检查员(比如电气检查员),房间不用改,只是检查内容不同了。
这个类比揭示了一个关键点:房间的种类(数据类型)相对稳定——建筑建好之后很少新增房间类型,但检查项目(操作)经常增加。消防检查完了加电气检查,电气检查完了加结构检查。这正是访问者模式最擅长的场景——数据稳定、操作多变。
三、核心思想
访问者模式将「如何遍历树」与「在每个节点做什么」分离。树节点通过 accept(visitor) 方法把自身传给访问者,访问者根据节点类型分发到类型特定的回调。这就是双重分派(Double Dispatch):第一次分派是节点调用 accept,第二次分派是访问者内部根据节点类型选择对应方法。
3.1 双重分派详解
为什么叫「双重」?看一段 Java 代码的执行过程:
// 第一次分派:运行时根据 receiver 的实际类型选择 accept 的实现expr.accept(visitor);// NumberNode.accept 中:visitor.visit(this);// 第二次分派:运行时根据 this 的实际类型选择 visit 的重载第一次分派发生在 expr.accept(visitor)——Java 的虚方法机制根据 expr 的实际类型(NumberNode 还是 AddNode)选择对应的 accept 实现。第二次分派发生在 visitor.visit(this)——this 的编译时类型是具体的节点子类(比如 NumberNode),所以编译器选择了 visit(NumberNode) 这个重载。
注意一个微妙之处:如果第二次调用的 this 声明为父类 Expr,Java 只会根据编译时类型选择 visit(Expr),不会根据运行时类型再分派一次。这就是为什么 accept 必须写在每个子类里——每个子类的 this 编译时类型不同,才能触发正确的重载。这也解释了为什么不支持多重分派的语言需要访问者模式来模拟双重分派。
3.2 内部访问者 vs 外部访问者
访问者还可以按遍历控制权的归属分为两种风格:
- 内部访问者(Internal Visitor):访问者自己控制遍历顺序。
visitAdd方法里显式调用visit(left)和visit(right)。好处是灵活——可以在遍历中剪枝、跳过子树、或者改变遍历顺序(比如先访问右子树再访问左子树)。Babel 的访问者就是这种风格。 - 外部访问者(External Visitor):遍历逻辑和数据结构分离,通常由
accept方法驱动遍历。节点在accept中先处理自己,再递归调用子节点的accept。访问者只负责对每个节点做处理,不控制遍历。好处是遍历逻辑只写一次,坏处是失去了灵活控制。
同一棵树,Eval 访问者算出 10,Print 访问者输出 ((2 * 3) + 4)。树结构不变,新增操作只需新建访问者。
| 属性 | 值 |
|---|---|
| 添加操作 | 容易——编写新的访问者 |
| 添加节点类型 | 困难——必须更新所有访问者(表达式问题的另一面) |
| 遍历控制 | 内部访问者灵活控制,外部访问者由数据结构驱动 |
| 模式分类 | 行为型——与 Strategy 和 Iterator 相关 |
四、变体与对比
| 模式 | 与访问者的关系 | 核心区别 |
|---|---|---|
| 迭代器 | 两者都遍历数据结构——迭代器产出元素,访问者分发回调 | 迭代器不关心元素类型,访问者根据类型分发 |
| 虚函数表 | 访问者的分发表在概念上是按节点类型索引的 vtable | vtable 按类型组织方法,访问者按操作组织方法 |
| 标签联合体 | 访问者分发匹配标签联合的类型标签,是同一问题的不同解法 | 见下文详述 |
| 策略模式 | 访问者可以看作应用于整棵树的策略族 | 策略替换一个行为,访问者替换一组类型特定行为 |
| 状态机 | 访问者可以遍历状态机节点;状态机可以驱动访问者分发 | 状态机按状态转移,访问者按类型分派 |
4.1 访问者 vs 标签联合体:表达式问题的两种解法
访问者和标签联合体(Tagged Union)是表达式问题的两个对立解法。访问者选择了「容易加操作、难加类型」,标签联合体选择了「容易加类型、难加操作」。
// Rust 的 enum + match:标签联合体风格enum Expr { Number(f64), Add(Box<Expr>, Box<Expr>), Mul(Box<Expr>, Box<Expr>),}
// 加操作:必须修改所有 matchfn eval(e: &Expr) -> f64 { match e { Expr::Number(n) => *n, Expr::Add(l, r) => eval(l) + eval(r), Expr::Mul(l, r) => eval(l) * eval(r), }}
// 加类型:在 enum 里加一个变体,编译器会提示所有 match 需要更新对比一下:
| 维度 | 访问者(OOP) | 标签联合体(FP) |
|---|---|---|
| 新增操作 | 新建一个访问者类,不改已有代码 | 在所有 match/switch 函数里加分支 |
| 新增数据类型 | 在所有访问者里加方法 | 在 enum 里加变体,编译器强制更新 |
| 类型安全 | 依赖接口完整性,可能运行时报错 | 编译器穷举检查,缺分支直接报错 |
| 适合场景 | 操作比类型变化频繁 | 类型比操作变化频繁 |
选择哪种,取决于你面临的变化方向。编译器前端节点类型很少变,但分析遍历经常加——用访问者。游戏里的实体类型经常加,但行为相对固定——用标签联合体。没有银弹,只有对变化方向的正确判断。
五、多语言实现
5.1 Go 实现
// Expr 是 AST 节点的接口type Expr interface{ exprNode() }
type NumberExpr struct{ Value float64 }type AddExpr struct{ Left, Right Expr }type MulExpr struct{ Left, Right Expr }
func (NumberExpr) exprNode() {}func (AddExpr) exprNode() {}func (MulExpr) exprNode() {}
// ExprVisitor 定义对每种节点的操作type ExprVisitor interface { VisitNumber(n NumberExpr) float64 VisitAdd(e AddExpr) float64 VisitMul(e MulExpr) float64}
// Visit 根据节点类型分发到访问者的对应方法func Visit(e Expr, v ExprVisitor) float64 { switch n := e.(type) { case NumberExpr: return v.VisitNumber(n) case AddExpr: return v.VisitAdd(n) case MulExpr: return v.VisitMul(n) default: panic("unknown node type") }}
// EvalVisitor 求值访问者type EvalVisitor struct{}
func (EvalVisitor) VisitNumber(n NumberExpr) float64 { return n.Value }func (EvalVisitor) VisitAdd(e AddExpr) float64 { return Visit(e.Left, EvalVisitor{}) + Visit(e.Right, EvalVisitor{})}func (EvalVisitor) VisitMul(e MulExpr) float64 { return Visit(e.Left, EvalVisitor{}) * Visit(e.Right, EvalVisitor{})}
// PrintVisitor 打印访问者——新增操作,零修改 ASTtype PrintVisitor struct{}
func (PrintVisitor) VisitNumber(n NumberExpr) float64 { fmt.Printf("%g", n.Value) return n.Value // 返回值仅用于满足接口,实际副作用在打印}func (PrintVisitor) VisitAdd(e AddExpr) float64 { fmt.Print("(") Visit(e.Left, PrintVisitor{}) fmt.Print(" + ") Visit(e.Right, PrintVisitor{}) fmt.Print(")") return 0}func (PrintVisitor) VisitMul(e MulExpr) float64 { fmt.Print("(") Visit(e.Left, PrintVisitor{}) fmt.Print(" * ") Visit(e.Right, PrintVisitor{}) fmt.Print(")") return 0}Go 没有传统的继承和方法重载,所以用 interface + type switch 替代 OOP 的双重分派。Visit 函数承担了分发的职责——它检查节点的运行时类型,然后调用访问者的对应方法。这和 Java 里 accept + visit 的两次虚方法调用等效,只是把两次分派合并到一个函数里了。
PrintVisitor 的加入正好说明访问者的核心价值:新增操作不需要碰任何已有代码。Expr 接口、三个节点类型、Visit 分发函数、EvalVisitor——全都不用改。只需要新建一个 PrintVisitor,实现 ExprVisitor 接口就行。
这种写法的代价是 Visit 函数里的 type switch 必须列举所有类型。新增节点类型时,除了实现 Expr 接口和更新 ExprVisitor,还得改 Visit 函数。Go 的编译器不会帮你检查 type switch 是否穷举,所以遗漏某个类型只会在运行时 panic。实际项目中可以用 stringer 之类的代码生成工具来缓解。
5.2 TypeScript 实现
// 用可辨识联合定义 AST 节点type Expr = | { type: 'number'; value: number } | { type: 'add'; left: Expr; right: Expr } | { type: 'multiply'; left: Expr; right: Expr };
// 访问者接口:每种节点类型一个方法interface ExprVisitor<T> { visitNumber: (value: number) => T; visitAdd: (left: Expr, right: Expr) => T; visitMultiply: (left: Expr, right: Expr) => T;}
// 分发函数:根据节点类型调用对应方法function visit<T>(expr: Expr, v: ExprVisitor<T>): T { switch (expr.type) { case 'number': return v.visitNumber(expr.value); case 'add': return v.visitAdd(expr.left, expr.right); case 'multiply': return v.visitMultiply(expr.left, expr.right); }}
// 求值访问者——零修改 AST 结构const evalVisitor: ExprVisitor<number> = { visitNumber: (n) => n, visitAdd: (l, r) => visit(l, evalVisitor) + visit(r, evalVisitor), visitMultiply: (l, r) => visit(l, evalVisitor) * visit(r, evalVisitor),};
// 打印访问者——新增操作,不改已有代码const printVisitor: ExprVisitor<string> = { visitNumber: (n) => String(n), visitAdd: (l, r) => `(${visit(l, printVisitor)} + ${visit(r, printVisitor)})`, visitMultiply: (l, r) => `(${visit(l, printVisitor)} * ${visit(r, printVisitor)})`,};TypeScript 用可辨识联合(Discriminated Union)定义节点,type 字段作为标签驱动分发。访问者是一个满足 ExprVisitor 接口的对象,每种节点一个方法。TypeScript 的 switch 做穷举检查——如果你开启了 noImplicitReturns,遗漏一个 case 编译器就会报错,这比 Go 的运行时 panic 安全得多。
printVisitor 返回拼接后的字符串,和 evalVisitor 返回数字使用不同的泛型参数。这也展示了泛型访问者的一个好处:不同操作可以有完全不同的返回类型,不需要用 any 或类型断言来绕过。
六、生产验证
-
LLVM —— InstVisitor.h#L45-L107
InstVisitor<SubClass, RetTy>是对所有 LLVM IR 指令类型的 CRTP 访问者。通过visit(Instruction &I)按操作码分发到visitAdd、visitBr、visitCall等。用于指令计数、常量折叠和优化遍历。CRTP(Curiously Recurring Template Pattern)在这里的作用是让子类可以覆写基类的分发逻辑。
InstVisitor的visit方法会先尝试调用子类的visitXXX,如果没有覆写,则回退到visitInstruction这个通用处理。这种「有具体处理就用具体的,没有就用通用兜底」的设计让新写的访问者不需要处理所有指令类型——只关心自己要处理的几种,其余的走默认路径。LLVM IR 有上百种指令,如果每个访问者都必须实现所有visitXXX,那和不用访问者也没什么区别了。 -
Vue.js —— transforms/vIf.ts#L35-L60
transformIf是遍历模板 AST 的NodeTransform访问者。编译器的traverseNode将每个 AST 节点分发到注册的转换访问者。每个转换(v-if、v-for、v-bind)都是独立访问者。 -
Babel —— JavaScript AST 转换使用基于访问者的插件架构。每个 Babel 插件导出一个
visitor对象,键是 AST 节点类型(如FunctionDeclaration、CallExpression),值是对应的处理函数。Babel 的遍历器负责深度优先遍历整棵 AST,每进入一个节点就调用匹配的访问者方法。这种架构让插件开发者只需要关注自己要处理的节点类型,不用操心遍历逻辑——典型的内部访问者风格。一个 Babel 插件可能只处理两三种节点类型,其他上百种节点类型全部走默认遍历,开发成本极低。这也解释了为什么 Babel 的生态如此繁荣:写插件几乎不需要理解编译器的其他部分。
七、小结
何时使用:
- 编译器和解释器——对 AST 进行求值、类型检查、优化遍历,操作多而节点类型稳定。比如一个 SQL 解析器有 30 种 AST 节点,但分析遍历(语义分析、查询重写、执行计划生成)有十几种,每加一种遍历只写一个访问者
- 代码检查和格式化——遍历代码 AST 以检测模式或重新格式化,新增规则不影响已有规则。ESLint 的规则就是访问者,每条规则只关心特定的 AST 节点
- 序列化——遍历对象图以生成 JSON、XML 或二进制格式,新增格式只需新访问者
- UI 框架——遍历组件树进行渲染、diff 或无障碍检查
何时不用:
- 节点类型频繁变化——如果经常添加节点类型,每个访问者都必须更新,维护成本反而更高。表达式问题的硬币有两面:访问者解决的是「操作多变」的一面,如果你面对的是「类型多变」,访问者只会帮倒忙
- 简单的单遍逻辑——只需要一个操作时,简单的递归函数比完整访问者更清晰。一个只有
eval()的解释器,直接写递归求值就好,不必引入访问者的分发机制 - 平面数据——访问者在树/图结构上发挥价值,对于平面列表用简单循环就够了。对
List<Invoice>做invoice.accept(visitor)不如直接invoices.forEach(process)
八、参考资料
- Design Patterns: Elements of Reusable Object-Oriented Software - GoF 原著,访问者模式的经典定义
- LLVM Programmer’s Manual - InstVisitor - LLVM IR 指令访问者的设计与使用
- Babel Plugin Handbook - 基于 Visitor 模式的 Babel 插件开发指南
- Expression Problem - 访问者模式试图解决的核心问题
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






