Skip to content

模式:访问者 / 树遍历器 (Visitor / Tree Walker)

高级

一句话

将树遍历与操作解耦,通过分发到类型特定的回调——使新操作无需修改树结构。

互动演示

现实类比

一个巡查不同类型房间的建筑检查员。检查员对每种房间有不同的检查清单——厨房和浴室的检查方式不同。房间不需要知道怎么检查自己;它们只需开门,让检查员根据房间类型做正确的事。

核心思想

访问者模式将"如何遍历树"与"在每个节点做什么"分离。树定义一个 accept(visitor) 方法,分发到访问者的类型特定回调(如 visitAddvisitMultiply)。要添加新操作(求值、打印、优化),只需创建新的访问者——无需修改树节点类。

text
  AST:         +               Eval Visitor:
              / \                visitNumber(n) → n
             *   4              visitAdd(l, r)  → visit(l) + visit(r)
            / \                 visitMul(l, r)  → visit(l) * visit(r)
           2   3

  visit(tree, evalVisitor):
    visitAdd(
      visitMul(visitNumber(2), visitNumber(3)),   → 6
      visitNumber(4)                               → 4
    ) → 10

  Print Visitor (new operation, zero tree changes):
    visitAdd(l, r) → "(" + visit(l) + " + " + visit(r) + ")"
    → "((2 * 3) + 4)"
属性
添加操作容易——编写新的访问者
添加节点类型困难——必须更新所有访问者(表达式问题)
遍历控制由访问者或 accept() 方法控制
模式分类行为型——与 Strategy 和 Iterator 相关

动手试试 — 选择访问者类型并遍历 AST,观察每个节点被访问:

生产验证

项目源码用途
LLVMInstVisitor.h#L45-L107InstVisitor<SubClass, RetTy>(L45)是对所有 LLVM IR 指令类型的 CRTP 访问者。通过 visit(Instruction &I) 按操作码分发到 visitAddvisitBrvisitCall 等。用于指令计数、常量折叠和优化遍历。默认行为委托给父类访问者。
Vue.jstransforms/vIf.ts#L35-L60transformIf 是一个遍历模板 AST 的 NodeTransform 访问者。编译器的 traverseNode(在 transform.ts 中)将每个 AST 节点分发到注册的转换访问者。每个转换(v-if、v-for、v-bind)都是一个访问者,在不修改 AST 结构代码的情况下重写节点。

实现

typescript
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);
  }
}

// Eval visitor
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),
};
rust
enum Expr {
    Number(f64),
    Add(Box<Expr>, Box<Expr>),
    Multiply(Box<Expr>, Box<Expr>),
}

trait ExprVisitor {
    type Output;
    fn visit_number(&mut self, value: f64) -> Self::Output;
    fn visit_add(&mut self, left: &Expr, right: &Expr) -> Self::Output;
    fn visit_multiply(&mut self, left: &Expr, right: &Expr) -> Self::Output;
}

fn visit<V: ExprVisitor>(expr: &Expr, v: &mut V) -> V::Output {
    match expr {
        Expr::Number(n) => v.visit_number(*n),
        Expr::Add(l, r) => v.visit_add(l, r),
        Expr::Multiply(l, r) => v.visit_multiply(l, r),
    }
}

struct Evaluator;
impl ExprVisitor for Evaluator {
    type Output = f64;
    fn visit_number(&mut self, value: f64) -> f64 { value }
    fn visit_add(&mut self, left: &Expr, right: &Expr) -> f64 {
        visit(left, self) + visit(right, self)
    }
    fn visit_multiply(&mut self, left: &Expr, right: &Expr) -> f64 {
        visit(left, self) * visit(right, self)
    }
}
go
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()    {}

func Eval(e Expr) float64 {
	switch n := e.(type) {
	case NumberExpr:
		return n.Value
	case AddExpr:
		return Eval(n.Left) + Eval(n.Right)
	case MulExpr:
		return Eval(n.Left) * Eval(n.Right)
	default:
		panic("unknown node")
	}
}
python
from dataclasses import dataclass
from typing import Protocol, TypeVar

T = TypeVar("T")

@dataclass
class Number:
    value: float

@dataclass
class Add:
    left: "Expr"
    right: "Expr"

@dataclass
class Multiply:
    left: "Expr"
    right: "Expr"

Expr = Number | Add | Multiply

def visit(expr: Expr, visitor: dict) -> float:
    if isinstance(expr, Number):
        return visitor["number"](expr.value)
    elif isinstance(expr, Add):
        return visitor["add"](expr.left, expr.right)
    elif isinstance(expr, Multiply):
        return visitor["multiply"](expr.left, expr.right)
    raise TypeError(f"Unknown expr: {expr}")

