访问者模式 (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),运行期根据接收者的实际类型决定。
访问者模式巧妙地利用了这两者,实现了双重动态分派的效果:
- 第一次分派:
element.accept(visitor)。根据 element 的实际类型,调用对应的accept方法。 - 第二次分派:
visitor.visit(this)。在accept内部,this的类型是确定的(比如Apple),所以会自动调用visitor.visit(Apple)方法。
3. Java 实战:电脑配件组装与检测
假设一台电脑由 CPU、内存、显卡 组成。 我们要对电脑进行两种操作:
- 个人用户:查看配置信息。
- 企业采购:计算总价格(打折)。
3.1 代码实现
Step 1: 定义元素 (Element)
// 电脑组件接口
public interface ComputerPart {
void accept(Visitor visitor);
}Step 2: 具体元素 (ConcreteElement)
CPU
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);
}
}内存
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);
}
}显卡
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)
public interface Visitor {
void visit(CPU cpu);
void visit(Memory memory);
void visit(GraphicsCard graphicsCard);
}Step 4: 具体访问者 (ConcreteVisitor)
个人用户:查看配置
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 折)
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)
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: 客户端
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());
}
}输出结果:
--- 个人用户查看配置 ---
CPU: Intel i9-13900K
内存: Corsair 32GB DDR5
显卡: NVIDIA RTX 4090
--- 企业采购核算成本 ---
企业采购总价:13440.04. 源码中的访问者模式
4.1 JDK Files.walkFileTree
Java NIO.2 提供了遍历文件树的功能。
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 优点
- 扩展操作极其容易:想增加一个新的操作(比如“计算功耗”),只需要增加一个新的
PowerVisitor类,不需要修改任何 Element 代码。符合开闭原则(针对操作)。 - 单一职责:相关的操作逻辑被集中在一个 Visitor 类中,而不是分散在各个 Element 类里。
5.2 缺点(非常致命)
- 增加新元素极其困难:如果在
Computer里增加一个HardDisk类,你需要:- 修改
Visitor接口,增加visit(HardDisk)。 - 修改所有已存在的
ConcreteVisitor类,实现visit(HardDisk)。 - 这完全违背了开闭原则(针对数据结构)。
- 修改
- 破坏封装:Visitor 为了进行操作,通常需要访问 Element 的内部状态(比如
price),这可能迫使 Element 暴露不该暴露的 getter/setter。
5.3 适用场景
只有当满足以下所有条件时,才考虑使用访问者模式:
- 数据结构非常稳定:元素类很少改变(如电脑配件就那几样,文件系统就文件和目录)。
- 操作非常频繁且易变:需要经常定义新的操作。
- 需要对不同类型的元素进行不同操作,且不希望这些操作污染元素类本身。
6. 总结
访问者模式是一把双刃剑。
- 它让增加操作变得易如反掌。
- 它让增加数据类型变得难如登天。* 所以,除非你是在写编译器或者处理像 XML/DOM 这样极其稳定的数据结构,否则慎用访问者模式。大多数业务系统里,数据结构往往是不稳定的。
7. 实验实操
在 design-patterns-web 项目中,我们提供了一个图形导出器的 Demo。 场景中有固定的图形集合(点、矩形、点),我们需要将它们导出为不同的格式。
- 数据结构稳定:屏幕上方显示的图形列表(Dot, Rectangle, Dot)是固定的对象结构。
- 操作变化:点击 "Export XML" 或 "Export JSON",实际上是应用了不同的访问者(
XmlExportVisitor或JsonExportVisitor)。 - 双重分发:观察右侧的输出面板,相同的图形集合被转换成了完全不同的文本格式,而图形类本身并没有包含任何导出逻辑。 截图建议:点击 "Export JSON" 按钮,截取包含左侧控制按钮和右侧 JSON 代码输出的完整界面,展示访问者如何为对象结构添加新的操作。