走进 Gang of Four 设计模式:访问者模式
说明:不仅告诉你"怎么用",更告诉你"为什么这样设计"
前置知识:Java 面向对象基础(接口、多态、重载)、双重分派概念
📊 设计模式分类总览
GoF 23 种设计模式可按两个维度交叉分类:类/对象 (处理方式) × 创建型/结构型/行为型(目的)。
| 维度 | 创建型(Creational) | 结构型(Structural) | 行为型(Behavioral) |
|---|---|---|---|
| 类(Class) (通过继承复用) | Factory Method 工厂方法 | Adapter(类适配器) | Interpreter(解释器) Template Method(模板方法) |
| 对象(Object) (通过组合/聚合复用) | Abstract Factory(抽象工厂) Builder(建造者) Prototype(原型) Singleton(单例) | Adapter(对象适配器) Bridge(桥接) Composite(组合) Decorator(装饰) Facade(外观) Flyweight(享元) Proxy(代理) | Chain of Resp.(责任链) Command(命令) Iterator(迭代器) Mediator(中介者) Memento(备忘录) Observer(观察者) State(状态) Strategy(策略) Visitor(访问者) |
本文档聚焦 行为型-对象模式 中的 Visitor(访问者),它是 GoF 23 种设计模式中最复杂、也是最难理解的模式之一,通过"双重分派"实现数据结构与业务操作的解耦。
📑 目录
- [模式概述 · 15 问深度分析](#模式概述 · 15 问深度分析)
- [Q1: 为什么需要?解决了什么问题?](#Q1: 为什么需要?解决了什么问题?)
- [Q2: 不用会有什么缺陷?](#Q2: 不用会有什么缺陷?)
- [Q3: 核心思想一句话](#Q3: 核心思想一句话)
- [Q4: 包含哪些角色?](#Q4: 包含哪些角色?)
- [Q5: 调用流程是怎样的?](#Q5: 调用流程是怎样的?)
- [Q6: 为什么这样设计?](#Q6: 为什么这样设计?)
- [Q7: 为什么用接口/抽象类/组合?](#Q7: 为什么用接口/抽象类/组合?)
- [Q8: 符合哪些面向对象原则?](#Q8: 符合哪些面向对象原则?)
- [Q9: 优缺点与额外成本](#Q9: 优缺点与额外成本)
- [Q10: 最适合/不该用的场景](#Q10: 最适合/不该用的场景)
- [Q11: 与其他模式的区别](#Q11: 与其他模式的区别)
- [Q12: 框架中的典型应用](#Q12: 框架中的典型应用)
- [Q13: 最小可运行版本](#Q13: 最小可运行版本)
- [Q14: 识别重构信号](#Q14: 识别重构信号)
- [Q15: 现代改进方案](#Q15: 现代改进方案)
- 框架源码实战分析
- [JDK NIO.2 FileVisitor](#JDK NIO.2 FileVisitor)
- [Spring BeanDefinitionVisitor](#Spring BeanDefinitionVisitor)
- [JDK Compiler API ElementVisitor](#JDK Compiler API ElementVisitor)
- [深度追问:Why / How / Trade-off / Evolution / Modern Practice](#深度追问:Why / How / Trade-off / Evolution / Modern Practice)
- 总结
- 参考文献
1. 模式概述 · 15 问深度分析
Q1: 为什么需要这个模式?它解决了什么问题?
在软件开发中,我们经常会遇到数据结构很稳定,但是作用在它之上的业务操作频繁变化的场景。
- 解决的问题 :传统的面向对象设计中,如果要给一个类层次结构(比如抽象类
Shape有Circle、Rectangle)增加一个新功能(比如exportToJson),你必须在每个实现类里都加上这个方法。如果过几天又要加exportToXml,又得把所有类改一遍。 - 核心痛点:访问者模式为了解决"在不改变已有数据结构的前提下,动态地为这些类定义新的操作"这一问题。它把"操作"从"数据结构"中抽离出来,封装成独立的访问者对象。
Q2: 如果不用这个模式,会有什么缺陷?
不用访问者模式,通常有两种替代方案,但它们都有严重的缺陷:
方案 A:直接在类中添加新方法
- 缺陷 :违反了开闭原则(OCP)。每次新增业务,都要修改底层的实体类,这可能引入 Bug,且导致实体类越来越臃肿,充斥着各种与核心业务无关的代码(如序列化、打日志、权限校验)。
方案 B:在外部使用 instanceof + 强转
java
for (Shape shape : shapes) {
if (shape instanceof Circle) { drawCircle((Circle)shape); }
else if (shape instanceof Rectangle) { drawRectangle((Rectangle)shape); }
}
- 缺陷 :违反了单一职责原则 和开闭原则 。代码中充斥着大量的
if-else,一旦增加新的Shape子类,所有写了instanceof的地方全部得改,极易遗漏。
Q3: 核心思想是什么?一句话如何概括?
- 核心思想 :将数据结构 与作用于结构上的操作解耦,使得操作可以独立演化。
- 一句话概括 :通过在数据类中预留一个
accept(Visitor)接口,将自身实例(this)传给访问者,由访问者来决定具体如何处理。
Q4: 它包含哪些角色?每个角色的职责是什么?
访问者模式包含 5 个核心角色:
- Visitor(抽象访问者) :声明了针对每个具体元素(Element)的
visit方法,形如visit(ConcreteElementA)。 - ConcreteVisitor(具体访问者) :实现
Visitor声明的接口,完成对每个具体元素的具体业务操作(如JsonExportVisitor、XmlExportVisitor)。 - Element(抽象元素) :定义一个
accept(Visitor)方法,接受一个访问者对象。 - ConcreteElement(具体元素) :实现
accept方法。几乎雷打不动地只有一行代码 :visitor.visit(this);。 - ObjectStructure(对象结构) :包含多个元素的集合(如
List<Element>),负责遍历这些元素并允许访问者访问。
Q5: 它们之间如何协作?调用流程是怎样的?
访问者模式的精髓在于双重分派(Double Dispatch)。
- 第一重分派 :客户端调用
element.accept(visitor)。由于多态,运行时决定调用哪一个具体元素 的accept方法。 - 第二重分派 :在
accept方法内部,调用visitor.visit(this)。由于this的类型在编译期就是确定的(就是当前具体元素类),Java 的重载(Overload)机制会准确找到对应参数的visit方法。
标准调用流程:
- 客户端创建具体访问者(如
CompressVisitor)。 - 客户端遍历对象结构(
ObjectStructure)中的每个元素。 - 元素触发
accept(visitor)→ 元素内部调用visitor.visit(this)→ 访问者执行具体逻辑。
Q6: 为什么要这样设计?每个角色存在的意义是什么?
- Element 的意义 :它像一个"接待员"。它不需要知道访问者具体要干嘛,它唯一的任务就是把访问者引进来,并把自己(
this)介绍给访问者。 - Visitor 的意义:它像一个"外包团队"。它把本属于 Element 的非核心功能外包了出来。这样,当需要换一种"玩法"(换个业务)时,只需要换一个外包团队(新的 ConcreteVisitor),不需要开除接待员(修改 Element)。
这种设计强制划分了"变"与"不变"的边界。
Q7: 为什么使用接口、抽象类、组合,而不是其他方式?
- 使用接口/抽象类(Visitor & Element) :为了利用多态 。客户端只需要面对统一的
Element接口和Visitor接口,而不需要关心具体实现。 - 使用组合(ObjectStructure 包含 Element 列表):因为元素通常是以树状或列表形式存在的组合体。通过组合,可以方便地用一个循环完成对整棵树/整个列表的"洗礼"(全量访问)。
- 为什么不能用纯反射或普通继承? 普通继承无法做到在不改动父类/子类的情况下增加新方法;而纯反射虽然能解决,但在大型系统中性能较差,且失去了编译期的类型安全检查。
Q8: 这种设计符合哪些面向对象原则?
- 符合:单一职责原则 (SRP):元素的类只负责维护核心数据结构,所有的周边业务(导出、计算、打印)都收拢到各自的 Visitor 中。
- 符合:开闭原则 (OCP)------对扩展开放,对修改关闭 :当需要增加新的业务操作时,只需要新增一个 Visitor 实现类,完全不需要修改现有的 Element 类。
- 违反了:开闭原则 (OCP) 的另一面 :如果需要增加一个新的 Element 子类,那是灾难性的。所有的 Visitor 接口和实现类全部要修改,增加对新 Element 的
visit支持。因此它只适合"元素结构极其稳定"的场景。
Q9: 它有哪些优点和局限性?会带来哪些额外成本?
- 优点 :
- 极好的扩展性(针对操作)。
- 职责单一,业务代码集中,避免类膨胀。
- 局限性/缺点 :
- 破坏封装:Visitor 往往需要访问 Element 的内部细节,被迫要求 Element 暴露一些原本应该 private 的属性或方法。
- 改动成本高:一旦元素类(Element)发生增加或删除,所有 Visitor 必须重构。
- 额外成本 :
- 代码复杂度和理解成本骤增(双重分派会让 debug 变得极其反人类,在代码里跳来跳去)。
Q10: 它最适合哪些场景?哪些场景不应该使用?
- 最适合的场景 :
- 数据结构高度稳定,但操作经常变动。(例如:编译器、AST 抽象语法树解析、报表生成器)。
- 一个对象结构包含很多不相关的操作,你想避免这些操作"污染"对象的类。
- 绝不应该使用的场景 :
- 数据结构经常变动。 如果你的业务还在频繁增加新的实体类(Element),用访问者模式就是自掘坟墓。
- 业务很简单,用几个简单的
if-else或普通多态就能搞定的场景。
Q11: 它与容易混淆的其他设计模式有什么区别和联系?
- 与策略模式 (Strategy) 的区别 :
- 策略模式关注的是同一个行为的不同算法实现(比如不同的支付方式),针对的是单个对象。
- 访问者模式关注的是不同类型对象上的多种不同操作,针对的是一个复杂的对象结构。
- 与组合模式 (Composite) 的联系 :
- 它们通常是结对出现的。组合模式用于构建复杂的树形结构(如文件系统、XML节点),而访问者模式用于遍历并处理这个树形结构中的各类节点。
Q12: Java/JDK、Spring、MyBatis、Tomcat 中有哪些典型应用?
- JDK (NIO.2) :
java.nio.file.FileVisitor。用于遍历文件目录树。你可以实现这个接口来决定在访问文件(visitFile)或进入目录(preVisitDirectory)时做什么。 - JDK (Compiler API) :
javax.lang.model.element.ElementVisitor。在 javac 编译器的 AST(抽象语法树)解析中,大量使用了访问者模式来对语法树节点进行语法检查、代码生成。 - Spring :
BeanDefinitionVisitor。Spring 容器在解析配置文件或注解生成BeanDefinition后,会使用该访问者去遍历并解析字符串占位符(如${jdbc.url}),将其替换为真实值。
Q13: 如何从零实现一个最小可运行版本?
假设我们要写一个商业计算系统,有两类客户:企业客户(Enterprise)和个人客户(Individual)。我们要对他们进行"服务费计算"和"发送营销邮件"两种操作。
java
import java.util.ArrayList;
import java.util.List;
// 1. 抽象访问者
interface Visitor {
void visit(EnterpriseCustomer customer);
void visit(IndividualCustomer customer);
}
// 2. 抽象元素
interface Customer {
void accept(Visitor visitor);
}
// 3. 具体元素:企业客户
class EnterpriseCustomer implements Customer {
public String companyName = "Google";
@Override
public void accept(Visitor visitor) {
visitor.visit(this); // 第二重分派
}
}
// 3. 具体元素:个人客户
class IndividualCustomer implements Customer {
public String name = "Jack";
@Override
public void accept(Visitor visitor) {
visitor.visit(this); // 第二重分派
}
}
// 4. 具体访问者 A:计算服务费
class ServiceFeeVisitor implements Visitor {
@Override
public void visit(EnterpriseCustomer customer) {
System.out.println("为企业客户 " + customer.companyName + " 计算阶梯服务费。");
}
@Override
public void visit(IndividualCustomer customer) {
System.out.println("为个人客户 " + customer.name + " 计算固定服务费。");
}
}
// 4. 具体访问者 B:发送营销广告
class MarketingVisitor implements Visitor {
@Override
public void visit(EnterpriseCustomer customer) {
System.out.println("给 " + customer.companyName + " 发送企业版 SaaS 广告。");
}
@Override
public void visit(IndividualCustomer customer) {
System.out.println("给 " + customer.name + " 发送个人优惠券。");
}
}
// 5. 客户端与对象结构
public class VisitorPatternDemo {
public static void main(String[] args) {
List<Customer> customers = new ArrayList<>();
customers.add(new EnterpriseCustomer());
customers.add(new IndividualCustomer());
// 运用操作 1:计算费用
Visitor feeCalc = new ServiceFeeVisitor();
for (Customer c : customers) {
c.accept(feeCalc); // 第一重分派
}
System.out.println("---------------------------------");
// 运用操作 2:发送营销(无需修改 Customer 类,直接扩展 Visitor)
Visitor marketing = new MarketingVisitor();
for (Customer c : customers) {
c.accept(marketing);
}
}
}
Q14: 如何在现有项目中识别可以使用该模式的地方?
如果你的代码中出现了以下信号,说明你可以考虑重构为访问者模式了:
- 频繁出现的
instanceof或强转 :在一个循环中,遍历一个基类集合,然后不停地用if (item instanceof SubClassA)来针对不同子类做不同的业务逻辑。 - 实体类臃肿且职责不单一 :一个本该纯粹的数据类(如
User实体),里面不仅有数据,还包含了exportPdf()、validateData()、pushToKafka()等大量散乱的操作。 - 频繁因为新需求修改同一批类:每次产品加新功能,你都要把那几个核心实体类全部打开改一遍,哪怕只是加一个微不足道的小方法。
Q15: 如果重新设计这个模式,在今天会有哪些改进?
在现代 Java(Java 8 到 Java 21+)中,传统的访问者模式很多时候已经成了"过时设计"。我们可以用更优雅的方式来实现它:
改进一:利用 Java 8+ 的 Lambda 和函数式接口
如果访问者的操作很简单,不需要定义一堆 Visitor 实现类。我们可以直接用 Map<Class<?>, Consumer<Object>> 来动态注册和分派操作:
java
// 现代流式/函数式动态分派替代传统 Visitor
public class FunctionalVisitor {
private final Map<Class<?>, Consumer<Object>> actions = new HashMap<>();
public <T> FunctionalVisitor register(Class<T> type, Consumer<T> action) {
actions.put(type, obj -> action.accept(type.cast(obj)));
return this;
}
public void visit(Object obj) {
if (actions.containsKey(obj.getClass())) {
actions.get(obj.getClass()).accept(obj);
}
}
}
// 使用时:
new FunctionalVisitor()
.register(EnterpriseCustomer.class, c -> System.out.println(c.companyName))
.register(IndividualCustomer.class, c -> System.out.println(c.name))
.visit(customer);
改进二:Java 21+ 的模式匹配(Pattern Matching)和密封类(Sealed Classes)
现代 Java(以及 Kotlin/Scala)引入的模式匹配(Pattern Matching for switch)和密封类(Sealed Classes),从语言底层彻底解决了访问者模式要解决的问题。
java
// 1. 使用 sealed 关键字限制子类,明确告诉编译器:Customer 只有这两种子类!
public sealed interface Customer permits EnterpriseCustomer, IndividualCustomer {}
public record EnterpriseCustomer(String companyName) implements Customer {}
public record IndividualCustomer(String name) implements Customer {}
// 2. 完美的解耦业务逻辑:编译器会自动帮你做"开闭原则"的检查
public class ModernVisitorDemo {
public static void handleCustomer(Customer customer) {
// Java 21 模式匹配:强类型、无显式强转、支持编译期完备性检查
switch (customer) {
case EnterpriseCustomer ec -> System.out.println("企业: " + ec.companyName());
case IndividualCustomer ic -> System.out.println("个人: " + ic.name());
// 如果未来你在 sealed 接口里加了新子类,这里如果没有写对应的 case,编译器会直接报错!
}
}
}
总结 :在现代开发中,优先使用 Java 21 的密封类+模式匹配。只有在无法升级 JDK 版本、或者对象结构极度复杂(如深度树状的 AST 解析)时,才考虑经典的 GOF 访问者模式。
2. 框架源码实战分析
为了让你真正看透访问者模式在工业级框架中的玩法,我们直接切入这三个经典案例的源码层级。在这些框架中,为了适应复杂的业务,它们并没有生搬硬套标准的 GoF 结构,而是做了不同程度的变通。下面我们结合核心源码和调用链一步步拆解。
2.1 JDK NIO.2:java.nio.file.FileVisitor
场景背景 :操作系统中的文件系统是一个典型的树状数据结构(由文件和目录组合而成)。这个结构非常稳定(只有文件、目录两种基础形态),但你对它的操作却千变万化:拷贝、删除、查找、压缩等。
访问者接口
JDK 定义了访问者需要实现的 4 个行为,分别对应遍历树形结构的不同生命周期:
java
public interface FileVisitor<T> {
// 进入目录前
FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs) throws IOException;
// 访问文件时
FileVisitResult visitFile(T file, BasicFileAttributes attrs) throws IOException;
// 访问文件失败时
FileVisitResult visitFileFailed(T file, IOException exc) throws IOException;
// 离开目录后
FileVisitResult postVisitDirectory(T dir, IOException exc) throws IOException;
}
客户端与数据结构:Files.walkFileTree
这是驱动整个遍历的"对象结构(ObjectStructure)"。其底层核心逻辑在 FileTreeWalker 类中:
java
// Files.java 中的入口
public static Path walkFileTree(Path start, Set<FileVisitOption> options, int maxDepth, FileVisitor<? super Path> visitor) {
// 1. 构建一个内部的树形遍历器
try (FileTreeWalker walker = new FileTreeWalker(options, maxDepth)) {
FileTreeWalker.Event ev = walker.walk(start);
do {
// 2. 根据当前的遍历事件,让 visitor 执行对应的 visit 方法
switch (ev.type()) {
case ENTRY: // 遇到文件
IOException ioe = ev.ioeException();
if (ioe == null) {
assert ev.attributes() != null;
// 相当于 visitor.visit(this),直接显式分派
FileVisitResult result = visitor.visitFile(ev.file(), ev.attributes());
if (result == FileVisitResult.TERMINATE) return start;
}
break;
case START_DIRECTORY: // 遇到目录
FileVisitResult result = visitor.preVisitDirectory(ev.file(), ev.attributes());
if (result == FileVisitResult.SKIP_SUBTREE || result == FileVisitResult.SKIP_SIBLINGS) {
walker.pop();
}
break;
case END_DIRECTORY: // 目录遍历结束
visitor.postVisitDirectory(ev.file(), ev.ioeException());
break;
}
ev = walker.next(); // 驱动走向下一个节点
} while (ev != null);
}
return start;
}
为什么这样设计? JDK NIO.2 中没有强求 Path 实现 Element 接口并在里面写 visitor.visit(this)。这是因为 Path 本身是一个单纯的字符串路径表示,它不持有底层操作系统的文件树结构 。真正的"结构"是由 FileTreeWalker 通过操作系统底层 API 动态读取出来的。因此,Files.walkFileTree 充当了控制中心,它从操作系统读取出当前的 Event,然后代替元素直接调用了 visitor 对应的方法。
2.2 Spring:BeanDefinitionVisitor
场景背景 :Spring 在启动时,会将 XML 或注解解析为 BeanDefinition 树形配置。有些属性值包含占位符(如 ${db.password})。Spring 需要在不破坏 BeanDefinition 内部结构的情况下,遍历并替换这些字符串。
访问者:BeanDefinitionVisitor
Spring 的做法非常硬核,它没有定义一个具体的 Visitor 接口,而是直接把 BeanDefinitionVisitor 做成了一个具备具体遍历逻辑的工具类 。它把具体的"字符串处理操作"通过回调(StringValueResolver)传进来。
java
public class BeanDefinitionVisitor {
private final StringValueResolver valueResolver; // 具体的行为由它定义(策略模式的融合)
public BeanDefinitionVisitor(StringValueResolver valueResolver) {
this.valueResolver = valueResolver;
}
// 核心的访问入口
public void visitBeanDefinition(BeanDefinition beanDefinition) {
visitParentName(beanDefinition);
visitBeanClassName(beanDefinition);
visitPropertyValues(beanDefinition.getPropertyValues());
// ... 访问其他组件
}
// 具体到某一个内部元素的处理
protected void visitPropertyValues(MutablePropertyValues pvs) {
PropertyValue[] pvArray = pvs.getPropertyValues();
for (PropertyValue pv : pvArray) {
Object val = pv.getValue();
if (val instanceof String strVal) {
// 使用外部传进来的解析器处理字符串
String resolvedValue = resolveStringValue(strVal);
if (!resolvedValue.equals(strVal)) {
pvs.addPropertyValue(pv.getName(), resolvedValue);
}
} else if (val instanceof BeanDefinition bd) {
// 如果属性值本身又是一个内部 Bean(嵌套结构),递归访问!
visitBeanDefinition(bd);
}
}
}
protected String resolveStringValue(String strVal) {
return this.valueResolver.resolveStringValue(strVal);
}
}
调用上下文:PropertyPlaceholderConfigurer
什么时候触发这个访问者?在 BeanFactoryPostProcessor 执行期间:
java
protected void processProperties(ConfigurableListableBeanFactory beanFactoryToProcess, Properties props) {
StringValueResolver valueResolver = new PlaceholderResolvingStringValueResolver(props);
BeanDefinitionVisitor visitor = new BeanDefinitionVisitor(valueResolver);
String[] beanNames = beanFactoryToProcess.getBeanDefinitionNames();
for (String beanName : beanNames) {
BeanDefinition bd = beanFactoryToProcess.getBeanDefinition(beanName);
try {
visitor.visitBeanDefinition(bd);
} catch (BeanDefinitionStoreException ex) {
throw new BeanDefinitionValidationException(beanName, ex.getMessage(), ex);
}
}
}
为什么这样设计? Spring 的 BeanDefinition 内部结构非常复杂且零散(有 PropertyValue、ConstructorArgumentValues、String、RuntimeBeanReference 等等)。如果严格按照 GoF 模式,Spring 需要在每一个细小的类里都加上 accept(Visitor),这会严重污染配置类的纯粹性。Spring 的变通方案是:把遍历的硬编码逻辑全部收拢到 BeanDefinitionVisitor 内部 ,利用 instanceof 和类型判断来做分派(在结构固定的框架设计里,极大地减少了类的数量)。
2.3 JDK Compiler API:javax.lang.model.element.ElementVisitor
场景背景 :这是 Java 编译器(javac)底层的核心设计。编译器将你写的 Java 代码解析为一棵抽象语法树(AST) 。代码里有:类(TypeElement)、方法(ExecutableElement)、变量(VariableElement)、包(PackageElement)。编译器需要对这棵树做:注解处理(Annotation Processing)、语法检查、编译成字节码。
这是最标准的、教科书般的 GoF 访问者模式实现。
抽象访问者接口
java
public interface ElementVisitor<R, P> {
R visitPackage(PackageElement e, P p);
R visitType(TypeElement e, P p);
R visitVariable(VariableElement e, P p);
R visitExecutable(ExecutableElement e, P p);
R visitTypeParameter(TypeParameterElement e, P p);
}
抽象元素接口
java
public interface Element {
<R, P> R accept(ElementVisitor<R, P> v, P p);
}
具体元素实现类
java
public class VariableElementImpl extends ElementImpl implements VariableElement {
@Override
public <R, P> R accept(ElementVisitor<R, P> v, P p) {
return v.visitVariable(this, p);
}
}
实际应用:自定义注解处理器
当你在写 Lombok、MapStruct 或自定义注解处理器时,你就是在写一个 ConcreteVisitor:
java
public class MyAnnotationProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(MyGetter.class);
ElementVisitor<Void, Void> getterGenerator = new SimpleElementVisitor21<Void, Void>() {
@Override
public Void visitVariable(VariableElement e, Void p) {
System.out.println("检测到字段: " + e.getSimpleName() + ",正在用 ASM 生成其 Getter 字节码...");
return null;
}
@Override
public Void visitType(TypeElement e, Void p) {
System.out.println("错误:@MyGetter 不能加在类 " + e.getSimpleName() + " 上!");
return null;
}
};
for (Element element : elements) {
element.accept(getterGenerator, null);
}
return true;
}
}
为什么这样设计? Javac 必须采用最标准的双重分派。因为 Java 语言的语法结构(包、类、方法、字段)在过去几十年中是极其极其稳定的 。但是围绕着这些语法树节点要做的事情(语法高亮、代码检查、代码生成、代码混淆、IDE 提示)是无限扩展且天天在变的。通过这种标准的双重分派,全世界的开发者都可以通过 ElementVisitor 随意扩展 javac 的功能,而不需要去修改 JDK 编译器的一行源码。
三者设计演变的对比
| 框架案例 | 访问者角色 | 元素角色 | 是标准 GoF吗? | 为什么做此种改变? |
|---|---|---|---|---|
| JDK NIO.2 | FileVisitor 接口 |
隐藏在 FileTreeWalker 内部的操作系统文件事件 |
❌(迭代器+分派结合) | Path 只是字符串,无法让其持有 accept 逻辑 |
| Spring | BeanDefinitionVisitor 实体类 |
BeanDefinition 及其内部属性组件 |
❌(遍历与访问逻辑合二为一) | 避免对繁多的配置项实体类进行入侵 |
| JDK Javac | ElementVisitor 接口 |
PackageElement, TypeElement 等 |
✅(严格双重分派) | 语法树结构绝对稳定,需要对全球开发者提供无侵入扩展能力 |
3. 深度追问
3.1 Why(为什么):解决哪个根本矛盾?
访问者模式解决的根本矛盾是:"数据结构的稳定性"与"业务操作的易变性"之间的矛盾。
在面向对象设计(OO)中,类型和行为通常是绑定在一起的。当系统面临组织架构、语法树、文件系统等天生结构非常稳定、但上层业务极其频繁变动的场景时,传统 OO 遭遇了巨大挑战。
- 封装的困境:如果把所有业务操作(如报表导出、权限检查、数据审计)都写在结构节点类里,类会迅速膨胀,违反单一职责原则(SRP)。
- 扩展的灾难:每次新增一个操作,都要纵向修改所有的节点类,这直接打破了开闭原则(OCP)对修改关闭的红线。
访问者模式的底层本质,就是将"多态的分派权"从底层数据结构中剥离出来,移交给上层业务,从而在不触动数据结构的前提下,获得无限扩展业务行为的能力。
3.2 How(怎么做):对象关系与交互机制
访问者模式的核心精髓在于双重分派(Double Dispatch)机制。
交互机制解构:
- 静态绑定(Overload)与动态绑定(Polymorphism)的错位 :Java 等语言支持单分派------在运行时根据接收者的实际类型决定调用哪个方法(多态);但方法的参数类型在编译期就已经静态绑定了(重载)。
- 第一重分派(谁来接待) :客户端持有一个抽象元素引用(如
Element e = new ConcreteElementA()),调用e.accept(visitor)。此时利用多态 ,运行时准确定位到ConcreteElementA的accept方法。 - 第二重分派(找谁服务) :在
ConcreteElementA的accept方法内部,执行visitor.visit(this)。关键点 :此时的this在编译期就是确定的ConcreteElementA类型。因此,编译器能够精准地将该调用绑定到Visitor接口中visit(ConcreteElementA)这个重载方法上。
通过这两次握手,系统在不需要知道具体元素类型、也不需要知道具体访问者类型的盲拆状态下,精准完成了"特定元素"与"特定算法"的完美匹配。
3.3 Trade-off(代价):牺牲了什么换取什么?
它牺牲了(付出代价):
- 牺牲了对"数据结构"扩展的灵活性 :如果系统要增加一个新元素
ConcreteElementC,所有的 Visitor 接口和几十个具体 Visitor 实现类全部得重构,增加visit(ConcreteElementC)方法。 - 牺牲了良好的封装性:为了让外包的 Visitor 能够完成业务,Element 必须被迫通过 getter 暴露原本应该隐藏的内部私有状态和属性。
- 牺牲了代码的可读性:调用链在 Element 和 Visitor 之间来回弹跳,Debug 时极难跟踪。
它换取了(获得收益):
- 获得了对"业务操作"近乎完美的开闭原则(OCP)支持 :增加一个全新的业务,只需要开辟一个新类实现
Visitor即可,底层结构类连一个标点符号都不用改。 - 获得了极高的职责单一性(SRP):所有的元素类退化为纯粹的数据载体,繁杂的周边业务被归拢到各自独立的 Visitor 中。
3.4 Evolution(演化):从何而来,如何变体?
演化路径:
- 硬编码
if-else/instanceof(起点) :最初开发者在遍历集合时,用instanceof判断类型并强转。随着子类增加,代码变成难以维护的焦油坑。 - 普通多态(纵向扩展) :为了消除
instanceof,将方法抽象到基类中,每个子类自己实现。结果导致子类严重膨胀,充斥着各种非核心的业务代码。 - 标准 GoF 访问者模式(解耦) :为了将非核心代码抽离,发明了双重分派的
accept/visit结构,将行为横向外包。
历史变体:
- 不退出的访问者(Acyclic Visitor):打破了 Visitor 接口与所有子类的强耦合,允许动态增加 Element 却不影响不相关的 Visitor。
- 反射访问者(Reflective Visitor) :在 Visitor 内部通过反射动态匹配,取消了 Element 的
accept方法,但牺牲了类型安全。
3.5 Modern Practice(现代实践):是否被时代抛弃?
函数式编程(FP)与 Lambda 的冲击: 通过 Map<Class<?>, Consumer<Object>> 动态注册不同类型的处理行为,彻底消除了显式的 accept 接口。
依赖注入(DI)与微服务架构: 我们倾向于使用贫血模型,将数据通过 RPC/MQ 传输,在各自的服务内部进行独立处理。组件之间的解耦通过配置中心、服务发现和消息驱动完成,不再依赖类层面的双重分派。
模式匹配(Pattern Matching): Java 21+ 引入了密封类(Sealed Classes)与 switch 模式匹配,编译器会检查所有子类是否都被 case 覆盖,如果新增子类而遗漏了 case,编译直接报错------这是给访问者模式盖上棺材板的核心技术。
结论: 传统 GoF 访问者模式的精髓(双重分派)在现代开发中大部分已经被"密封类 + 模式匹配"以及"函数式响应"所取代。但它的**核心思想(数据与行为分离、对操作开放对结构关闭)**依然长存,在编译器前端(AST 解析)、大型复杂低代码平台的规则引擎拓扑树解析等领域,标准的访问者模式或其变体依然是不可替代的利器。
4. 总结
核心要点
- 访问者模式通过双重分派实现数据与操作的解耦------数据结构稳定、操作易变时使用
- 五角色协作:Visitor → ConcreteVisitor → Element → ConcreteElement → ObjectStructure
- 核心代价 :新增元素子类时需要修改所有访问者,因此只适合元素结构极其稳定的场景
- 工业级应用三步走:
FileVisitor(文件遍历)→BeanDefinitionVisitor(属性占位符替换)→ElementVisitor(编译器 AST 处理) - 在现代 Java 中,密封类 + 模式匹配 和函数式分派可以更优雅地替代经典访问者模式
一句话
访问者模式就是"数据结构不动,操作上门"------元素类只负责开门(accept),访问者进来干活(visit)。
5. 参考文献
1 GAMMA E, HELM R, JOHNSON R, et al. Design Patterns: Elements of Reusable Object-Oriented SoftwareM. Boston: Addison-Wesley, 1994.
2 BLOCH J. Effective JavaM. 3rd ed. Boston: Addison-Wesley, 2018.
3 Oracle. Java Language Updates (Pattern Matching)EB/OL. https://docs.oracle.com/en/java/javase/21/language/, 2024.
4 SPRING. Spring BeanFactoryPostProcessorEB/OL. https://docs.spring.io/spring-framework/docs/current/javadoc-api/, 2024.