一、访问者模式:数据结构与操作分离、双分派

说实话,访问者模式是我在讲设计模式时,学员反馈最难理解的一个。它不像单例、工厂那样直观,也不像观察者、策略那样常见。但一旦你理解了它的核心思想——数据结构与操作分离,你会发现它在某些场景下简直是救星。

我个人习惯把访问者模式比作「医院体检」。你想想看,病人(数据结构)是固定的,但不同的医生(访问者)可以给同一个病人做不同的检查。内科医生查心肺,眼科医生查视力,骨科医生查骨骼。病人不需要知道医生怎么查,医生也不需要改变病人的结构。这就是分离。

1. 什么是访问者模式?

访问者模式的定义是:封装一些作用于某种数据结构中的各元素的操作,它可以在不改变数据结构的前提下定义作用于这些元素的新操作。

说白了,就是把操作从数据结构中抽出来。数据结构只负责存储和提供元素,操作则由访问者来定义。这样,当你需要新增操作时,不需要修改数据结构,只需要新增一个访问者即可。

核心思想:数据结构与操作分离,支持双分派。

2. 双分派是什么?

这里有个关键概念——双分派。我刚开始学的时候也被绕晕了。简单说:

  • 单分派:根据调用者的类型和参数类型,在运行时确定调用哪个方法。Java 是单分派语言。
  • 双分派:在运行时,根据两个对象的类型来确定调用哪个方法。访问者模式通过accept方法模拟了双分派。

为什么会这样?因为访问者模式中,accept方法接收一个访问者,然后访问者再调用自己的visit方法,传入当前元素。这样,元素的类型和访问者的类型都在运行时被确定,实现了双分派。

我的经验:我在项目中遇到过需要处理多种文件格式的场景。PDF、Word、Excel 各有不同的解析逻辑。如果用 if-else 写,代码会膨胀到无法维护。访问者模式让我把每种格式的解析逻辑封装到独立的访问者中,新增格式时只需要新增一个访问者类,完全不用改已有的代码。

3. 访问者模式的结构

访问者模式包含以下几个角色:

角色 说明
Visitor(抽象访问者) 声明访问操作的接口,为每种具体元素定义一个 visit 方法
ConcreteVisitor(具体访问者) 实现 Visitor 接口,定义对每种元素的具体操作
Element(抽象元素) 声明 accept 方法,接收一个访问者
ConcreteElement(具体元素) 实现 accept 方法,调用访问者的 visit 方法
ObjectStructure(对象结构) 持有元素集合,提供遍历方法

下面我用 SVG 画了一张结构图,帮你理清关系:

访问者模式结构图 «接口» Visitor + visit(ConcreteElementA) + visit(ConcreteElementB) ConcreteVisitorA + visit(...) 实现 ConcreteVisitorB + visit(...) 实现 «接口» Element + accept(Visitor) ConcreteElementA + accept(Visitor) 实现 ConcreteElementB + accept(Visitor) 实现 ObjectStructure + elements: List<Element> + accept(Visitor) 调用 visitor.visit(this) 抽象角色 具体角色 调用关系

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)操作是否经常新增?如果两个答案都是「是」,那就放心用吧。

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