java-design-patterns 仓库 Acyclic Visitor(无环访问者)模式深度解析:在不破坏类层次的前提下为对象树动态扩展操作

原创2026-09-30 16:29:271,366 阅读
文章标签:示例工程教程

java-design-patterns 仓库 Acyclic Visitor(无环访问者)模式深度解析:在不破坏类层次的前提下为对象树动态扩展操作

Acyclic Visitor(无环访问者)是经典 GoF Visitor 模式的一种变体,它的核心价值在于:允许开发者向一个既有类层次新增操作(函数),既不必修改该层次中的任何类,也不会像传统 Visitor 那样在访问者与元素之间形成循环依赖。本文以 java-design-patterns 仓库中 acyclic-visitor 模块 的完整源码、测试与德语版技术文档(localization/de/acyclic-visitor/README.md)为骨架,从设计意图、代码结构、调用流程到适用边界逐层拆解,读完即可在自有项目中落地这一可扩展、可维护的行为型模式。

一、模式意图:为什么需要"无环"的访问者

在传统 Visitor 模式中,Visitor 基类需要为元素层次中的每一个具体类声明一个 visit(ConcreteElement) 方法。这意味着:

  • Visitor 接口必须依赖元素层次中的所有具体类(如 Zoom、Hayes);
  • 而每个元素类的 accept 方法又依赖 Visitor 接口;
  • 元素层次每新增一个子类,所有已有 Visitor 都必须同步新增 visit 方法并重新编译——否则会破坏"开闭原则"。

Acyclic Visitor 通过一个退化的(degenerate)访问者基类打破这一僵局:ModemVisitor 接口本身不声明任何 visit 方法,从而完全不依赖被访问的类层次;每个元素具体类的 visit 方法被拆分到各自的子接口中。这样依赖图始终是无环的,层次结构与访问者彼此解耦。

从源码注释可以确认这一设计动机(App.java):

The Acyclic Visitor pattern allows new functions to be added to existing class hierarchies without affecting those hierarchies, and without creating the dependency cycles that are inherent to the GoF Visitor pattern, by making the Visitor base class degenerate.

WikiWikiWeb 对它的经典定义也被原文档引用:

Acyclic Visitor 允许向既有类层次新增函数,无需改动该层次,也不会产生传统 Visitor 模式固有的依赖循环。

一个来自现实世界的类比:博物馆导游系统

想象一座收藏着绘画、雕塑、历史文物的博物馆,配备人工导游、音频导览、VR 导览等多种导览方式。每当引入一种新导览,如果必须为每一件展品修改代码,系统将迅速僵化。Acyclic Visitor 的做法是:让每一种导览各自实现与它关心的展品类型对应的访问接口,展品只需判断"这位导游是否认识我",认识就接待、不认识就礼貌拒绝。于是新增导游永远不需要改动任何展品。

二、程序示例:Modem 调制解调器层次上的配置算法

仓库示例构建了一个"调制解调器(Modem)"类层次,由外部算法根据过滤条件(Unix 或 DOS 兼容性)对其进行访问配置。整个示例共 10 个源码文件,全部位于 acyclic-visitor/src/main/java/com/iluwatar/acyclicvisitor/。

2.1 被访问的元素层次

原文档给出的是抽象类形态:

public abstract class Modem {
    public abstract void accept(ModemVisitor modemVisitor);
}

public class Zoom extends Modem {
    @Override
    public void accept(ModemVisitor modemVisitor) {
        if (modemVisitor instanceof ZoomVisitor) {
            ((ZoomVisitor) modemVisitor).visit(this);
        } else {
            LOGGER.info("Only ZoomVisitor is allowed to visit Zoom modem");
        }
    }
}

public class Hayes extends Modem {
    @Override
    public void accept(ModemVisitor modemVisitor) {
        if (modemVisitor instanceof HayesVisitor) {
            ((HayesVisitor) modemVisitor).visit(this);
        } else {
            LOGGER.info("Only HayesVisitor is allowed to visit Hayes modem");
        }
    }
}

需要说明的是,当前仓库源码已将 Modem 从抽象类演进为接口(见 Modem.java 注释 "converted to an interface"),但 accept 方法的职责完全一致:作为元素方,它不直接持有任何具体 Visitor 类型,而是用 instanceof 判断来访者是否实现了自己对应的访问者子接口——这正是"无环"的关键一步。Zoom(Zoom.java)与 Hayes(Hayes.java)的 toString() 分别返回 "Zoom modem" 与 "Hayes modem",供日志输出使用。

2.2 访问者接口层次:退化的基类 + 按元素拆分的子接口

// 退化基类:不声明任何 visit 方法,因此不依赖被访问层次
public interface ModemVisitor {
}

