访问者模式:定义、结构、实现、双分派、优缺点、应用场景

访问者模式,说实话,是我个人觉得设计模式里最「绕」的一个。很多同学学到这里就开始犯迷糊——「为什么要搞这么复杂?」。别急,我当年第一次接触时也是这个反应。直到我在一个报表系统里被各种 if-else 折磨得够呛,才真正体会到它的价值。

定义:它到底想解决什么问题?

访问者模式的核心思想很简单:将数据结构与数据操作分离

你想想看,我们平时写代码,对象的结构和对象的行为往往是绑在一起的。比如一个订单类,它既要有订单数据,又要有计算价格、生成报表的方法。这本身没问题,但一旦业务逻辑疯狂膨胀——今天加个导出 Excel,明天加个发送邮件,后天加个数据统计——这个类就会变得臃肿不堪。

访问者模式说:别把操作塞进对象里,让一个「访问者」专门来做这些事。对象只需要做一件事:接受访问者的来访

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

结构:四个角色,一个都不能少

访问者模式的结构其实很清晰,我习惯把它拆成四个部分:

  • Visitor(抽象访问者):声明访问方法,比如 visit(ConcreteElementA)、visit(ConcreteElementB)
  • ConcreteVisitor(具体访问者):实现具体的访问逻辑,每个访问者就是一种操作
  • Element(抽象元素):声明 accept 方法,参数是 Visitor
  • ConcreteElement(具体元素):实现 accept 方法,核心就是调用 visitor.visit(this)

这里有个关键点:accept 方法里调用 visitor.visit(this),这个 this 是具体类型。这就是双分派的秘密,后面我会细说。

我的习惯:画图时把 Element 放在左边,Visitor 放在右边,中间用 accept 和 visit 两条线连接。这样结构一目了然。

核心逻辑:一张图看懂

下面这张 SVG 图,是我自己讲课常用的。它把访问者模式的调用流程画得清清楚楚:

访问者模式核心调用流程 Client ObjectStructure ConcreteElementA ConcreteElementB ConcreteVisitor ConcreteVisitor 双分派:accept 确定元素类型 visit 确定访问者类型

实现:一个真实的代码例子

我记得之前做过一个文件系统工具,需要遍历文件夹并执行不同操作——统计大小、搜索文件、生成目录树。如果用 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());
    }
}
避坑指南:我曾经在项目中犯过一个错误——在 Directory.accept 里忘了递归调用子元素的 accept。结果只统计了根目录下的文件,子文件夹里的全丢了。记住:如果元素有子结构,accept 里一定要处理递归

双分派:这才是访问者模式的核心

为什么叫「双分派」?说白了就是:一次调用,两次决定

普通的单分派是这样的:

  • 调用 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。而访问者模式把类型判断交给了语言的方法重载机制,干净利落。

关键理解:双分派的核心是「两次多态」。第一次多态是 accept 方法,第二次多态是 visit 方法。两次多态叠加,就实现了「元素类型 × 访问者类型」的矩阵式扩展。

优缺点:用之前想清楚

优点 缺点
  • 符合开闭原则:新增操作只需加访问者,不用改元素
  • 将相关操作集中到一个访问者中,高内聚
  • 方便积累状态(比如统计总和、收集结果)
  • 新增元素类型困难:所有访问者都要加一个 visit 方法
  • 破坏封装:访问者需要知道元素内部细节
  • 代码结构复杂,初学者难理解
注意:如果元素类型经常变化,千万别用访问者模式。我见过一个项目,元素类型从 3 个涨到 10 个,每个访问者都要改,改到后面团队直接放弃了。访问者模式适合「元素稳定,操作多变」的场景。

应用场景:什么时候该用它?

根据我的经验,以下场景特别适合访问者模式:

  1. 编译器设计:抽象语法树(AST)的节点类型相对固定,但操作(类型检查、代码生成、优化)经常变化
  2. 文档处理:文档元素(段落、表格、图片)稳定,但导出格式(PDF、HTML、Markdown)经常新增
  3. UI 组件树:组件类型固定,但渲染逻辑、事件处理、样式计算可以独立扩展
  4. 报表系统:数据源结构稳定,但报表格式(图表、表格、仪表盘)经常变化

我当年在报表系统里用访问者模式,把数据源和报表生成彻底解耦。后来产品经理说「再加一个饼图报表」,我只用写一个新的 Visitor 类,数据源一行代码都不用改。那种感觉,真的很爽。

我的建议:如果你发现代码里频繁出现「对一组对象做不同操作」,而且操作还在不断增加,那就考虑访问者模式。但如果你不确定元素类型会不会变,先别用——等稳定了再重构也不迟。

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