Skip to content

访问者模式 (Visitor) —— 极其难懂但很强大

前言

在深入探讨访问者模式之前,想向大家推荐一个非常棒的开源项目:design-patterns-23。这个项目用最现代的技术栈重写了 23 种设计模式,非常适合实战学习,还配套了在线交互演示站,可以边读文章边动手玩。本文的实战案例灵感也来源于此。

1. 访问者模式是什么?

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

它是 23 种设计模式中最复杂、最难理解的一种。

简单来说,访问者模式就是把“数据”和“操作”分离

1.1 核心概念

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

  • Visitor(抽象访问者):定义了对每一个元素(Element)访问的行为。它的方法参数通常就是具体的元素对象,以便访问者能直接操作元素。
  • ConcreteVisitor(具体访问者):实现了对每一个元素类访问时的具体行为。
  • Element(抽象元素):定义了一个 accept 方法,该方法通常接收一个 Visitor 作为参数。
  • ConcreteElement(具体元素):实现 accept 方法,通常逻辑就是 visitor.visit(this),将自己暴露给访问者。
  • ObjectStructure(对象结构):一个元素的容器,如 List、Set 或 Tree,负责遍历元素并让它们接受访问。

1.2 为什么我们需要它?

场景: 假设你在开发一个编译器。源代码被解析成一颗抽象语法树(AST),树上有各种节点:AssignmentNode(赋值)、VariableNode(变量)、IfNode(条件判断)。 你需要对这棵树做很多不同的操作,比如代码检查(检查变量是否定义)、代码优化(消除死代码)、代码生成(生成机器码)以及格式化

痛点: 如果把这些逻辑都写在 Node 类里,会违反单一职责原则(Node 类应该只关心语法结构,不该关心怎么生成机器码),而且扩展困难(如果要增加一个“语法高亮”功能,你得去修改所有的 Node 子类)。

解决方案: Node 类只保留一个 accept(Visitor v) 方法,把所有的操作逻辑移到 CodeCheckVisitor, OptimizeVisitor, GenerateVisitor 等独立的访问者类中。这样,增加新的操作只需要增加一个新的 Visitor 类,而不需要修改 Node 类。

2. 核心原理:双分派 (Double Dispatch)

理解访问者模式的关键在于理解双分派

在 Java 中,方法重载(Overload) 是静态分派(Static Dispatch),编译期就决定了;方法重写(Override) 是动态分派(Dynamic Dispatch),运行期根据接收者的实际类型决定。

访问者模式巧妙地利用了这两者,实现了双重动态分派的效果:

  1. 第一次分派:element.accept(visitor)。根据 element 的实际类型,调用对应的 accept 方法。
  2. 第二次分派:visitor.visit(this)。在 accept 内部,this 的类型是确定的(比如 Apple),所以会自动调用 visitor.visit(Apple) 方法。

3. Java 实战:电脑配件组装与检测

假设一台电脑由 CPU内存显卡 组成。 我们要对电脑进行两种操作:

  1. 个人用户:查看配置信息。
  2. 企业采购:计算总价格(打折)。

3.1 代码实现

Step 1: 定义元素 (Element)

java
// 电脑组件接口
public interface ComputerPart {
    void accept(Visitor visitor);
}

Step 2: 具体元素 (ConcreteElement)

CPU

java
public class CPU implements ComputerPart {
    public String getName() { return "Intel i9-13900K"; }
    public double getPrice() { return 4000.0; }

    @Override
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }
}

内存

java
public class Memory implements ComputerPart {
    public String getName() { return "Corsair 32GB DDR5"; }
    public double getPrice() { return 800.0; }

    @Override
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }
}

显卡

java
public class GraphicsCard implements ComputerPart {
    public String getName() { return "NVIDIA RTX 4090"; }
    public double getPrice() { return 12000.0; }

    @Override
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }
}

Step 3: 定义访问者 (Visitor)

java
public interface Visitor {
    void visit(CPU cpu);
    void visit(Memory memory);
    void visit(GraphicsCard graphicsCard);
}

Step 4: 具体访问者 (ConcreteVisitor)

个人用户:查看配置

java
public class PersonalVisitor implements Visitor {
    @Override
    public void visit(CPU cpu) {
        System.out.println("CPU: " + cpu.getName());
    }

    @Override
    public void visit(Memory memory) {
        System.out.println("内存: " + memory.getName());
    }

    @Override
    public void visit(GraphicsCard graphicsCard) {
        System.out.println("显卡: " + graphicsCard.getName());
    }
}

企业采购:计算价格(企业打 8 折)

java
public class CorporateVisitor implements Visitor {
    private double totalPrice = 0.0;

    @Override
    public void visit(CPU cpu) {
        totalPrice += cpu.getPrice() * 0.8;
    }

    @Override
    public void visit(Memory memory) {
        totalPrice += memory.getPrice() * 0.8;
    }

