重构与设计模式(五):用访问者模式重构数据结构操作
说实话,访问者模式是我个人觉得最「反直觉」的设计模式之一。你想想看,一个对象居然要「接待」另一个对象来操作自己,这和我们平时写代码的习惯完全相反。但正是这种反直觉,让它成了重构利器。
我在项目中遇到过好几次这样的场景:一个数据结构里塞满了各种业务逻辑,每次加新功能都要改这个数据结构本身。改着改着,这个类就变成了「上帝类」——什么都知道,什么都做。这时候,访问者模式就是一把手术刀。
什么时候该用访问者模式?
说白了,就是当你的数据结构相对稳定,但操作这些数据结构的算法经常变化时。举个例子:
- 一个抽象语法树(AST),节点类型就那么几种
- 但你要做的操作却很多:类型检查、代码生成、优化、格式化……
- 每次加操作都要改所有节点类?太痛苦了
访问者模式把「操作」从「数据结构」中抽离出来。数据结构只负责接待访问者,操作逻辑全在访问者里。
重构前的代码:操作和数据结构混在一起
先看一段典型的「坏代码」。假设我们有一个文件系统的数据结构:
// 每个节点都要实现自己的操作
class File {
constructor(name, size) {
this.name = name;
this.size = size;
}
// 计算总大小
getTotalSize() {
return this.size;
}
// 统计文件数量
getFileCount() {
return 1;
}
// 查找文件
findByName(name) {
if (this.name === name) return this;
return null;
}
}
class Directory {
constructor(name) {
this.name = name;
this.children = [];
}
getTotalSize() {
return this.children.reduce(
(sum, child) => sum + child.getTotalSize(), 0
);
}
getFileCount() {
return this.children.reduce(
(sum, child) => sum + child.getFileCount(), 0
);
}
findByName(name) {
if (this.name === name) return this;
for (let child of this.children) {
let result = child.findByName(name);
if (result) return result;
}
return null;
}
}
问题在哪?每次加一个新操作(比如「查找最大文件」「统计每种文件类型的数量」),你都得在 File 和 Directory 里各加一个方法。类越来越臃肿,职责越来越模糊。
重构后:访问者模式登场
核心思路就两步:
- 数据结构只保留一个
accept方法 - 所有操作都封装到访问者里
// 1. 定义访问者接口
class Visitor {
visitFile(file) {
throw new Error('子类必须实现 visitFile');
}
visitDirectory(directory) {
throw new Error('子类必须实现 visitDirectory');
}
}
// 2. 数据结构只保留 accept 方法
class File {
constructor(name, size) {
this.name = name;
this.size = size;
}
accept(visitor) {
return visitor.visitFile(this);
}
}
class Directory {
constructor(name) {
this.name = name;
this.children = [];
}
accept(visitor) {
return visitor.visitDirectory(this);
}
addChild(child) {
this.children.push(child);
}
}
// 3. 具体访问者:计算总大小
class SizeCalculator extends Visitor {
visitFile(file) {
return file.size;
}
visitDirectory(directory) {
return directory.children.reduce(
(sum, child) => sum + child.accept(this), 0
);
}
}
// 4. 具体访问者:统计文件数量
class FileCounter extends Visitor {
visitFile(file) {
return 1;
}
visitDirectory(directory) {
return directory.children.reduce(
(sum, child) => sum + child.accept(this), 0
);
}
}
// 5. 具体访问者:查找文件
class FileFinder extends Visitor {
constructor(targetName) {
super();
this.targetName = targetName;
}
visitFile(file) {
return file.name === this.targetName ? file : null;
}
visitDirectory(directory) {
if (directory.name === this.targetName) return directory;
for (let child of directory.children) {
let result = child.accept(this);
if (result) return result;
}
return null;
}
}
使用起来也很直观:
const root = new Directory('root');
const docs = new Directory('docs');
docs.addChild(new File('readme.md', 10));
docs.addChild(new File('guide.md', 20));
root.addChild(docs);
root.addChild(new File('package.json', 5));
const sizeCalc = new SizeCalculator();
console.log(root.accept(sizeCalc)); // 35
const fileCounter = new FileCounter();
console.log(root.accept(fileCounter)); // 3
const finder = new FileFinder('readme.md');
console.log(root.accept(finder)); // File { name: 'readme.md', size: 10 }
访问者模式的核心结构
我画了一张图,帮你理清各个角色的关系:
这张图里,Element 和 Visitor 是两个独立的抽象体系。具体元素只知道自己要接待访问者,具体访问者知道怎么处理每种元素。这就是「双重分派」——调用哪个方法,由元素类型和访问者类型共同决定。
避坑指南
- 数据结构层级稳定(比如 AST、文件系统、组织架构)
- 操作频繁变化(比如报表生成、数据导出、规则检查)
- 不想让数据结构依赖具体业务逻辑
访问者模式 vs 其他模式
| 模式 | 适用场景 | 变化点 |
|---|---|---|
| 访问者模式 | 数据结构稳定,操作变化 | 操作 |
| 策略模式 | 算法族互相替换 | 算法 |
| 模板方法 | 算法骨架固定,步骤可变 | 具体步骤 |
| 迭代器模式 | 遍历数据结构 | 遍历方式 |
你想想看,如果操作和数据结构都在变,那访问者模式就不太合适了。这时候我建议先稳定数据结构,或者用组合模式把数据结构封装好,再考虑引入访问者。
总结
访问者模式的核心价值就一句话:把操作从数据结构中抽离出来。它让数据结构保持纯净,让操作可以独立变化和扩展。
重构时,我一般这样操作:
- 识别出「操作频繁变化」的数据结构
- 在数据结构中只保留核心属性和
accept方法 - 把每个操作封装成一个访问者
- 通过
accept方法触发访问者的处理逻辑
嗯,说白了就是「让数据归数据,让操作归操作」。这个原则在很多设计模式里都适用,访问者模式只是把它做到了极致。
记住:访问者模式不是银弹。它适合「数据结构稳定,操作变化」的场景。如果数据结构本身也在频繁变化,那还是先重构数据结构本身吧。
公众号:蓝海资料掘金营,微信deep3321