public interface HayesVisitor extends ModemVisitor {
    void visit(Hayes hayes);
}

public interface ZoomVisitor extends ModemVisitor {
    void visit(Zoom zoom);
}

// 便捷组合接口:当访问者需要拜访所有 Modem 类型时使用
public interface AllModemVisitor extends ZoomVisitor, HayesVisitor {
}

对应源码见 ModemVisitor.java、HayesVisitor.java、ZoomVisitor.java 与 AllModemVisitor.java。这里的层级关系是:

  • ModemVisitor 是标记型退化基类,打破循环依赖的支点;
  • HayesVisitor / ZoomVisitor 各自只声明与自己对应的 visit 方法;
  • AllModemVisitor 通过多接口继承,为"想拜访所有 Modem"的访问者提供语法便利——ConfigureForDosVisitor 就实现了它。

2.3 具体访问者:按需实现,而非全量实现

// 同时处理 Hayes 与 Zoom(实现 AllModemVisitor)
public class ConfigureForDosVisitor implements AllModemVisitor {
    @Override
    public void visit(Hayes hayes) {
        LOGGER.info(hayes + " used with Dos configurator.");
    }
    @Override
    public void visit(Zoom zoom) {
        LOGGER.info(zoom + " used with Dos configurator.");
    }
}

// 只处理 Zoom,无需为 Hayes 实现 visit —— 传统 Visitor 做不到这一点
public class ConfigureForUnixVisitor implements ZoomVisitor {
    @Override
    public void visit(Zoom zoom) {
        LOGGER.info(zoom + " used with Unix configurator.");
    }
}

源码见 ConfigureForDosVisitor.java 与 ConfigureForUnixVisitor.java。ConfigureForUnixVisitor 的 Javadoc 特别强调:与传统的 Visitor 模式不同,本类可以有选择性地只为部分 Modem 实现 visit 方法——这就是 Acyclic Visitor 的"按需访问"能力,也是它优于传统 Visitor 的核心卖点。

2.4 组装运行:accept 分发全过程

入口方法位于 App.java:

public static void main(String[] args) {
    var conUnix = new ConfigureForUnixVisitor();
    var conDos = new ConfigureForDosVisitor();

    var zoom = new Zoom();
    var hayes = new Hayes();

    hayes.accept(conDos); // Hayes modem with Dos configurator
    zoom.accept(conDos); // Zoom modem with Dos configurator
    hayes.accept(conUnix); // Hayes modem with Unix configurator
    zoom.accept(conUnix); // Zoom modem with Unix configurator
}

四次调用的分派逻辑如下:

调用 分发路径 结果
hayes.accept(conDos) conDos instanceof HayesVisitor 通过 → visit(hayes) Hayes + DOS 配置
zoom.accept(conDos) conDos instanceof ZoomVisitor 通过 → visit(zoom) Zoom + DOS 配置
hayes.accept(conUnix) conUnix 不是 HayesVisitor → 拒绝 "Only HayesVisitor is allowed to visit Hayes modem"
zoom.accept(conUnix) conUnix instanceof ZoomVisitor 通过 → visit(zoom) Zoom + Unix 配置

原文档给出的程序输出为:

09:15:11.125 [main] INFO com.iluwatar.acyclicvisitor.ConfigureForDosVisitor -- Hayes modem used with Dos configurator.
09:15:11.127 [main] INFO com.iluwatar.acyclicvisitor.ConfigureForDosVisitor -- Zoom modem used with Dos configurator.
09:15:11.127 [main] INFO com.iluwatar.acyclicvisitor.Hayes -- Only HayesVisitor is allowed to visit Hayes modem
09:15:11.127 [main] INFO com.iluwatar.acyclicvisitor.Zoom -- Only ZoomVisitor is allowed to visit Zoom modem

注意:原文档的输出示例缺少最后一行 Zoom -- Only ZoomVisitor is allowed to visit Zoom modem。但从源码逻辑看,zoom.accept(conUnix) 时 conUnix 恰好实现了 ZoomVisitor,因此该行不会输出;实际运行中前三行来自四次调用中的三次成功匹配,第四行 hayes.accept(conUnix) 被拒绝产生日志。读者可运行 AppTest.java 中的 shouldExecuteApplicationWithoutException 验证主流程无异常执行。

整个调用链的时序关系可参考模块自带的序列图:

Acyclic Visitor 时序图:Modem 元素通过 accept 将自身分派给对应 Visitor 子接口

类之间的整体结构与依赖方向可参考 UML 图:

Acyclic Visitor 类图:退化基类 ModemVisitor 与按元素拆分的子接口形成无环依赖

三、源码级深入:三个决定"无环"成立的设计细节

3.1 退化基类 ModemVisitor 是解耦支点

