访问者模式:编译器中的语法树遍历
说实话,访问者模式是我当年学设计模式时,觉得最绕的一个。它不像单例、工厂那样直观,也不像观察者那样常见。但后来我在做代码分析工具时,才真正体会到它的威力。
先看定义:表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。
嗯,这句话有点绕。说白了就是:你有一堆对象,它们结构固定,但你想给它们加一堆新操作,又不想改它们的代码。 这时候,访问者模式就派上用场了。
为什么需要访问者模式?
我举个例子你就明白了。假设你有一个公司,里面有财务部、人事部、技术部。每个部门都有自己的数据结构。现在,老板说:
- 「给我出一份各部门的薪资报表」
- 「再出一份各部门的设备资产报表」
- 「再出一份各部门的项目进度报表」
你想想看,如果每个部门类里都加一个 generateSalaryReport() 方法,再加一个 generateAssetReport() 方法……那部门类会膨胀成什么样?而且每次新增报表类型,你都得改所有部门类。这违反了开闭原则。
访问者模式就是来解决这个问题的。它把「操作」从「对象」中抽离出来。部门类只负责自己的数据,报表逻辑交给访问者去处理。
核心结构
访问者模式有四个角色:
| 角色 | 说明 |
|---|---|
| Visitor(访问者接口) | 声明对每种元素类型的访问方法 |
| ConcreteVisitor(具体访问者) | 实现具体的操作逻辑,比如薪资报表、资产报表 |
| Element(元素接口) | 声明 accept 方法,接收访问者 |
| ConcreteElement(具体元素) | 实现 accept 方法,将自身传给访问者 |
这里有个关键点:accept 方法里会调用 visitor.visit(this)。这就是所谓的「双重分派」——先由元素决定调用哪个访问者,再由访问者决定对哪种元素做什么操作。
SVG 结构图
业务场景一:公司部门报表
先看一个简单的例子。我们有三个部门,每个部门都有自己的数据:
// 元素接口
public interface Department {
void accept(Visitor visitor);
}
// 财务部
public class FinanceDepartment implements Department {
private double salaryTotal = 500000;
private double assetTotal = 2000000;
public double getSalaryTotal() { return salaryTotal; }
public double getAssetTotal() { return assetTotal; }
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
// 人事部
public class HRDepartment implements Department {
private int employeeCount = 150;
private double trainingCost = 30000;
public int getEmployeeCount() { return employeeCount; }
public double getTrainingCost() { return trainingCost; }
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
// 技术部
public class TechDepartment implements Department {
private int projectCount = 8;
private double serverCost = 120000;
public int getProjectCount() { return projectCount; }
public double getServerCost() { return serverCost; }
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
现在,我们定义访问者接口和具体访问者:
// 访问者接口
public interface Visitor {
void visit(FinanceDepartment finance);
void visit(HRDepartment hr);
void visit(TechDepartment tech);
}
// 薪资报表访问者
public class SalaryReportVisitor implements Visitor {
private double totalSalary = 0;
@Override
public void visit(FinanceDepartment finance) {
totalSalary += finance.getSalaryTotal();
System.out.println("财务部薪资: " + finance.getSalaryTotal());
}
@Override
public void visit(HRDepartment hr) {
System.out.println("人事部无直接薪资数据,培训成本: " + hr.getTrainingCost());
}
@Override
public void visit(TechDepartment tech) {
System.out.println("技术部无直接薪资数据,服务器成本: " + tech.getServerCost());
}
public double getTotalSalary() { return totalSalary; }
}
// 资产报表访问者
public class AssetReportVisitor implements Visitor {
private double totalAsset = 0;
@Override
public void visit(FinanceDepartment finance) {
totalAsset += finance.getAssetTotal();
System.out.println("财务部资产: " + finance.getAssetTotal());
}
@Override
public void visit(HRDepartment hr) {
System.out.println("人事部主要资产为人力,员工数: " + hr.getEmployeeCount());
}
@Override
public void visit(TechDepartment tech) {
totalAsset += tech.getServerCost();
System.out.println("技术部服务器资产: " + tech.getServerCost());
}
public double getTotalAsset() { return totalAsset; }
}
客户端调用:
public class Client {
public static void main(String[] args) {
List<Department> departments = Arrays.asList(
new FinanceDepartment(),
new HRDepartment(),
new TechDepartment()
);
// 生成薪资报表
SalaryReportVisitor salaryVisitor = new SalaryReportVisitor();
for (Department dept : departments) {
dept.accept(salaryVisitor);
}
System.out.println("总薪资: " + salaryVisitor.getTotalSalary());
// 生成资产报表
AssetReportVisitor assetVisitor = new AssetReportVisitor();
for (Department dept : departments) {
dept.accept(assetVisitor);
}
System.out.println("总资产: " + assetVisitor.getTotalAsset());
}
}
核心要点:每个部门类只负责自己的数据,报表逻辑完全在访问者中。新增报表类型时,只需要新增一个访问者类,部门类完全不用动。
业务场景二:AST 遍历
说到编译器,AST 遍历是访问者模式的经典应用。我记得有一次我在做一个代码质量分析工具,需要遍历 Java 源码的 AST,统计各种信息。
AST 的节点类型是固定的:有类声明、方法声明、变量声明、表达式等等。但你要做的操作却可以很多:
- 统计代码行数
- 检查命名规范
- 计算圈复杂度
- 查找未使用的变量
如果用访问者模式,每个分析器就是一个访问者。AST 节点只需要实现 accept 方法,把自身传给访问者。
// AST 节点接口
public interface ASTNode {
void accept(ASTVisitor visitor);
}
// 具体节点
public class ClassNode implements ASTNode {
private String className;
private List<MethodNode> methods;
// 构造方法、getter 省略
@Override
public void accept(ASTVisitor visitor) {
visitor.visit(this);
for (MethodNode method : methods) {
method.accept(visitor);
}
}
}
public class MethodNode implements ASTNode {
private String methodName;
private int lineCount;
@Override
public void accept(ASTVisitor visitor) {
visitor.visit(this);
}
}
// 访问者接口
public interface ASTVisitor {
void visit(ClassNode classNode);
void visit(MethodNode methodNode);
}
// 具体访问者:统计代码行数
public class LineCountVisitor implements ASTVisitor {
private int totalLines = 0;
@Override
public void visit(ClassNode classNode) {
System.out.println("分析类: " + classNode.getClassName());
}
@Override
public void visit(MethodNode methodNode) {
totalLines += methodNode.getLineCount();
System.out.println("方法 " + methodNode.getMethodName() + " 行数: " + methodNode.getLineCount());
}
public int getTotalLines() { return totalLines; }
}
个人经验:我在做代码分析工具时,一开始把分析逻辑直接写在节点类里。结果每个节点类都变得臃肿不堪,而且新增分析器要改所有节点。后来重构为访问者模式,清爽多了。每个分析器独立成一个类,测试也方便。
优缺点分析
| 优点 | 缺点 |
|---|---|
| 符合开闭原则:新增操作只需新增访问者 | 增加新元素类困难:需要修改所有访问者 |
| 将相关操作集中在一个访问者中 | 访问者需要知道元素内部细节,破坏封装 |
| 便于积累状态(如统计总和) | 元素层次复杂时,访问者实现会变得复杂 |
避坑指南:我曾经在一个项目中,元素类经常新增。每次新增一个元素,就要修改所有访问者接口和实现类。那叫一个痛苦。所以,如果元素类结构不稳定,频繁新增,就别用访问者模式。它更适合元素类固定、操作频繁变化的场景。
什么时候用?
我总结了几条经验:
- 对象结构稳定,操作多变 —— 比如 AST 节点固定,但分析器可以有很多
- 需要对对象结构做很多不相关的操作 —— 避免每个操作都污染元素类
- 操作需要积累状态 —— 比如统计总和、最大值等
反过来,如果对象结构经常变化,或者操作很少,那就别用。杀鸡焉用牛刀。
访问者模式虽然有点绕,但用对场景,它就是一把利器。尤其是做框架、工具、编译器这类底层系统时,它的价值会充分体现出来。