一、访问者模式:当数据结构遇上操作

访问者模式,说白了就是解决一个经典矛盾:稳定的数据结构多变的操作

我刚开始接触这个模式时,也觉得它有点绕。后来在做一个文件系统工具时,才真正体会到它的价值。你想想看,文件系统里无非就是文件和文件夹,结构很稳定对吧?但你要对它们做的事可就多了——统计大小、搜索文件、生成目录树、权限检查……每个操作都不同。

传统做法是在每个类里加方法。但这样每加一个新操作,就得改所有类。访问者模式换个思路:把操作抽出来,变成独立的访问者

1.1 核心思想:双分派

访问者模式最妙的地方,就是双分派机制。什么意思呢?

普通的方法调用,只根据接收者的类型来决定执行哪个方法。这叫单分派。双分派则是在运行时,同时根据接收者类型参数类型来决定。

我画个图帮你理解:

访问者模式双分派机制 客户端 元素 (File / Folder) 访问者 (Visitor) 1. accept(visitor) 2. visit(this) 第一次分派: 客户端调用 element.accept(visitor) 根据 element 的实际类型(File 还是 Folder)决定调用哪个 accept 方法 第二次分派: accept 方法内部调用 visitor.visit(this) 根据 visitor 的实际类型决定调用哪个 visit 方法 双分派 = 运行时类型 + 参数类型 共同决定方法调用 避免了大量的 if-else 或 instanceof 判断

1.2 访问者模式的结构

访问者模式有四个核心角色:

角色 职责 例子
Visitor(访问者接口) 声明对每种元素的访问方法 visitFile(), visitFolder()
ConcreteVisitor(具体访问者) 实现具体的操作逻辑 SizeCalculator, FileSearcher
Element(元素接口) 声明 accept 方法 accept(Visitor visitor)
ConcreteElement(具体元素) 实现 accept,调用 visitor 的对应方法 File, Folder

二、实战:文件系统遍历

好了,理论说完了。咱们直接上代码。我记得有一次做代码分析工具,需要遍历整个项目目录,统计每种文件的数量和总大小。用访问者模式,代码清晰得不得了。

2.1 定义元素接口

// 文件系统元素接口
public interface FileSystemElement {
    void accept(FileSystemVisitor visitor);
}

// 文件类
public class File implements FileSystemElement {
    private String name;
    private long size;  // 字节数
    
    public File(String name, long size) {
        this.name = name;
        this.size = size;
    }
    
    @Override
    public void accept(FileSystemVisitor visitor) {
        visitor.visit(this);  // 关键:把自身传给访问者
    }
    
    // getter
    public String getName() { return name; }
    public long getSize() { return size; }
}

// 文件夹类
public class Folder implements FileSystemElement {
    private String name;
    private List<FileSystemElement> children = new ArrayList<>();
    
    public Folder(String name) {
        this.name = name;
    }
    
    public void addChild(FileSystemElement element) {
        children.add(element);
    }
    
    @Override
    public void accept(FileSystemVisitor visitor) {
        visitor.visit(this);  // 先访问自己
        for (FileSystemElement child : children) {
            child.accept(visitor);  // 再递归访问子元素
        }
    }
    
    public String getName() { return name; }
    public List<FileSystemElement> getChildren() { return children; }
}

关键点:Folder 的 accept 方法里,先访问自己,再遍历子元素。这样访问者就能递归处理整个树结构。

2.2 定义访问者接口

public interface FileSystemVisitor {
    void visit(File file);
    void visit(Folder folder);
}

就这么简单。每种元素对应一个 visit 方法。这就是访问者模式的核心约定。

2.3 实现具体访问者

咱们先来个统计文件大小的访问者:

public class SizeCalculator implements FileSystemVisitor {
    private long totalSize = 0;
    
    @Override
    public void visit(File file) {
        totalSize += file.getSize();
        System.out.println("文件: " + file.getName() + 
                           " 大小: " + file.getSize() + " 字节");
    }
    
