走进 Gang of Four 设计模式:访问者模式

走进 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 种设计模式中最复杂、也是最难理解的模式之一,通过"双重分派"实现数据结构与业务操作的解耦。


📑 目录

  1. [模式概述 · 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: 现代改进方案)
  2. 框架源码实战分析
    • [JDK NIO.2 FileVisitor](#JDK NIO.2 FileVisitor)
    • [Spring BeanDefinitionVisitor](#Spring BeanDefinitionVisitor)
    • [JDK Compiler API ElementVisitor](#JDK Compiler API ElementVisitor)
  3. [深度追问:Why / How / Trade-off / Evolution / Modern Practice](#深度追问:Why / How / Trade-off / Evolution / Modern Practice)
  4. 总结
  5. 参考文献

1. 模式概述 · 15 问深度分析

Q1: 为什么需要这个模式?它解决了什么问题?

在软件开发中,我们经常会遇到数据结构很稳定,但是作用在它之上的业务操作频繁变化的场景。

  • 解决的问题 :传统的面向对象设计中,如果要给一个类层次结构(比如抽象类 ShapeCircleRectangle)增加一个新功能(比如 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 个核心角色:

  1. Visitor(抽象访问者) :声明了针对每个具体元素(Element)的 visit 方法,形如 visit(ConcreteElementA)
  2. ConcreteVisitor(具体访问者) :实现 Visitor 声明的接口,完成对每个具体元素的具体业务操作(如 JsonExportVisitorXmlExportVisitor)。
  3. Element(抽象元素) :定义一个 accept(Visitor) 方法,接受一个访问者对象。
  4. ConcreteElement(具体元素) :实现 accept 方法。几乎雷打不动地只有一行代码visitor.visit(this);
  5. ObjectStructure(对象结构) :包含多个元素的集合(如 List<Element>),负责遍历这些元素并允许访问者访问。

Q5: 它们之间如何协作?调用流程是怎样的?

访问者模式的精髓在于双重分派(Double Dispatch)

  • 第一重分派 :客户端调用 element.accept(visitor)。由于多态,运行时决定调用哪一个具体元素accept 方法。
  • 第二重分派 :在 accept 方法内部,调用 visitor.visit(this)。由于 this 的类型在编译期就是确定的(就是当前具体元素类),Java 的重载(Overload)机制会准确找到对应参数的 visit 方法。

标准调用流程:

  1. 客户端创建具体访问者(如 CompressVisitor)。
  2. 客户端遍历对象结构(ObjectStructure)中的每个元素。
  3. 元素触发 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: 它最适合哪些场景?哪些场景不应该使用?

  • 最适合的场景
    1. 数据结构高度稳定,但操作经常变动。(例如:编译器、AST 抽象语法树解析、报表生成器)。
    2. 一个对象结构包含很多不相关的操作,你想避免这些操作"污染"对象的类。
  • 绝不应该使用的场景
    1. 数据结构经常变动。 如果你的业务还在频繁增加新的实体类(Element),用访问者模式就是自掘坟墓。
    2. 业务很简单,用几个简单的 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(抽象语法树)解析中,大量使用了访问者模式来对语法树节点进行语法检查、代码生成。
  • SpringBeanDefinitionVisitor。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: 如何在现有项目中识别可以使用该模式的地方?

如果你的代码中出现了以下信号,说明你可以考虑重构为访问者模式了:

  1. 频繁出现的 instanceof 或强转 :在一个循环中,遍历一个基类集合,然后不停地用 if (item instanceof SubClassA) 来针对不同子类做不同的业务逻辑。
  2. 实体类臃肿且职责不单一 :一个本该纯粹的数据类(如 User 实体),里面不仅有数据,还包含了 exportPdf()validateData()pushToKafka() 等大量散乱的操作。
  3. 频繁因为新需求修改同一批类:每次产品加新功能,你都要把那几个核心实体类全部打开改一遍,哪怕只是加一个微不足道的小方法。

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 内部结构非常复杂且零散(有 PropertyValueConstructorArgumentValuesStringRuntimeBeanReference 等等)。如果严格按照 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)机制。

交互机制解构:

  1. 静态绑定(Overload)与动态绑定(Polymorphism)的错位 :Java 等语言支持单分派------在运行时根据接收者的实际类型决定调用哪个方法(多态);但方法的参数类型在编译期就已经静态绑定了(重载)。
  2. 第一重分派(谁来接待) :客户端持有一个抽象元素引用(如 Element e = new ConcreteElementA()),调用 e.accept(visitor)。此时利用多态 ,运行时准确定位到 ConcreteElementAaccept 方法。
  3. 第二重分派(找谁服务) :在 ConcreteElementAaccept 方法内部,执行 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(演化):从何而来,如何变体?

演化路径:

  1. 硬编码 if-else / instanceof(起点) :最初开发者在遍历集合时,用 instanceof 判断类型并强转。随着子类增加,代码变成难以维护的焦油坑。
  2. 普通多态(纵向扩展) :为了消除 instanceof,将方法抽象到基类中,每个子类自己实现。结果导致子类严重膨胀,充斥着各种非核心的业务代码。
  3. 标准 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.

相关推荐
雪碧聊技术1 小时前
一文深入理解 Java 函数式编程思想
java·函数式编程
AIGS0011 小时前
突破语义鸿沟:从向量空间JBoltAI看企业大脑构建逻辑
java·大数据·人工智能·人工智能ai大模型应用
琢磨先生David2 小时前
OpenJDK 与 Oracle JDK 完整区别
java
倔强的石头1062 小时前
企业智能设备运维管理系统:靠飞算 JavaAI,告别 Java 低效搬砖日常
java
深盾科技_Virbox2 小时前
软件授权工具静默安装实践
java·运维·数据库·安全·软件需求
qq_452396232 小时前
第二篇:《Jenkins 从零搭建:安装、配置与第一个 Pipeline》
java·运维·jenkins
AI人工智能+电脑小能手2 小时前
【大白话说Java面试题 第172题】【07_Redis篇】第8题:`setnx` 做分布式锁存在的问题
java·redis·分布式锁·死锁·setnx
折哥的程序人生 · 物流技术专研2 小时前
Java 23 种设计模式:从踩坑到精通 | 番外:代理 vs 装饰器 —— Spring AOP 到底用了哪个?
java·代理模式·装饰器模式·java面试·java设计模式·springaop·从踩坑到精通
喜欢打篮球的普通人3 小时前
LLVM后端指令选择
android·java·javascript