重构与设计模式(五):用访问者模式重构数据结构操作

说实话,访问者模式是我个人觉得最「反直觉」的设计模式之一。你想想看,一个对象居然要「接待」另一个对象来操作自己,这和我们平时写代码的习惯完全相反。但正是这种反直觉,让它成了重构利器。

我在项目中遇到过好几次这样的场景:一个数据结构里塞满了各种业务逻辑,每次加新功能都要改这个数据结构本身。改着改着,这个类就变成了「上帝类」——什么都知道,什么都做。这时候,访问者模式就是一把手术刀。

什么时候该用访问者模式?

说白了,就是当你的数据结构相对稳定,但操作这些数据结构的算法经常变化时。举个例子:

  • 一个抽象语法树(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;
    }
}

问题在哪?每次加一个新操作(比如「查找最大文件」「统计每种文件类型的数量」),你都得在 FileDirectory 里各加一个方法。类越来越臃肿,职责越来越模糊。

我曾经接手过一个项目,一个「节点」类有四十多个方法。一半是数据结构本身的,一半是各种业务操作的。每次改业务逻辑都要动这个类,测试用例跑一次要半小时。嗯,这就是典型的「操作和数据结构耦合过紧」。

重构后:访问者模式登场

核心思路就两步:

  1. 数据结构只保留一个 accept 方法
  2. 所有操作都封装到访问者里
// 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(抽象元素) + accept(visitor) File(具体元素A) + accept(visitor) Directory(具体元素B) + accept(visitor) Visitor(抽象访问者) + visitFile(File) + visitDirectory(Directory) SizeCalculator(具体访问者A) + visitFile(File) + visitDirectory(Directory) FileFinder(具体访问者B) + visitFile(File) + visitDirectory(Directory) 使用 虚线箭头表示「使用」关系,实线箭头表示「继承」关系

这张图里,ElementVisitor 是两个独立的抽象体系。具体元素只知道自己要接待访问者,具体访问者知道怎么处理每种元素。这就是「双重分派」——调用哪个方法,由元素类型和访问者类型共同决定。

避坑指南

我个人习惯在以下场景使用访问者模式:
  • 数据结构层级稳定(比如 AST、文件系统、组织架构)
  • 操作频繁变化(比如报表生成、数据导出、规则检查)
  • 不想让数据结构依赖具体业务逻辑
我曾经在一个项目中滥用访问者模式。数据结构本身也在频繁变化,每次加一个新节点类型,所有访问者都要跟着改。那叫一个酸爽。后来我意识到:访问者模式适合「数据结构稳定,操作变化」的场景。如果数据结构本身也不稳定,还是用策略模式或者模板方法更合适。

访问者模式 vs 其他模式

模式 适用场景 变化点
访问者模式 数据结构稳定,操作变化 操作
策略模式 算法族互相替换 算法
模板方法 算法骨架固定,步骤可变 具体步骤
迭代器模式 遍历数据结构 遍历方式

你想想看,如果操作和数据结构都在变,那访问者模式就不太合适了。这时候我建议先稳定数据结构,或者用组合模式把数据结构封装好,再考虑引入访问者。

总结

访问者模式的核心价值就一句话:把操作从数据结构中抽离出来。它让数据结构保持纯净,让操作可以独立变化和扩展。

重构时,我一般这样操作:

  1. 识别出「操作频繁变化」的数据结构
  2. 在数据结构中只保留核心属性和 accept 方法
  3. 把每个操作封装成一个访问者
  4. 通过 accept 方法触发访问者的处理逻辑

嗯,说白了就是「让数据归数据,让操作归操作」。这个原则在很多设计模式里都适用,访问者模式只是把它做到了极致。

记住:访问者模式不是银弹。它适合「数据结构稳定,操作变化」的场景。如果数据结构本身也在频繁变化,那还是先重构数据结构本身吧。


公众号:蓝海资料掘金营,微信deep3321