    @Override
    public void visit(Folder folder) {
        System.out.println("进入文件夹: " + folder.getName());
        // 注意:这里不需要手动累加,因为子文件会触发 visit(File)
    }
    
    public long getTotalSize() {
        return totalSize;
    }
}

再来个搜索文件的访问者:

public class FileSearcher implements FileSystemVisitor {
    private String keyword;
    private List<File> results = new ArrayList<>();
    
    public FileSearcher(String keyword) {
        this.keyword = keyword;
    }
    
    @Override
    public void visit(File file) {
        if (file.getName().contains(keyword)) {
            results.add(file);
            System.out.println("找到匹配文件: " + file.getName());
        }
    }
    
    @Override
    public void visit(Folder folder) {
        // 文件夹本身不参与搜索,但需要遍历子元素
        // 这里可以加一些过滤逻辑,比如跳过隐藏文件夹
    }
    
    public List<File> getResults() {
        return results;
    }
}

2.4 客户端使用

public class Client {
    public static void main(String[] args) {
        // 构建文件系统树
        Folder root = new Folder("root");
        Folder docs = new Folder("docs");
        Folder src = new Folder("src");
        
        root.addChild(docs);
        root.addChild(src);
        
        docs.addChild(new File("readme.md", 1024));
        docs.addChild(new File("design.md", 2048));
        src.addChild(new File("Main.java", 4096));
        src.addChild(new File("Utils.java", 2048));
        
        // 使用访问者
        SizeCalculator sizeCalc = new SizeCalculator();
        root.accept(sizeCalc);
        System.out.println("总大小: " + sizeCalc.getTotalSize() + " 字节");
        
        System.out.println("---");
        
        FileSearcher searcher = new FileSearcher(".md");
        root.accept(searcher);
        System.out.println("找到 " + searcher.getResults().size() + " 个匹配文件");
    }
}

三、避坑指南

我曾经踩过的坑:

  • 元素类型不稳定时别用:如果 FileSystemElement 经常新增子类(比如加个 Symlink),那所有访问者都得改。访问者模式适合元素类型稳定的场景。
  • 循环引用要小心:文件系统里可能有符号链接形成环。我建议在 Folder.accept 里加个 visited 集合,防止死循环。
  • 访问者方法命名要规范:我习惯用 visit + 元素类名,比如 visitFile、visitFolder。这样一看就知道是访问哪个元素的。

我的小技巧:

如果访问者需要返回结果,可以在访问者里维护一个状态(比如 totalSize)。或者用泛型让访问者支持返回值:

public interface FileSystemVisitor<T> {
    T visit(File file);
    T visit(Folder folder);
}

这样每个访问者可以返回不同类型的结果,更灵活。

四、访问者模式的优缺点

优点 缺点
新增操作方便:加个新访问者就行,不用改元素类 新增元素类型困难:所有访问者都得改
操作逻辑集中:相关操作都在一个访问者里 访问者可能访问元素内部细节,破坏封装
符合开闭原则:对扩展开放,对修改关闭 代码结构变复杂,多了很多类和接口
双分派机制优雅,避免 instanceof 判断 学习曲线较陡,新手不容易理解

五、总结

访问者模式,说白了就是把操作从数据结构里抽出来。它用双分派机制,让代码既灵活又干净。

我个人觉得,这个模式最适合用在元素类型稳定、操作频繁变化的场景。比如编译器(AST 遍历)、文件系统工具、报表生成等。如果你发现代码里到处都是 if-else 判断元素类型,然后做不同操作,那就可以考虑用访问者模式重构了。

嗯,最后提醒一句:别为了用模式而用模式。访问者模式确实优雅,但也会增加代码复杂度。如果你的操作就那么两三个,或者元素类型经常变,那还是老老实实用普通的多态吧。


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