一、访问者模式:数据结构与操作分离、双分派
说实话,访问者模式是我在讲设计模式时,学员反馈最难理解的一个。它不像单例、工厂那样直观,也不像观察者、策略那样常见。但一旦你理解了它的核心思想——数据结构与操作分离,你会发现它在某些场景下简直是救星。
我个人习惯把访问者模式比作「医院体检」。你想想看,病人(数据结构)是固定的,但不同的医生(访问者)可以给同一个病人做不同的检查。内科医生查心肺,眼科医生查视力,骨科医生查骨骼。病人不需要知道医生怎么查,医生也不需要改变病人的结构。这就是分离。
1. 什么是访问者模式?
访问者模式的定义是:封装一些作用于某种数据结构中的各元素的操作,它可以在不改变数据结构的前提下定义作用于这些元素的新操作。
说白了,就是把操作从数据结构中抽出来。数据结构只负责存储和提供元素,操作则由访问者来定义。这样,当你需要新增操作时,不需要修改数据结构,只需要新增一个访问者即可。
核心思想:数据结构与操作分离,支持双分派。
2. 双分派是什么?
这里有个关键概念——双分派。我刚开始学的时候也被绕晕了。简单说:
- 单分派:根据调用者的类型和参数类型,在运行时确定调用哪个方法。Java 是单分派语言。
- 双分派:在运行时,根据两个对象的类型来确定调用哪个方法。访问者模式通过
accept方法模拟了双分派。
为什么会这样?因为访问者模式中,accept方法接收一个访问者,然后访问者再调用自己的visit方法,传入当前元素。这样,元素的类型和访问者的类型都在运行时被确定,实现了双分派。
我的经验:我在项目中遇到过需要处理多种文件格式的场景。PDF、Word、Excel 各有不同的解析逻辑。如果用 if-else 写,代码会膨胀到无法维护。访问者模式让我把每种格式的解析逻辑封装到独立的访问者中,新增格式时只需要新增一个访问者类,完全不用改已有的代码。
3. 访问者模式的结构
访问者模式包含以下几个角色:
| 角色 | 说明 |
|---|---|
| Visitor(抽象访问者) | 声明访问操作的接口,为每种具体元素定义一个 visit 方法 |
| ConcreteVisitor(具体访问者) | 实现 Visitor 接口,定义对每种元素的具体操作 |
| Element(抽象元素) | 声明 accept 方法,接收一个访问者 |
| ConcreteElement(具体元素) | 实现 accept 方法,调用访问者的 visit 方法 |
| ObjectStructure(对象结构) | 持有元素集合,提供遍历方法 |
下面我用 SVG 画了一张结构图,帮你理清关系:
4. 代码示例:文件系统访问者
我们来看一个实际例子。假设有一个文件系统,包含文件和文件夹。我们需要对它们执行不同的操作,比如计算大小、生成目录树、搜索文件等。
先定义元素接口:
// 抽象元素
public interface FileElement {
void accept(FileVisitor visitor);
}
// 具体元素:文件
public class File implements FileElement {
private String name;
private int size; // KB
public File(String name, int size) {
this.name = name;
this.size = size;
}
public String getName() { return name; }
public int getSize() { return size; }
@Override
public void accept(FileVisitor visitor) {
visitor.visit(this); // 双分派的关键
}
}
// 具体元素:文件夹
public class Folder implements FileElement {
private String name;
private List<FileElement> children = new ArrayList<>();
public Folder(String name) {
this.name = name;
}
public void add(FileElement element) {
children.add(element);
}
public List<FileElement> getChildren() {
return children;
}
public String getName() { return name; }
@Override
public void accept(FileVisitor visitor) {
visitor.visit(this); // 双分派的关键
for (FileElement child : children) {
child.accept(visitor);
}
}
}
再定义访问者接口和具体访问者:
// 抽象访问者
public interface FileVisitor {
void visit(File file);
void visit(Folder folder);
}
// 具体访问者:计算总大小
public class SizeCalculator implements FileVisitor {
private int totalSize = 0;
@Override
public void visit(File file) {
totalSize += file.getSize();
}
@Override
public void visit(Folder folder) {
// 文件夹本身不占空间,只遍历子元素
// 子元素的 accept 已经在 Folder 中调用了
}
public int getTotalSize() {
return totalSize;
}
}
// 具体访问者:生成目录树
public class TreePrinter implements FileVisitor {
private int indent = 0;
@Override
public void visit(File file) {
printIndent();
System.out.println("📄 " + file.getName() + " (" + file.getSize() + "KB)");
}
@Override
public void visit(Folder folder) {
printIndent();
System.out.println("📁 " + folder.getName() + "/");
indent++;
// 子元素的遍历由 Folder.accept 中的循环完成
indent--;
}
private void printIndent() {
for (int i = 0; i < indent; i++) {
System.out.print(" ");
}
}
}
客户端使用:
public class Client {
public static void main(String[] args) {
// 构建文件系统
Folder root = new Folder("root");
Folder docs = new Folder("docs");
Folder src = new Folder("src");
docs.add(new File("readme.txt", 10));
docs.add(new File("design.md", 25));
src.add(new File("Main.java", 50));
src.add(new File("Utils.java", 30));
root.add(docs);
root.add(src);
// 使用访问者
SizeCalculator sizeCalc = new SizeCalculator();
root.accept(sizeCalc);
System.out.println("总大小: " + sizeCalc.getTotalSize() + "KB");
System.out.println("\n目录树:");
TreePrinter printer = new TreePrinter();
root.accept(printer);
}
}
输出结果:
总大小: 115KB
目录树:
📁 root/
📁 docs/
📄 readme.txt (10KB)
📄 design.md (25KB)
📁 src/
📄 Main.java (50KB)
📄 Utils.java (30KB)
关键点:注意 Folder.accept 方法中,先调用了 visitor.visit(this),然后遍历子元素并调用 child.accept(visitor)。这样,访问者可以控制遍历的顺序和方式。如果你需要先处理子元素再处理父元素,调整一下顺序即可。
5. 访问者模式的优缺点
| 优点 | 缺点 |
|---|---|
| 符合开闭原则:新增操作只需新增访问者 | 增加新元素类困难:需要修改所有访问者 |
| 将相关操作集中到一个访问者中 | 访问者需要知道元素内部细节,破坏封装 |
| 可以跨类层次结构操作 | 代码复杂度较高,理解门槛高 |
避坑指南:我曾经在一个项目中滥用访问者模式,把简单的 CRUD 操作也拆成了访问者。结果代码变得非常绕,团队成员都看不懂。后来我总结了一条经验:只有当数据结构稳定(很少新增元素类),但操作频繁变化时,才适合用访问者模式。如果元素类经常变化,访问者模式会让你痛不欲生。
6. 适用场景
- 编译器设计:抽象语法树(AST)的节点是固定的,但各种操作(类型检查、代码生成、优化)是变化的。
- 文件系统:文件和文件夹是固定的元素,但操作(搜索、统计、备份)可以任意扩展。
- UI 组件树:按钮、输入框、列表等组件是固定的,但渲染、事件处理、数据校验等操作可以分离。
- 报表系统:数据源结构固定,但报表格式(PDF、Excel、HTML)可以灵活切换。
7. 与策略模式的区别
很多同学会把访问者模式和策略模式搞混。我简单说一下:
- 策略模式:算法的分离。同一个对象,不同的算法。比如排序算法,对同一个数组用冒泡排序还是快速排序。
- 访问者模式:操作的分离。不同的对象,不同的操作。比如对文件计算大小,对文件夹计算大小,操作不同。
说白了,策略模式是「一个对象,多种算法」;访问者模式是「多个对象,多种操作」。
8. 总结
访问者模式的核心就是数据结构与操作分离,通过双分派机制,让操作可以独立于数据结构变化。它不适合初学者,但当你遇到「数据结构稳定,操作频繁变化」的场景时,它是最优雅的解决方案。
嗯,最后提醒一句:不要为了用设计模式而用设计模式。我见过太多人把简单问题复杂化。访问者模式是个好工具,但要用对地方。
我的建议:如果你不确定是否该用访问者模式,先问自己两个问题:1)数据结构是否稳定?2)操作是否经常新增?如果两个答案都是「是」,那就放心用吧。