eval_visitor = {
    "number": lambda v: v,
    "add": lambda l, r: visit(l, eval_visitor) + visit(r, eval_visitor),
    "multiply": lambda l, r: visit(l, eval_visitor) * visit(r, eval_visitor),
}

练习

难度练习文件
基础数学表达式的 AST 访问者(求值 + 打印)exercises/typescript/visitor/01-basic.test.ts
进阶重写树的转换访问者(常量折叠)exercises/typescript/visitor/02-intermediate.test.ts

运行练习:pnpm test:exercises(TypeScript)· cargo test(Rust)· go test ./...(Go)· pytest(Python)

练习文件: Rust exercises/rust/src/visitor/mod.rs · Go exercises/go/visitor/visitor_test.go · Python exercises/python/visitor/test_visitor.py

何时使用

  • 编译器和解释器 — 对 AST 进行求值、类型检查、优化遍历
  • 代码检查和格式化 — 遍历代码 AST 以检测模式或重新格式化
  • 序列化 — 遍历对象图以生成 JSON、XML 或二进制格式
  • UI 框架 — 遍历组件树进行渲染、diff 或无障碍检查
  • 查询规划器 — 遍历和优化 SQL 查询计划

何时不用

  • 节点类型频繁变化 — 如果经常添加节点类型,每个访问者都必须更新(表达式问题)
  • 简单的单遍逻辑 — 如果只需要一个操作,简单的递归函数比完整访问者更清晰
  • 平面数据 — 访问者在树/图结构上发光;对于平面列表,简单循环就够了

更多生产案例

  • Babel — JavaScript AST 转换使用基于访问者的插件架构
  • ESLint — lint 规则是遍历 AST 并报告违规的访问者
  • Roslyn — C# 编译器的语法树访问者用于分析和代码生成
  • rustc — HIR 和 MIR 访问者 trait 用于借用检查、优化和代码生成

相关模式

模式关系
迭代器 / 惰性求值 (Iterator)两者都遍历结构——访问者分发回调,迭代器产出元素
虚函数表 / 操作分发 (Vtable / Ops Dispatch)访问者的分发表在概念上是按节点类型索引的虚函数表
依赖图 (Dependency Graph)访问者遍历依赖图以正确顺序处理节点
标签联合体 (Tagged Union / Variant)访问者分发匹配标签联合的类型标签
状态机 (State Machine)访问者可以遍历状态机节点;状态机可以驱动访问者分发

挑战题

Q1: 你正在构建一个有 20 种 AST 节点类型和 15 个优化遍历的编译器。应该用访问者还是 switch 语句?

答案: 访问者。有 15 个遍历(操作)和 20 种节点类型,你需要 15 个单独的 switch 语句,每个处理 20 种情况。用访问者,每个遍历是一个自包含的访问者类。

如果添加新遍历,你写一个访问者。如果用 switch,你添加一个有 20 种情况的函数。访问者让每个遍历的逻辑保持内聚。然而,如果添加新节点类型,你必须更新所有 15 个访问者——这是经典的权衡。

Q2: Babel 插件是访问者。如果两个插件访问相同的节点类型并冲突会怎样?

答案: 插件顺序很重要。Babel 按配置中列出的顺序运行访问者。如果插件 A 转换了插件 B 也期望的节点,第一个插件的输出变成第二个插件的输入。

这可能导致微妙的 bug:插件 A 将 import 重写为 require,然后插件 B(期望 import)不匹配。解决方案:(1) 记录插件排序要求,(2) 使用访问者优先级,(3) 在不同遍历中运行冲突的插件。Babel 的 pre/post 钩子有助于协调。

Q3: 练习中的转换访问者创建新节点而不是修改。为什么不可变性在这里很重要?

答案: 不可变转换更安全,因为原始树被保留。这使得以下操作成为可能:(1) 对同一输入运行多个转换,(2) 比较前后以便调试,(3) 如果转换失败则回滚,(4) 对同一棵树进行并行转换。

可变访问者更快(无分配)但危险——一个访问者的修改可能破坏正在读取同一棵树的另一个访问者。LLVM 为性能使用可变访问者,但 Vue 的编译器为安全使用不可变转换。

Q4: LLVM 的 InstVisitor 有一个调用父指令类访问者的默认 case。这为什么有用?

答案: 它通过类层次结构实现回退行为。如果你没有覆盖 visitAdd,它回退到 visitBinaryOperator,然后到 visitInstruction,然后到空操作。

这意味着你只需要覆盖你关心的情况。指令计数器可以只覆盖 visitInstruction 来计数所有指令。二元操作优化器可以只覆盖 visitBinaryOperator 来一次处理 add、sub、mul、div,而不用逐一列出。这是应用于访问者的"模板方法"模式。

基于 MIT 许可证发布。