访问者模式:定义、结构、实现、双分派、优缺点、应用场景
访问者模式,说实话,是我个人觉得设计模式里最「绕」的一个。很多同学学到这里就开始犯迷糊——「为什么要搞这么复杂?」。别急,我当年第一次接触时也是这个反应。直到我在一个报表系统里被各种 if-else 折磨得够呛,才真正体会到它的价值。
定义:它到底想解决什么问题?
访问者模式的核心思想很简单:将数据结构与数据操作分离。
你想想看,我们平时写代码,对象的结构和对象的行为往往是绑在一起的。比如一个订单类,它既要有订单数据,又要有计算价格、生成报表的方法。这本身没问题,但一旦业务逻辑疯狂膨胀——今天加个导出 Excel,明天加个发送邮件,后天加个数据统计——这个类就会变得臃肿不堪。
访问者模式说:别把操作塞进对象里,让一个「访问者」专门来做这些事。对象只需要做一件事:接受访问者的来访。
结构:四个角色,一个都不能少
访问者模式的结构其实很清晰,我习惯把它拆成四个部分:
- Visitor(抽象访问者):声明访问方法,比如 visit(ConcreteElementA)、visit(ConcreteElementB)
- ConcreteVisitor(具体访问者):实现具体的访问逻辑,每个访问者就是一种操作
- Element(抽象元素):声明 accept 方法,参数是 Visitor
- ConcreteElement(具体元素):实现 accept 方法,核心就是调用 visitor.visit(this)
这里有个关键点:accept 方法里调用 visitor.visit(this),这个 this 是具体类型。这就是双分派的秘密,后面我会细说。
核心逻辑:一张图看懂
下面这张 SVG 图,是我自己讲课常用的。它把访问者模式的调用流程画得清清楚楚:
实现:一个真实的代码例子
我记得之前做过一个文件系统工具,需要遍历文件夹并执行不同操作——统计大小、搜索文件、生成目录树。如果用 if-else 判断文件类型,代码会变得又臭又长。访问者模式正好派上用场。
先定义元素接口:
// 抽象元素
public interface FileElement {
void accept(FileVisitor visitor);
}
// 具体元素:文件
public class File implements FileElement {
private String name;
private long size;
public File(String name, long size) {
this.name = name;
this.size = size;
}
@Override
public void accept(FileVisitor visitor) {
visitor.visit(this); // 关键:传入 this,类型是 File
}
// getter 略
}
// 具体元素:文件夹
public class Directory implements FileElement {
private String name;
private List<FileElement> children = new ArrayList<>();
public Directory(String name) {
this.name = name;
}
public void add(FileElement element) {
children.add(element);
}
@Override
public void accept(FileVisitor visitor) {
visitor.visit(this); // 关键:传入 this,类型是 Directory
for (FileElement child : children) {
child.accept(visitor); // 递归遍历
}
}
// getter 略
}
再定义访问者:
// 抽象访问者
public interface FileVisitor {
void visit(File file);
void visit(Directory directory);
}
// 具体访问者:计算总大小
public class SizeCalculator implements FileVisitor {
private long totalSize = 0;
@Override
public void visit(File file) {
totalSize += file.getSize();
}
@Override
public void visit(Directory directory) {
// 文件夹本身不占空间,只遍历子元素
// 递归已经在 Directory.accept 中处理了
}
public long getTotalSize() {
return totalSize;
}
}
// 具体访问者:搜索文件
public class FileSearcher implements FileVisitor {
private String keyword;
private List<String> results = new ArrayList<>();
public FileSearcher(String keyword) {
this.keyword = keyword;
}
@Override
public void visit(File file) {
if (file.getName().contains(keyword)) {
results.add(file.getName());
}
}
@Override
public void visit(Directory directory) {
// 也可以搜索文件夹名
if (directory.getName().contains(keyword)) {
results.add(directory.getName() + "/");
}
}
public List<String> getResults() {
return results;
}
}
客户端调用:
public class Client {
public static void main(String[] args) {
Directory root = new Directory("root");
root.add(new File("readme.txt", 1024));
root.add(new File("photo.jpg", 2048));
Directory subDir = new Directory("docs");
subDir.add(new File("design.doc", 512));
root.add(subDir);
// 计算大小
SizeCalculator calculator = new SizeCalculator();
root.accept(calculator);
System.out.println("总大小: " + calculator.getTotalSize());
// 搜索文件
FileSearcher searcher = new FileSearcher("doc");
root.accept(searcher);
System.out.println("搜索结果: " + searcher.getResults());
}
}
双分派:这才是访问者模式的核心
为什么叫「双分派」?说白了就是:一次调用,两次决定。
普通的单分派是这样的:
- 调用 obj.method(),只根据 obj 的实际类型决定调用哪个方法
双分派是这样的:
- 第一步:element.accept(visitor) —— 根据 element 的实际类型,决定调用哪个 accept
- 第二步:visitor.visit(this) —— 根据 visitor 的实际类型,决定调用哪个 visit
你想想看,如果没有双分派,我们只能写出这样的代码:
// 糟糕的设计:用 instanceof 判断类型
if (element instanceof File) {
// 处理文件
} else if (element instanceof Directory) {
// 处理文件夹
}
每增加一种元素类型,就要改这个 if-else。而访问者模式把类型判断交给了语言的方法重载机制,干净利落。
优缺点:用之前想清楚
| 优点 | 缺点 |
|---|---|
|
|
应用场景:什么时候该用它?
根据我的经验,以下场景特别适合访问者模式:
- 编译器设计:抽象语法树(AST)的节点类型相对固定,但操作(类型检查、代码生成、优化)经常变化
- 文档处理:文档元素(段落、表格、图片)稳定,但导出格式(PDF、HTML、Markdown)经常新增
- UI 组件树:组件类型固定,但渲染逻辑、事件处理、样式计算可以独立扩展
- 报表系统:数据源结构稳定,但报表格式(图表、表格、仪表盘)经常变化
我当年在报表系统里用访问者模式,把数据源和报表生成彻底解耦。后来产品经理说「再加一个饼图报表」,我只用写一个新的 Visitor 类,数据源一行代码都不用改。那种感觉,真的很爽。
公众号:蓝海资料掘金营,微信deep3321