Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

23_访问者模式 Visitor Pattern

1. 基本信息

中文名称: 访问者模式

英文名称: Visitor Pattern

模式类型: 行为型设计模式

Rust 中常见实现方式: trait Visitoraccept() 方法、enum + match、AST 遍历、递归访问

访问者模式的核心是:在不修改已有数据结构的前提下,为数据结构增加新的操作。

在 Rust 中,访问者模式经常出现在编译器、解释器、语法树 AST、文档树、UI 组件树等场景中。

不过需要注意:Rust 有强大的 enum + match,所以很多传统 OOP 中必须使用访问者模式的场景,在 Rust 中可以用更直接的方式表达。

代码示例


2. 模式核心思想

访问者模式的核心思想是:

把“数据结构”和“作用在数据结构上的操作”分离。

也就是说,数据结构本身只负责保存数据,而具体操作由访问者对象完成。

例如一棵表达式树:

数字节点
加法节点
乘法节点
变量节点

可以对它做很多操作:

求值
打印
类型检查
代码生成
统计节点数量
优化表达式

如果把这些操作全部写在节点结构内部,节点代码会越来越臃肿。访问者模式就是把这些操作抽离出来,让不同访问者负责不同任务。


3. 这个模式解决什么问题

访问者模式主要解决的是:当一个稳定的数据结构需要不断增加新操作时,如何避免频繁修改数据结构本身。

在实际开发中,可能有这样的情况:

1. 数据结构相对稳定。
2. 对数据结构的操作经常变化。
3. 不希望每新增一个操作就修改所有节点类型。
4. 希望把不同操作逻辑拆到不同对象中。

例如编译器中的 AST 结构可能比较稳定:

Number
BinaryExpr
Variable
FunctionCall
IfExpr

但作用在 AST 上的操作很多:

语法检查
类型检查
解释执行
代码生成
格式化输出
死代码分析
优化

访问者模式可以把这些操作独立成不同访问者。


4. 不使用这个模式会怎样

如果不使用访问者模式,可能出现以下问题:

1. 数据结构内部塞满各种操作方法。
2. 新增操作时,需要修改多个节点类型。
3. 不同操作逻辑混在一起,职责不清晰。
4. 代码可维护性下降。
5. 对复杂树形结构的遍历逻辑重复出现。

例如表达式节点中同时写:

eval()
print()
type_check()
generate_code()
count_nodes()
optimize()

短期看很方便,但随着操作越来越多,节点类型会变得越来越臃肿。

访问者模式的作用就是:让节点结构保持相对干净,把操作移到访问者中。


5. 传统面向对象中的实现思路

在传统 OOP 中,访问者模式通常包含几个角色:

1. Visitor:访问者接口,定义访问不同节点的方法。
2. ConcreteVisitor:具体访问者,实现具体操作。
3. Element:被访问元素接口,定义 accept(visitor) 方法。
4. ConcreteElement:具体元素,实现 accept 方法。

典型结构是:

Visitor
 ├── visit_number(node)
 ├── visit_add(node)
 └── visit_mul(node)

Element
 └── accept(visitor)

Number
 └── accept(visitor) { visitor.visit_number(this) }

Add
 └── accept(visitor) { visitor.visit_add(this) }

调用流程是:

节点.accept(visitor)
→ 节点内部调用 visitor.visit_xxx(self)
→ 访问者执行对应操作

这就是所谓的“双分派”思想:

具体执行哪个 visit 方法,
既取决于访问者类型,
也取决于被访问节点类型。

6. Rust 中的实现思路

6.1 Rust 中通常怎么实现

Rust 中访问者模式有两种常见写法。

第一种是传统访问者写法:trait Visitor + accept()

例如:

#![allow(unused)]
fn main() {
trait Visitor {
    fn visit_number(&mut self, value: i32);
    fn visit_add(&mut self, left: &Expr, right: &Expr);
}

enum Expr {
    Number(i32),
    Add(Box<Expr>, Box<Expr>),
}

impl Expr {
    fn accept<V: Visitor>(&self, visitor: &mut V) {
        match self {
            Expr::Number(value) => {
                visitor.visit_number(*value);
            }
            Expr::Add(left, right) => {
                visitor.visit_add(left, right);
            }
        }
    }
}
}

可以定义一个统计节点数量的访问者:

struct CountVisitor {
    count: usize,
}

impl Visitor for CountVisitor {
    fn visit_number(&mut self, _value: i32) {
        self.count += 1;
    }

    fn visit_add(&mut self, left: &Expr, right: &Expr) {
        self.count += 1;
        left.accept(self);
        right.accept(self);
    }
}

