23_访问者模式 Visitor Pattern
1. 基本信息
中文名称: 访问者模式
英文名称: Visitor Pattern
模式类型: 行为型设计模式
Rust 中常见实现方式: trait Visitor、accept() 方法、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、树形结构,通常需要使用 Box、Rc、Arc 等来表达递归和共享关系。
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 更简单直接。只有当数据结构比较稳定、需要对其执行多种不同操作时,访问者模式才更有价值。