    @Override
    public void visit(GraphicsCard graphicsCard) {
        totalPrice += graphicsCard.getPrice() * 0.8;
    }

    public double getTotalPrice() {
        return totalPrice;
    }
}

Step 5: 对象结构 (ObjectStructure)

java
public class Computer {
    private List<ComputerPart> parts = new ArrayList<>();

    public Computer() {
        parts.add(new CPU());
        parts.add(new Memory());
        parts.add(new GraphicsCard());
    }

    public void accept(Visitor visitor) {
        for (ComputerPart part : parts) {
            part.accept(visitor);
        }
    }
}

Step 6: 客户端

java
public class Client {
    public static void main(String[] args) {
        Computer computer = new Computer();

        // 1. 个人用户看配置
        System.out.println("--- 个人用户查看配置 ---");
        computer.accept(new PersonalVisitor());

        // 2. 企业采购算价格
        System.out.println("\n--- 企业采购核算成本 ---");
        CorporateVisitor corpVisitor = new CorporateVisitor();
        computer.accept(corpVisitor);
        System.out.println("企业采购总价:" + corpVisitor.getTotalPrice());
    }
}

输出结果

text
--- 个人用户查看配置 ---
CPU: Intel i9-13900K
内存: Corsair 32GB DDR5
显卡: NVIDIA RTX 4090

--- 企业采购核算成本 ---
企业采购总价:13440.0

4. 源码中的访问者模式

4.1 JDK Files.walkFileTree

Java NIO.2 提供了遍历文件树的功能。

java
Files.walkFileTree(Paths.get("."), new SimpleFileVisitor<Path>() {
    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
        System.out.println("Visit file: " + file);
        return FileVisitResult.CONTINUE;
    }
    
    @Override
    public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
        System.out.println("Visit dir: " + dir);
        return FileVisitResult.CONTINUE;
    }
});

这里文件系统结构是稳定的(文件、目录),但对文件的操作(删除、拷贝、搜索)是多变的,所以用了访问者模式。

4.2 Spring BeanDefinitionVisitor

Spring IOC 容器在解析 Bean 定义时,使用 BeanDefinitionVisitor 来遍历 BeanDefinition 中的各种属性(如 property values, constructor argument values),解析其中的占位符(${...})。

4.3 编译器设计 (Javac)

几乎所有的编译器(包括 Javac)在处理抽象语法树(AST)时都重度依赖访问者模式。

5. 优缺点与避坑指南

5.1 优点

  1. 扩展操作极其容易:想增加一个新的操作(比如“计算功耗”),只需要增加一个新的 PowerVisitor 类,不需要修改任何 Element 代码。符合开闭原则(针对操作)。
  2. 单一职责:相关的操作逻辑被集中在一个 Visitor 类中,而不是分散在各个 Element 类里。

5.2 缺点(非常致命)

  1. 增加新元素极其困难:如果在 Computer 里增加一个 HardDisk 类,你需要:
    • 修改 Visitor 接口,增加 visit(HardDisk)
    • 修改所有已存在的 ConcreteVisitor 类,实现 visit(HardDisk)
    • 这完全违背了开闭原则(针对数据结构)。
  2. 破坏封装:Visitor 为了进行操作,通常需要访问 Element 的内部状态(比如 price),这可能迫使 Element 暴露不该暴露的 getter/setter。

5.3 适用场景

只有当满足以下所有条件时,才考虑使用访问者模式:

  1. 数据结构非常稳定:元素类很少改变(如电脑配件就那几样,文件系统就文件和目录)。
  2. 操作非常频繁且易变:需要经常定义新的操作。
  3. 需要对不同类型的元素进行不同操作,且不希望这些操作污染元素类本身。

6. 总结

访问者模式是一把双刃剑。

  • 它让增加操作变得易如反掌。
  • 它让增加数据类型变得难如登天。* 所以,除非你是在写编译器或者处理像 XML/DOM 这样极其稳定的数据结构,否则慎用访问者模式。大多数业务系统里,数据结构往往是不稳定的。

7. 实验实操

design-patterns-web 项目中,我们提供了一个图形导出器的 Demo。 场景中有固定的图形集合(点、矩形、点),我们需要将它们导出为不同的格式。

  • 数据结构稳定:屏幕上方显示的图形列表(Dot, Rectangle, Dot)是固定的对象结构。
  • 操作变化:点击 "Export XML" 或 "Export JSON",实际上是应用了不同的访问者(XmlExportVisitorJsonExportVisitor)。
  • 双重分发:观察右侧的输出面板,相同的图形集合被转换成了完全不同的文本格式,而图形类本身并没有包含任何导出逻辑。 截图建议:点击 "Export JSON" 按钮,截取包含左侧控制按钮和右侧 JSON 代码输出的完整界面,展示访问者如何为对象结构添加新的操作。