fn main() {
    let expr = Expr::Add(
        Box::new(Expr::Number(1)),
        Box::new(Expr::Number(2)),
    );

    let mut visitor = CountVisitor { count: 0 };
    expr.accept(&mut visitor);

    println!("节点数量:{}", visitor.count);
}

这个例子中:

Expr 是被访问的数据结构
CountVisitor 是访问者
accept() 负责分发访问逻辑

第二种是 Rust 更常见的简化写法:enum + match

例如:

#![allow(unused)]
fn main() {
enum Expr {
    Number(i32),
    Add(Box<Expr>, Box<Expr>),
}

fn count_nodes(expr: &Expr) -> usize {
    match expr {
        Expr::Number(_) => 1,
        Expr::Add(left, right) => {
            1 + count_nodes(left) + count_nodes(right)
        }
    }
}
}

这种写法没有显式的 Visitor trait,但同样实现了“访问树结构并执行操作”的目的。

在 Rust 中,如果数据结构是 enum,并且操作数量不多,那么 match 往往比传统访问者更简单。


6.2 和传统 OOP 写法相比有什么不同

和传统面向对象语言相比,Rust 的访问者模式有几个明显特点。

第一,Rust 中很多访问者模式可以被 enum + match 替代。 传统 OOP 中因为缺少代数数据类型,常常需要访问者模式来处理不同节点类型;Rust 可以直接用 match 对枚举变体进行分发。

第二,Rust 不依赖继承体系。 传统访问者模式依赖接口、抽象类和多态。Rust 中可以使用 trait,但不是基于类继承,而是基于 trait 实现。

第三,Rust 的 match 具有穷尽性检查。 如果 Expr 新增一个变体,编译器会提醒相关 match 没有覆盖完整。这一点比传统访问者模式更加直接。

第四,Rust 中访问者可以更灵活地控制借用方式。 访问者可以使用:

&self
&mut self
self

来决定是只读访问、可变访问,还是消费式访问。

第五,Rust 中需要考虑所有权和递归结构。 对于 AST、树形结构,通常需要使用 BoxRcArc 等来表达递归和共享关系。


6.3 Rust 中是否有更自然的替代写法

在 Rust 中选择访问者模式时,可以这样判断。

如果数据结构是 enum,并且操作数量较少:

优先使用 enum + match

如果数据结构稳定,但操作很多:

可以使用访问者模式,把不同操作拆成不同 Visitor

如果节点类型经常增加,但操作比较固定:

enum + match 可能更合适

如果操作经常增加,但节点结构比较稳定:

Visitor 更合适

这就是访问者模式里经典的权衡:

新增操作容易,新增节点困难。

enum + match 的权衡通常是:

新增节点时编译器提示修改所有 match;
新增操作时只需要新增函数。

所以 Rust 中不需要机械照搬访问者模式,而要看数据结构和操作哪一边更稳定。


7. Rust 中涉及的语言特性

访问者模式在 Rust 中主要涉及以下语言特性:

1. trait
2. enum
3. match
4. Box
5. 递归数据结构
6. 泛型
7. dyn Trait
8. 所有权和借用
9. 可变引用 &mut

这些特性分别解决的问题是:

trait:定义访问者接口
enum:表达不同节点类型
match:根据节点类型分发操作
Box:构造递归树结构
泛型:静态分发访问者
dyn Trait:动态分发访问者
&mut self:让访问者保存或更新访问过程中的状态

其中最核心的是:

trait Visitor
accept()
enum + match
递归遍历

8. 性能与工程代价

访问者模式的性能取决于具体实现方式。

如果使用 enum + match

1. 分发成本较低。
2. 没有动态分发。
3. 编译器容易优化。
4. 适合固定结构的 AST 或树。

如果使用泛型访问者:

1. 静态分发,性能较好。
2. 编译器可以内联部分访问逻辑。
3. 但泛型类型可能让编译产物变大。

如果使用 Box<dyn Visitor>

1. 支持运行时选择访问者。
2. 有动态分发开销。
3. 可能涉及堆分配。
4. 灵活性更强,但性能略低。

工程代价方面,访问者模式会增加结构复杂度:

1. 需要定义 Visitor trait。
2. 每个节点都要支持 accept。
3. 新增节点时,需要修改 Visitor trait 和所有具体 Visitor。
4. 对简单数据结构来说可能过度设计。

一句话总结:

访问者模式适合复杂结构和多种操作,但对简单场景来说,Rust 的 enum + match 更轻量。

9. 典型应用场景

访问者模式适合用于复杂数据结构,尤其是树形结构。

常见应用包括:

1. 编译器 AST 遍历
2. 解释器语法树处理
3. 表达式树求值
4. 代码格式化工具
5. 静态分析工具
6. UI 组件树遍历
7. 文档结构遍历
8. 文件系统树遍历
9. JSON / XML 节点处理

