访问者模式:当代码结构遇上“不速之客”
访问者模式,这个名字听起来有点“入侵”的味道。没错,它确实像是一个外来者,闯进你稳定的对象结构里,干点“额外”的事。
我最早接触这个模式,是在做编译器前端的时候。那时候要遍历抽象语法树(AST),做类型检查、代码生成、优化……每个节点都要处理,但处理逻辑又各不相同。如果每个节点都自己实现这些功能,那代码就乱成一锅粥了。访问者模式,就是专门解决这类问题的。
核心思想:把操作从对象中抽离出来
你想想看,一个稳定的对象结构(比如 AST 节点),经常需要增加新的操作(比如打印、求值、类型检查)。如果每次加操作都去改节点类,那节点类会越来越臃肿,而且违反了“开闭原则”。
访问者模式的做法是:把操作封装到独立的“访问者”对象中。对象结构本身只提供一个“接待”方法(accept),让访问者进来执行操作。
核心角色:
- Visitor(访问者接口):声明一系列 visit 方法,每个方法对应一种具体元素类型。
- ConcreteVisitor(具体访问者):实现 visit 方法,定义对每种元素的具体操作。
- Element(元素接口):声明 accept 方法,接收一个访问者对象。
- ConcreteElement(具体元素):实现 accept 方法,通常就是调用 visitor.visit(this)。
- ObjectStructure(对象结构):管理元素集合,提供遍历机制。
一个简单的例子:表达式求值与打印
假设我们有一个简单的算术表达式 AST,包含数字、加法、乘法三种节点。我们要实现两种操作:计算表达式的值,以及打印表达式。
先定义元素接口和具体元素:
// 元素接口
interface Expression {
void accept(Visitor visitor);
}
// 数字节点
class NumberExpr implements Expression {
int value;
NumberExpr(int value) { this.value = value; }
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
// 加法节点
class AddExpr implements Expression {
Expression left, right;
AddExpr(Expression left, Expression right) {
this.left = left; this.right = right;
}
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
// 乘法节点
class MulExpr implements Expression {
Expression left, right;
MulExpr(Expression left, Expression right) {
this.left = left; this.right = right;
}
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
然后定义访问者接口和两个具体访问者:
// 访问者接口
interface Visitor {
void visit(NumberExpr expr);
void visit(AddExpr expr);
void visit(MulExpr expr);
}
// 求值访问者
class EvalVisitor implements Visitor {
int result;
public void visit(NumberExpr expr) {
result = expr.value;
}
public void visit(AddExpr expr) {
expr.left.accept(this);
int leftVal = result;
expr.right.accept(this);
int rightVal = result;
result = leftVal + rightVal;
}
public void visit(MulExpr expr) {
expr.left.accept(this);
int leftVal = result;
expr.right.accept(this);
int rightVal = result;
result = leftVal * rightVal;
}
}
// 打印访问者
class PrintVisitor implements Visitor {
StringBuilder sb = new StringBuilder();
public void visit(NumberExpr expr) {
sb.append(expr.value);
}
public void visit(AddExpr expr) {
sb.append("(");
expr.left.accept(this);
sb.append(" + ");
expr.right.accept(this);
sb.append(")");
}
public void visit(MulExpr expr) {
sb.append("(");
expr.left.accept(this);
sb.append(" * ");
expr.right.accept(this);
sb.append(")");
}
}
客户端代码:
// 构建表达式: (1 + 2) * 3
Expression expr = new MulExpr(
new AddExpr(new NumberExpr(1), new NumberExpr(2)),
new NumberExpr(3)
);
EvalVisitor eval = new EvalVisitor();
expr.accept(eval);
System.out.println("结果: " + eval.result); // 输出 9
PrintVisitor print = new PrintVisitor();
expr.accept(print);
System.out.println("表达式: " + print.sb.toString()); // 输出 ((1 + 2) * 3)
个人经验: 我在实际项目中,经常用访问者模式来做“多态遍历”。比如一个 UI 组件树,每个组件都要支持“渲染”、“事件处理”、“序列化”等操作。用访问者模式,这些操作可以独立开发和测试,互不干扰。
编译器 AST 遍历:访问者模式的经典应用
编译器前端处理源代码时,会生成一棵抽象语法树。这棵树的结构是固定的(比如 IfStatement、WhileStatement、BinaryExpression 等节点),但后续的语义分析、优化、代码生成等阶段,都需要遍历这棵树并执行不同的操作。
访问者模式在这里简直是天作之合。每个编译阶段就是一个具体访问者:
- 类型检查访问者:检查每个节点的类型是否匹配。
- 常量折叠访问者:在编译期计算常量表达式。
- 代码生成访问者:为每个节点生成目标代码。
- 死代码消除访问者:标记并移除不可达代码。
我记得有一次,我们需要给一个已有编译器增加“安全审计”功能。如果不用访问者模式,就得修改所有 AST 节点类,加入审计逻辑。那工作量……想想都头疼。但因为我们一开始就用了访问者模式,只需要新增一个 SecurityAuditVisitor,然后遍历一遍 AST 就行了。前后只花了两天。
避坑指南: 我曾经在一个项目中,把访问者模式用“过”了。当时每个节点都有十几个访问者,而且访问者之间还有依赖关系。结果每次修改节点结构,都要同步更新所有访问者,维护成本极高。
我的建议是:访问者模式适合“操作频繁变化,但结构相对稳定”的场景。如果节点结构本身也在频繁变化,那访问者模式反而会成为负担。
双分派机制:访问者模式的核心
访问者模式之所以能工作,依赖的是“双分派”机制。什么意思呢?
普通的单分派,是根据接收者的实际类型来调用方法。比如 obj.method(),调用哪个 method 取决于 obj 的实际类型。
双分派则是在单分派的基础上,再根据参数的实际类型进行一次分派。在访问者模式中:
- 首先,元素调用
visitor.visit(this),这里的this是元素的实际类型(比如NumberExpr)。 - 然后,JVM 根据
visitor的实际类型和this的实际类型,找到最匹配的visit方法。
说白了,就是“两次动态绑定”。第一次绑定访问者,第二次绑定元素。这样就能实现“一个操作,多种行为”。
访问者模式的优缺点
| 优点 | 缺点 |
|---|---|
| 符合开闭原则:增加新操作容易,只需新增访问者 | 增加新元素困难:需要修改所有访问者接口和实现 |
| 将相关操作集中到一个访问者中,提高内聚性 | 访问者需要知道元素内部细节,破坏了封装 |
| 便于积累和复用:同一访问者可用于不同对象结构 | 代码量增加:每个元素都要实现 accept 方法 |
| 遍历逻辑与业务逻辑分离,职责清晰 | 双分派机制对新手不太友好,理解成本高 |
什么时候用访问者模式?
我个人总结了几条经验:
- 对象结构稳定,但操作频繁变化:比如 AST、UI 组件树、文档对象模型。
- 需要对一个对象结构执行多种不相关的操作:比如打印、导出、校验、统计。
- 操作需要访问元素内部状态,但又不希望污染元素类:访问者可以访问元素的公开接口,但不需要修改元素本身。
- 操作需要跨多个元素类型协作:比如类型检查需要同时知道变量声明和使用处的类型。
一句话总结: 访问者模式,就是把“操作”从“对象”中解放出来。它让对象结构保持稳定,让操作自由生长。但前提是——对象结构得真的稳定。
SVG 结构图:访问者模式的核心流程
这张图展示了访问者模式的核心结构。左边是访问者体系,右边是元素体系,中间通过 accept 方法连接。对象结构在最右侧,负责管理元素集合。整个模式的关键,就是那个红色的虚线箭头——它代表了双分派的调用链。
我的习惯: 在设计访问者模式时,我会先确定“哪些操作是可能变化的”,然后为每个操作创建一个访问者接口。如果操作之间有公共逻辑,我会用抽象类或组合的方式复用代码。另外,我建议给每个访问者起一个“动词+Visitor”的名字,比如 TypeCheckVisitor、CodeGenVisitor,这样一看就知道它负责什么。
访问者模式,说白了就是“把操作外包”。对象结构只负责“接待”,具体做什么,由访问者决定。这种分离,让代码的扩展性大大提升。但也要记住,它并不是万能的。如果你的对象结构本身就不稳定,那还是老老实实用策略模式或者模板方法吧。
公众号:蓝海资料掘金营,微信 deep3321