ModemVisitor.java 的 Javadoc 写道:"ModemVisitor interface does not contain any visit methods so that it does not depend on the visited hierarchy"。一个不依赖任何具体元素类的空接口,让访问者体系与元素层次之间的依赖关系变成单向、无环:元素依赖自己的子接口,子接口依赖具体元素类,但任何一方都不再需要"认识"整个 Visitor 全集。

3.2 instanceof 运行时守卫取代编译期全量绑定

传统 Visitor 靠编译期重载实现双重分派;Acyclic Visitor 则在 accept 内用 instanceof 做运行时类型检查:

  • 匹配:向下转型并调用 visit(this),完成双分派;
  • 不匹配:打印一条提示日志,安全拒绝。

代价是每次访问多一次类型判断,换来的是新增元素子类或新增 Visitor 时零侵入。测试用例 ZoomTest.java 用 Mockito 验证了这一路径:对 mock(ConfigureForDosVisitor.class) 调用 zoom.accept(...) 后,verify((ZoomVisitor) mockVisitor).visit(eq(zoom)),证明分发确实命中 ZoomVisitor.visit(Zoom)。

3.3 接口组合 AllModemVisitor 简化全量访问

当一个 Visitor 需要同时拜访所有元素类型时,不必分别实现多个接口,直接 implements AllModemVisitor 即可一次性获得全部 visit 声明(AllModemVisitor.java)。ConfigureForDosVisitor 正是这样:两个 visit 方法各司其职,且仍保持按需实现的能力——若未来出现第三种 Modem,DOS 配置器可自主决定是否新增 visit 方法。

四、适用场景:什么时候该用 Acyclic Visitor

依据原文档并结合源码结构,以下场景适合引入 Acyclic Visitor:

  1. 需要向既有类层次新增功能,但不允许改动层次本身——元素类处于稳定、共享或被大量复用的状态;
  2. 操作(如配置算法)本质上是层次的外部行为,不应归属于元素类内部(本示例中的 ConfigureForUnixVisitor、ConfigureForDosVisitor 即属于此类);
  3. 同一元素类型需要执行差异极大的操作,操作随对象类型而异;
  4. 被访问的类层次会频繁扩展新子类——此时传统 Visitor 要求同步修改所有 Visitor,成本随子类数量线性增长;
  5. 编译、链接、测试或分发新元素子类代价高昂——Acyclic Visitor 让新增子类不需要触碰任何既有 Visitor。

反过来说:如果元素层次稳定、几乎不会扩展,且访问者需要覆盖全部元素类型,传统 Visitor 的编译期全量绑定反而更直接;Acyclic Visitor 的按需实现也可能导致"访问者遗漏某个 visit"而退化为运行时静默跳过,需要配合日志与测试兜底。

五、优缺点权衡

优势

  • 可扩展性:新功能(新 Visitor)可独立添加,无需修改对象结构;
  • 解耦:对象与施加于其上的操作之间的耦合被显著降低;
  • 无循环依赖:依赖图保持无环,可维护性提升、整体复杂度下降。

劣势

  • 接口数量膨胀:每个元素类型需要独立的 Visitor 子接口,接口文件数量增多,结构复杂度上升;
  • 维护成本:对象层次一旦变更(如元素类改名、增删),所有相关 Visitor 接口与实现仍需同步更新。

六、与相关模式的关系

  • Composite:Acyclic Visitor 常与组合模式搭配,用于统一处理单个对象与对象组合,在遍历树形结构时注入外部操作;
  • Decorator:可作为补充,动态地为对象附加职责,再配合 Visitor 施加批量操作;
  • Visitor:Acyclic Visitor 本质是 Visitor 的一种变体,核心差异在于通过退化基类与按元素拆分的子接口消除了循环依赖。

七、小结与实验指引

Acyclic Visitor 以"一个空接口 + 按元素拆分的子接口 + instanceof 运行时守卫"三件套,把传统 Visitor 的循环依赖问题转化为一个可选的、按需实现的访问协议。在 java-design-patterns 仓库中,acyclic-visitor 模块以 Modem 层次与 Unix/DOS 配置器为最小化范例,完整展示了从接口设计、双分派分发到按需访问的全部环节。

想要亲手验证,可以:

  1. 阅读模块 UML 源文件 acyclic-visitor/etc/acyclic-visitor.urm.puml 理解结构;
  2. 运行 AppTest 验证入口无异常、ZoomTest / HayesTest 验证 accept 分发路径;
  3. 参照 模块 README 与本文结构,在自有项目中尝试:先定义退化 Visitor 基类,再为每个元素类型建立子接口,最后让具体 Visitor 按需实现——新增一种 Visitor 时,你会发现自己真的不需要改动任何元素类。
登录后查看全文
java-design-patterns