在 Rust 项目中,访问者模式可能出现在:

编译器前端
DSL 解释器
语法树分析器
代码生成工具
规则引擎
文档转换器

例如对同一棵 AST 执行:

打印 AST
统计节点
类型检查
解释执行
代码生成

这类场景就适合考虑访问者模式。


10. 和相似模式的区别

访问者模式容易和解释器模式、组合模式、迭代器模式、策略模式混淆。

10.1 访问者模式和解释器模式

二者都经常用于 AST,但关注点不同。

解释器模式:关注如何解释执行表达式。
访问者模式:关注如何在不修改数据结构的情况下增加新的操作。

如果 AST 的主要任务是求值,那么解释器模式就够了。

如果同一棵 AST 需要支持很多操作,例如:

求值
打印
优化
类型检查
代码生成

访问者模式更合适。


10.2 访问者模式和组合模式

组合模式关注的是树形结构的组织方式。

访问者模式关注的是如何对树形结构执行操作。

组合模式:整体和部分如何统一表示。
访问者模式:如何遍历这些节点并执行不同操作。

例如:

文件夹和文件组成文件树:组合模式
统计文件树大小、打印目录结构:访问者模式

组合模式负责“结构”,访问者模式负责“操作”。


10.3 访问者模式和迭代器模式

迭代器模式关注统一遍历元素。

访问者模式关注根据元素类型执行不同操作。

迭代器模式:怎么一个一个访问元素。
访问者模式:访问到不同类型元素后做什么操作。

例如:

遍历文件列表:迭代器模式
根据文件、文件夹、链接类型执行不同统计逻辑:访问者模式

10.4 访问者模式和策略模式

策略模式关注替换算法。

访问者模式关注对复杂数据结构增加操作。

策略模式:选择哪种处理算法。
访问者模式:对不同节点类型执行对应访问逻辑。

例如:

选择不同排序算法:策略模式
对 AST 的不同节点执行类型检查:访问者模式

11. 使用该模式的优点

访问者模式的优点主要有:

1. 可以在不修改数据结构的情况下增加新操作。
2. 适合复杂树形结构或 AST。
3. 不同操作可以拆分到不同 Visitor 中。
4. 可以避免节点类型内部塞入大量无关方法。
5. 便于对同一结构执行多种处理逻辑。
6. 在 Rust 中可以结合 enum + match 或 trait 实现。

从工程角度看,访问者模式的价值在于:

让复杂数据结构保持稳定,
把不断变化的操作逻辑移到访问者中。

12. 使用该模式的代价

访问者模式的代价主要包括:

1. 结构复杂度较高。
2. 新增节点类型时,需要修改 Visitor trait。
3. 所有具体 Visitor 都可能需要补充对应方法。
4. accept + visit 的调用关系对初学者不直观。
5. 在 Rust 中,很多场景直接用 match 更简单。
6. 如果使用 dyn Trait,可能有动态分发开销。

访问者模式最大的缺点是:

新增操作容易,新增节点类型麻烦。

比如新增一个 Div 表达式节点,可能需要修改:

Expr 定义
Visitor trait
PrintVisitor
EvalVisitor
TypeCheckVisitor
CodeGenVisitor

所以访问者模式适合“节点类型比较稳定,操作经常增加”的场景。


13. 什么时候不应该使用

以下情况不适合使用访问者模式:

1. 数据结构很简单。
2. 操作数量很少。
3. 节点类型经常变化。
4. enum + match 已经足够清晰。
5. 不需要把操作从数据结构中分离。
6. 为了套设计模式而强行增加 Visitor trait。

在 Rust 中可以遵循一个简单原则:

简单结构:enum + match。
复杂结构 + 多种操作:考虑访问者模式。
节点经常变:慎用访问者模式。
操作经常变:访问者模式更合适。

14. 一个简单例子思路

场景: 表达式树访问

表达式类型包括:

Number
Add
Mul

需要支持多个操作:

1. 统计节点数量
2. 打印表达式结构
3. 计算表达式结果

处理流程可以设计为:

1. 定义 enum Expr 表达语法树。
2. 定义 Visitor trait。
3. Expr 实现 accept(visitor)。
4. CountVisitor 负责统计节点。
5. PrintVisitor 负责打印表达式。
6. EvalVisitor 负责计算结果。

适合练习的 Rust 特性:

enum
match
trait
Box
递归结构
&mut self
泛型访问者

15. 总结一句话

访问者模式的本质是:

把作用在数据结构上的操作抽离出来,使数据结构保持稳定,同时可以独立增加新的操作。

在 Rust 中,访问者模式可以通过 trait Visitor + accept() 实现,但很多情况下 enum + match 更简单直接。只有当数据结构比较稳定、需要对其执行多种不同操作时,访问者模式才更有价值。