访问者模式的经典卖点是「加操作容易、加类型难」:操作轴加一个类,类型轴改遍所有操作。
sealed interface 与模式匹配之后,这笔账要重算:switch 也有穷尽性检查,加类型时编译器同样点名。
4 类型 × 4 操作的矩阵两种写法都写一遍,量耗时、量两个扩展方向的真实 diff,再看 JDK 24+ 的 ClassFile API 为什么选了访问者这一侧。

一、问题出在哪

访问者的机制是双分派。第一次分派给节点,第二次分派给操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 节点侧
public record Add(Expr left, Expr right) implements Expr {
@Override
public <R> R accept(ExprVisitor<R> visitor) {
return visitor.visitAdd(this);
}
}

// 操作侧
public final class EvalVisitor implements ExprVisitor<Integer> {
@Override
public Integer visitAdd(Add add) {
return add.left().accept(this) + add.right().accept(this);
}
// visitNum / visitMul / visitNeg 同形
}

第二次分派把「这是个 Add」和「现在在做求值」拼在一起,操作的实现就从节点里挪进了访问者。代价同时落到类型侧:ExprVisitor 每多一个方法,所有实现类一起编译失败。

另一条路是 sealed interface + 模式匹配,JDK 21 起可以用穷尽 switch:

1
2
3
4
5
6
7
8
public static int eval(Expr expr) {
return switch (expr) {
case Num num -> num.value();
case Add add -> eval(add.left()) + eval(add.right());
case Mul mul -> eval(mul.left()) * eval(mul.right());
case Neg neg -> -eval(neg.expr());
};
}

加一个类型,每个 switch 都会因为「不覆盖全部可能输入」编译失败。

经典论断的两半都松动了。「加操作容易」这半,两种写法都是新增一个文件,谁都碰不到现有代码;「加类型难」这半,两边编译器指向同一批操作文件。这个场景有个更老的名字,expression problem,问的是「能不能在不改现有代码的前提下同时扩展类型和操作」,答案是两边都躲不开改现有代码,区别只在改动落在哪一侧。

代码量上有一处稳定的差别。同一个矩阵的基线:

基线 文件数 总行数 节点侧 操作侧
访问者 10 158 40(4 个 record 各 8 行,含 accept 113(4 个访问者类)
sealed + switch 9 96 20(4 个 record 各 4 行) 76(4 个静态方法类)

访问者版本多出 62 行,多在一个 accept 方法和四个访问者类的骨架。这个差距与矩阵大小无关,是一次性的入场费。

要看出差别,得分开看三件事:运行期代价、两条轴扩展时的改动规模、编译器与运行期各自能替你抓到什么。

二、实验设计

矩阵的两条轴:

取值 说明
类型 NumAddMulNeg 表达式节点,构成一棵 41 节点、深度 7 的树
操作 evalsize 求值、节点数
操作 depthprint 深度、中缀打印(追进 StringBuilder 再产出 String

三个操作返回数值,一个产出字符串,形状刻意拉开:求值只做算术,深度只做比较,打印要分配。操作形状会不会影响两种写法的差距,是这个矩阵要先回答的问题。

两种写法的代码在 /tmp/Visitor/ 下,包结构一致:expr.Expr 是 sealed 接口,四个 record 节点;访问者版每个节点实现 accept,操作是实现 ExprVisitor<R> 的类;switch 版节点只有数据,操作是含穷尽 switch 的静态方法类。两边都不缓存中间结果,每次遍历完整走树。

访问者侧的接口保持泛型原样(ExprVisitor<Integer>ExprVisitor<Void>),没有做 int 特化。这个取舍会在字节码和耗时里看到后果,两处都会给出对照。

测量口径:

  • 机器 Apple M1 Pro,macOS Darwin 25.6.0,单线程
  • JDK 21.0.8+12-LTS-250 与 JDK 25+37-LTS-3491,都是 arm64
  • 计时前先预热 100 万次,再计时 5 轮,每轮 200 万次遍历,取最快一轮
  • 树构造了 16 棵,循环里按 i & 15 轮换。单棵树会让整个循环体成为循环不变量,JIT 有理由把它提到循环外,量到的就不是遍历
  • 计时期间持全局文件锁,避免同机其他进程干扰
  • 没有 JMH。这是单机微基准,结论只取量级

全部耗时来自同一次测量会话,横向可比。绝对值不要跨文章比:树的形状、操作里做的事、预热口径都会改数字,本矩阵内 print 和 eval 就差 8 倍。

evalsizedepth 复用同一个无状态访问者实例,print 每次遍历新建一个访问者,因为它要持有 StringBuilder。打印操作的形态决定了它必须带状态,两种写法都得这样写。

没覆盖的东西一并列出:没有 JMH 的 Blackhole,靠 volatile 写把结果留住;没有多线程竞争场景;没有测深递归到栈溢出的边界;没有测节点数超过 JIT 内联预算之后的形态。树只有 41 个节点,单趟耗时的绝对值小,看相对差时把注意力放在同一行内的两列,不要跨行比。每节点成本可以自己除:把某一行除以 41 就是每个节点的平均代价。

三、实测一:矩阵的运行期代价

41 节点树的单趟遍历,单位纳秒:

操作 JDK 21 访问者 JDK 21 switch JDK 25 访问者 JDK 25 switch
eval 26.1 115.8 26.4 117.3
size 38.5 116.9 46.1 117.9
depth 25.9 121.4 25.6 91.5
print 209.0 293.9 196.6 223.5
四项合计 329.9 631.7 323.0 543.3

矩阵两种写法的耗时与两个扩展方向的改动规模

两代 JDK 上排序一致,这棵 41 节点的树上四种操作都是访问者更快。

print 每趟 209.0ns(JDK 21 访问者),是 eval 的 8 倍,绝对值由操作形状决定。它每个节点都要往 StringBuilder 里 append,遍历收尾还要 toString() 一次,分配与拷贝吃掉了大部分时间。size 只做整数加法,比 eval 慢一点;depth 用 Math.max,和 eval 同量级。矩阵里最贵的是每个节点上操作自己做的事。

switch 的倍数按操作收缩。 JDK 21 上 eval 是 4.4 倍,depth 4.7 倍,print 只有 1.4 倍,四项合计 1.9 倍。JDK 25 上 depth 从 4.7 倍降到 3.6 倍,print 从 1.4 倍降到 1.1 倍,eval 与 size 基本不动。两个 JDK 的 switch 实现不同,它换掉了每个节点的分派形态,但收益为什么只落在 depth 与 print 上,没有进一步定位。

四项相加与 all4 那一行大体相等:JDK 21 访问者 26.1 + 38.5 + 25.9 + 209.0 = 299.5,实测 all4 是 329.9;switch 侧 115.8 + 116.9 + 121.4 + 293.9 = 648.0,实测 631.7。四次独立遍历没有出现互相抵消,访问者侧的 +10% 与 switch 侧的 −2.5% 方向相反,落在本次抖动与 JIT 布局变化之内。

访问者的单节点成本是 26.1 / 41 = 0.64ns,低于一次未内联的接口调用。41 个节点、深度 7 的树让 C2 有机会把 acceptvisitXxx 的调用链内联掉大半;换一棵 8 倍大的树能验证这个数字是不是小树的假象。

换一棵 339 节点的深树

把树深从 6 加到 10(339 节点、深度 11),写法和口径不变,只改树的规模:

操作 JDK 21 访问者 JDK 21 switch JDK 25 访问者 JDK 25 switch
eval 232.5 700.0 230.1 755.5
size 231.4 701.7 221.3 749.5
depth 224.8 981.8 219.1 757.8
print 1713.4 1683.3 1656.9 1512.7
四项合计 2414.1 4063.7 2337.6 3778.0

单节点成本在两个规模上都稳定。访问者的 eval 在 41 节点树上是 26.1ns,除以 41 是 0.64ns/节点;在 339 节点树上是 232.5ns,除以 339 是 0.69ns/节点。switch 侧同样稳定,2.8ns/节点降到 2.1ns/节点,两代 JDK 一致。两边都近似线性,差的 1.5ns/节点可以当成这两种分派方式在本题里的固定价差。

操作自身的成本摊薄了分派差。 print 在深树上反转:JDK 21 是 switch 快一点(1683.3 对 1713.4),JDK 25 是 switch 快 9%(1512.7 对 1656.9)。打印每个节点要 append、收尾要 toString(),固定成本约 4.4ns/节点,1.5ns 的价差摊到 1.02 倍;访问者版每次遍历还要多建一个访问者对象(它持有 StringBuilder),switch 版只需要一个 StringBuilder。操作越重,分派风格越不重要,最后比的是谁少分配一个对象。

int 特化在深树上的收益更稳:JDK 25 上三个操作都快了,eval 从 230.1ns 到 182.8ns,size 从 221.3ns 到 156.2ns,depth 从 219.1ns 到 156.1ns,快 20% 到 29%。浅树上 size 的倒退没有复现。

字节码里是什么

访问者的递归调用点,javap -c -p expr.EvalVisitor(JDK 25 编译产物):

1
2
3
4
5: invokeinterface #25,  2  // InterfaceMethod expr/Expr.accept:(Lexpr/ExprVisitor;)Ljava/lang/Object;
10: checkcast #14 // class java/lang/Integer
13: invokevirtual #31 // Method java/lang/Integer.intValue:()I
33: invokestatic #13 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;

每个节点一次接口调用,加上 checkcast 与拆装箱。泛型接口的返回值是 R,擦除后是 Object,这条路绕不开。

switch 侧的同一个递归点:

1
2
3
2: invokestatic  #7   // Method java/util/Objects.requireNonNull:(Ljava/lang/Object;)Ljava/lang/Object;
11: invokedynamic #13, 0 // InvokeDynamic #0:typeSwitch:(Lexpr/Expr;I)I
16: tableswitch { // 0 to 3

每个节点一次 invokedynamic 加上按序号跳转的 tableswitch

泛型接口的账单:int 特化的对照

把接口从 ExprVisitor<Integer> 换成 IntVisitorint visitNum(Num) 这种),同样四个类型各加一个 int acceptInt(IntVisitor),字节码就变干净了:

1
2
3
public int visitAdd(expr.Add);
5: invokeinterface #19, 2 // InterfaceMethod expr/Expr.acceptInt:(Lexpr/IntVisitor;)I
15: invokeinterface #19, 2 // InterfaceMethod expr/Expr.acceptInt:(Lexpr/IntVisitor;)I

checkcastintValuevalueOf 全部消失。三个数值操作的耗时对照(同一把锁、同一次会话):

操作 JDK 21 泛型访问者 JDK 21 int 特化 JDK 25 泛型访问者 JDK 25 int 特化
eval 26.1 22.8 26.4 23.5
size 38.5 48.0 46.1 70.0
depth 25.9 21.2 25.6 22.1

三个数值操作里,int 特化在浅树上有两个变快、一个变慢:

  • eval:JDK 21 从 26.1ns 到 22.8ns,JDK 25 从 26.4ns 到 23.5ns,快 10% 到 13%
  • depth:JDK 21 从 25.9ns 到 21.2ns,JDK 25 从 25.6ns 到 22.1ns,快 14% 到 18%
  • size:JDK 21 从 38.5ns 到 48.0ns,JDK 25 从 46.1ns 到 70.0ns,慢了 25% 到 52%

size 的倒退在两代 JDK 上同时出现,看着像系统性代价。换到 339 节点的深树就不见了:JDK 21 是 227.2ns 对 231.4ns,JDK 25 是 156.2ns 对 221.3ns,后者还快了 29%。内联日志里,int 版本把 Num::acceptInt 内联进 Add::acceptInt 时出现 failed to inline: already compiled into a big method,这类拒绝在泛型版本里没有;方法形状一换,内联预算的分配就变,浅树上的 25% 更可能是这种布局偶然,而不是拆装箱的真实代价。

字节码的账单是确定的:省掉一条 checkcast、一次 intValue、一次 valueOf。省下来的时间不稳定,深树与浅树给出的答案都不一样。把接口 int 特化当性能开关之前,先拿真实负载量一遍。

两个 JDK 的 switch 不是同一套东西

同一次运行里加 -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining-XX:+PrintCompilation-Xlog:class+load=debug,两边差别明确:

  • JDK 25:每个 switch 生成一个隐藏类,expr.Eval$$TypeSwitch/0x…::typeSwitch (82 bytes)-XX:+PrintCompilation 里编到了 4 层,内联日志里是 inline (hot)
  • JDK 21:同一个程序、同一组日志开关,TypeSwitch 零条记录。JDK 21 的 -Xlog:class+load=debug 会输出 Hid$$Lambda 这类隐藏类,说明问题不在日志级别

源码对得上。JDK 21 的 java.base/java/lang/runtime/SwitchBootstraps.java:222 起是 createMethodHandleSwitch,用 MethodHandles.guardWithTest 串出判断链(:231),再 MethodHandles.tableSwitch:235)收口。JDK 25 的同一个类 :732 起换了做法:

1
2
3
4
5
6
7
8
byte[] classBytes = ClassFile.of(ClassFile.StackMapsOption.DROP_STACK_MAPS).build(ConstantUtils.binaryNameToDesc(typeSwitchClassName(caller.lookupClass())),
clb -> {
clb.withFlags(AccessFlag.FINAL, AccessFlag.SUPER, AccessFlag.SYNTHETIC)
.withMethodBody("typeSwitch",
addExtraInfo ? MTD_TYPE_SWITCH_EXTRA : MTD_TYPE_SWITCH,
ClassFile.ACC_FINAL | ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC,
generateTypeSwitchSkeleton(selectorType, labelConstants, enumDescs, extraClassLabels));
});

(JDK 25 SwitchBootstraps.java:737-743。)生成的字节码再用 caller.defineHiddenClass(classBytes, true, NESTMATE, STRONG):749)挂进调用点。生出来的方法能被 C2 内联,替换掉了原先一串句柄。

内联日志里访问者侧的高频行是 expr.Expr::accept (0 bytes) failed to inline: no static binding,递归自调用则是 callee is too large。两种写法都没有把整趟遍历压成一个方法的余地,能优化的只是每节点的那次分派。

四、实测二:两个扩展方向的真实成本

扩展实验分两次:先加类型,再加操作,每次都用 git diff --stat 记录真实改动。

加第 5 个类型 Div

写法 文件数 新增行 删除行 被改的文件
访问者 7 35 1 ExprExprVisitorDiv(新)、EvalVisitorSizeVisitorDepthVisitorPrintVisitor
switch 6 15 1 ExprDiv(新)、EvalSizeDepthPrint

加第 5 个操作 EvenCount(统计偶数值节点):

写法 文件数 新增行 被改的文件
访问者 1 23 EvenCountVisitor(新)
switch 1 15 EvenCount(新)

行数的差来自写法本身。访问者每加一个类型要补一整个方法(签名、方法体、缩进),switch 只加一条 case 标签,两边要写的逻辑一样多。单看两条轴,类型轴一步在访问者侧是 35 行 / 7 个文件,在 switch 侧是 15 行 / 6 个文件;操作轴一步两边都是 1 个新文件,23 行对 15 行。

两条轴一起长

真实重构里更常见的场面是新语法配新分析,DivEvenCount 一起加。两个变体第一次编译都失败:

1
2
3
4
$ javac ...   # 访问者
EvenCountVisitor.java:3: error: EvenCountVisitor is not abstract and does not override abstract method visitDiv(Div) in ExprVisitor
$ javac ... # switch
EvenCount.java:8: error: the switch expression does not cover all possible input values

新操作必须先覆盖新类型,两种写法都拦得住,没有谁能漏掉。补全之后再统计:

写法 文件数 新增行 矩阵从 4×4 长到 5×5 后的基线
访问者 8 63 12 文件 / 221 行
switch 7 31 11 文件 / 127 行

两条轴同时长的成本接近两笔单步之和:访问者 35 + 23 = 58 行,实测 63 行;switch 15 + 15 = 30 行,实测 31 行。多出来的几行是新操作必须立刻覆盖新类型,访问者侧是 visitDiv 方法,switch 侧是 Div 分支。没有「一起改更便宜」的窗口。

只改类型和接口、故意不修操作类,让编译器报错:

1
2
3
4
5
6
7
8
9
10
$ javac -J-Duser.language=en ...   # 访问者:Expr 加 permits,ExprVisitor 加 visitDiv
EvalVisitor.java:3: error: EvalVisitor is not abstract and does not override abstract method visitDiv(Div) in ExprVisitor
SizeVisitor.java:3: error: SizeVisitor is not abstract and does not override abstract method visitDiv(Div) in ExprVisitor
DepthVisitor.java:3: error: DepthVisitor is not abstract and does not override abstract method visitDiv(Div) in ExprVisitor
PrintVisitor.java:3: error: PrintVisitor is not abstract and does not override abstract method visitDiv(Div) in ExprVisitor
4 errors

$ javac -J-Duser.language=en ... # switch:Expr 加 permits,新增 Div
Size.java:8: error: the switch expression does not cover all possible input values
1 error

switch 侧的多文件批编译只报 1 条就中止。把四个操作文件逐个单独编译,四条错误各归其位:Eval.java:8Size.java:8Depth.java:8switch expression does not cover all possible input valuesPrint.java:8 用的是语句形式的 switch,措辞是 switch statement。责任文件同样是 4 个。

测量口径:javac 遇到穷尽性错误会提前中止,多文件批编译数不出责任文件的数量。 要么逐文件编译,要么看 IDE 的编译日志。

两条轴的编译期保护都是可关的

访问者侧的「编译器点名」来自 ExprVisitor 的抽象方法。给新方法加个 default 实现就能关掉:

1
2
3
default R visitDiv(Div div) {
throw new UnsupportedOperationException("visitDiv");
}

同样的实验,编译结果 0 错误,通过。加了 Div 而没修任何操作类,四个访问者都能编出来,漏掉的那个方法只在运行期、只在数据里真的出现 Div 时抛异常。

switch 侧的开关是 default 分支,同理。两种写法都能把编译期保护降级成运行期异常,区别在默认状态:抽象方法默认打开保护,switch 只有配了 sealed 才有穷尽性检查。

类型集封闭不了的时候,差别才出现

Expr 从 sealed 改成普通接口,switch 需要 default 兜底,访问者的 ExprVisitor 仍是抽象方法。同样只加 Div、不修操作类:

  • 访问者:仍然 4 个编译错误,EvalVisitorSizeVisitorDepthVisitorPrintVisitor 一个不落
  • switch:编译通过,0 错误

switch 版本运行起来是这样:

1
2
3
4
5
6
$ java -cp out expr.OpenDemo
compiled fine, now running...
Exception in thread "main" java.lang.IllegalStateException: unknown node: Div[left=Num[value=4], right=Num[value=2]]
at expr.Size.size(Size.java:13)
at expr.Size.size(Size.java:10)
at expr.OpenDemo.main(OpenDemo.java:7)

default 分支把「漏了一个类型」从编译期推到运行期,而且只在数据里真的出现 Div 时才炸。插件式层级里,第三方给你带来第五个类型时,你的 switch 连提醒都不会有。

类型集封闭时,两边都是编译器驱动的修改;类型集开放时,访问者靠接口方法把更新逼出来,switch 靠 default 把问题藏起来。 类型集封闭与否,看的是这个层级会不会被外部实现,跟此刻有几种类型无关。

五、矩阵里还有第三种写法

两种写法之外,矩阵还能这么摆:一个访问者遍历一次,把四个操作全算出来。

1
2
3
4
5
6
public final class AllOpsVisitor implements ExprVisitor<Void> {
private int value;
private int size;
private int depth;
// 四个 visitXxx 里同时更新这三个字段
}

它把操作轴的四个格子压进一次遍历。访问者天然支持这种融合:accept 是框架调进来的,一个节点上想累计多少个量都行。switch 侧要融合,得把四个 switch 合并成一个巨型 switch,每个 case 里干四件事,四段逻辑长在一起。

融合省下的是分派账单,操作本身的计算照付,可以按前面的数字估个上限。深树上 339 个节点 × 0.69ns ≈ 234ns 是访问者一次遍历的逐节点分派成本;四个操作各跑一遍要付四遍(约 936ns),融合之后只付一遍,省下的上限在 700ns 上下,对 2414ns 的合计来说是 30% 左右。这正好解释了 all4 与四项之和为什么几乎相等:没有融合时,四次遍历的分派一分钱也省不掉。

六、JDK 源码里的同类选择

JDK 24 起 java.lang.classfile 提供 Class-File API,它的对象模型把两条轴分得很清楚。

类型轴是封闭的。java.base/java/lang/classfile/ClassFileElement.java:60public sealed interface ClassFileElementCompoundElement.java:63-66

1
2
3
public sealed interface CompoundElement<E extends ClassFileElement>
extends ClassFileElement, Iterable<E>
permits ClassModel, CodeModel, FieldModel, MethodModel, jdk.internal.classfile.impl.AbstractUnboundModel {

ClassModel.java:65 起是 public sealed interface ClassModel extends CompoundElement<ClassElement>, AttributedElement。JVMS 的语法不会因为你的工具多出第 5 种成员。

操作轴是开放的,而且刻意做成了访问者形态。ClassFileTransform.java:105

1
2
3
4
5
public sealed interface ClassFileTransform<
C extends ClassFileTransform<C, E, B>,
E extends ClassFileElement,
B extends ClassFileBuilder<E, B>>
permits ClassTransform, FieldTransform, MethodTransform, CodeTransform {

四个子类型对应类、字段、方法、方法体四种粒度的操作。用户侧的扩展点是一个 accept:119):

1
void accept(B builder, E element);

注释写得很直白(:113-114):

1
2
* This method is called by the Class-File API.  Users should never call
* this method.

框架控制调用方向。jdk.internal.classfile.impl.TransformImpl.java:51 是框架侧的调度:

1
2
3
return new ResolvedTransform<>(e -> transform.accept(builder, e),
() -> transform.atEnd(builder),
() -> transform.atStart(builder));

API 负责遍历,元素逐个送进来。CompoundElement.forEachCompoundElement.java:73)是元素侧的入口。

访问者形态在这里有实际用处,原因在 ClassFileTransform 的另外两个方法,atEnd:132)与 atStart:146):

1
2
3
4
5
default void atEnd(B builder) {
}

default void atStart(B builder) {
}

一个 transform 可以在遍历开始前做准备、结束后做清理,可以在处理成员时累计状态,还可以用 andThen:160)把两个 transform 串成流水线。状态化的用法在文档里专门写了一节(:64-72):

1
2
3
4
5
6
7
8
9
* Transforms can have states that persist across processing of individual
* member elements. For example, if a transform injects an annotation, the
* transform may keep track if it has encountered and presented an updated
* {@link RuntimeVisibleAnnotationsAttribute} to the builder; if it has not yet,
* it can present a new attribute containing only the injected annotation in its
* end handler. If such a transform is to be shared or reused, each returned
* transform should have its own state. Each subtype of {@code ClassFileTransform}
* defines a utility method {@code ofStateful} where a supplier creates the
* transform at its initial state each time the transform is reused.

这些位置在静态 switch 函数里没有对应的语法:一个纯函数拿不到「遍历开始 / 结束」这两个时刻,也存不住跨元素的状态。

ClassFile 接口上的操作可以数出来:parse(byte[])ClassFile.java:524)、build(...):555)、verify(ClassModel):758)、transformClass(...):692:717:749)。解析、生成、校验、改写四个操作,落在一个封闭的类型层级上。

用矩阵的眼光看,这个选型说得通:类型轴由 JVMS 定死,操作轴由无数工具(脱糖、注入、校验、插桩)共享,每个操作都要求「逐个处理成员元素」的遍历节奏,还带生命周期钩子与组合需求。矩阵的形状决定了它长成访问者。

JDK 25 自己的模式匹配就用这套 API 生成字节码(SwitchBootstraps.java:737)。封闭的类型层级加开放的操作轴,JDK 自己就是这么用的。

七、什么时候用,什么时候不用

访问者值得写的条件,满足两条以上:

  • 类型层级封闭,或者改动类型集需要一轮自知的版本决定
  • 操作数量在长,而且要跨团队分发(分析器、导出器、校验器各写各的)
  • 操作有状态、有生命周期(开始 / 结束钩子),或者需要互相组合
  • 需要框架控制遍历顺序,用户代码只关心「遇到某个节点做什么」

该放弃访问者的信号:

  • 类型集还在长。每加一个节点就要改 7 个文件、补 35 行,这笔成本会一直付下去
  • 层级来自别人,你只能写 instanceof 链或 default 兜底。这种情况下 sealed 加穷尽 switch 没有编译期保护,别指望模式匹配帮你
  • 操作都是一次性的、几行的函数。JDK 21 起 switch 表达式的语法密度低于访问者里的四个方法声明,读起来更短
  • 返回原始类型而且对热点敏感。泛型访问者每个节点带一次 checkcast 与拆装箱,字节码已经写明了;要么把接口 int 特化,要么直接用 switch

判断顺序,从便宜到贵:

  1. 这个类型集能不能 sealed?不能的话,先想清楚「谁来保证新类型被处理」,再谈模式选择
  2. 两条轴谁先长?操作先长选访问者,类型先长选穷尽 switch
  3. 操作需要状态、钩子、组合吗?需要就只剩访问者
  4. 最后谈性能。矩阵上的差距在量级之内,别拿它当架构决策的依据

局部状态与遍历控制

两种写法还有一个形状差异,落在能写出什么样的控制流上。

switch 版的每个操作是一个完整的函数,可以在里面平铺任意逻辑:用显式栈改成迭代遍历、按深度跳过整棵子树、把中间结果放进局部变量传给兄弟调用。访问者版的每个方法只看到自己那一个节点,任何跨节点的状态都得挂到访问者对象上;accept 的调用方向由节点决定,留给自己的遍历顺序空间很小。

代价在这次实验里露过面:PrintVisitor 必须持有一个 StringBuilder 字段,每次遍历新建一个访问者;如果某个操作需要「进入节点」和「离开节点」两个时机,访问者得在同一个方法里前后各写一段,或者另起一套回调协议。switch 版里这些就是普通的代码顺序。

三种把访问者写废的形态

只写 accept,不写 visitor 接口。 每个节点一个 accept(Op op)Op 里再用 instanceof 分支。双分派退化成了单分派加类型判断,多了一层壳,少了一次编译期检查。

接口写了一堆,实现只覆盖一个类型。 四个访问者方法里三个是 throw new UnsupportedOperationException()。这时矩阵只有一列,接口的形状是假的,编译器点名反而成了噪音。

在 visitXxx 里再判断子类型。 visitAdd(Add add) 内部又去 add.left() instanceof Num 走另一条路。这种情况通常说明方法该拆得更细,或者矩阵的类型轴设计有问题。

八、复现

源码、编译脚本与原始输出都在 /tmp/Visitor/

1
2
3
4
5
6
7
8
9
10
11
visitor/src/expr/      访问者版基线(10 个文件 158 行)
switch/src/expr/ sealed + switch 基线(9 个文件 96 行)
visitor-int/src/expr/ 泛型接口的 int 特化对照
ext/ 四个扩展方向的变体(git 仓库,diff 用)
scratch/ 编译期实验(非 sealed、default 方法)
run-timing.sh 持锁计时脚本(41 节点树)
run-timing-deep.sh 深树计时脚本(339 节点树)
timing.out 41 节点树的原始输出
timing-deep.out 339 节点树的原始输出
inline-*.txt -XX:+PrintInlining 原始输出
comp-*.txt -XX:+PrintCompilation 原始输出

运行环境是 JDK 21.0.8 与 25+37 LTS,java_home -v 切换。计时命令形如:

1
java -cp out-25/visitor bench.VisitorBench 1000000 2000000 5

三个参数是预热次数、计时次数、轮数。

九、总结

基线规模。 4 类型 × 4 操作的两种写法:访问者 10 个文件 158 行,sealed + switch 9 个文件 96 行。

运行期。 访问者的每节点成本 0.64ns(41 节点树)与 0.69ns(339 节点树),switch 对应 2.8ns 与 2.1ns。两者都近似线性,价差固定在 1.5ns/节点上下。操作越重,这个价差摊得越薄;JDK 25 的深树上 print 反而是 switch 略快(1512.7 对 1656.9),因为访问者版每次遍历要多带一个访问者对象。

扩展。 加类型:访问者 7 文件 35 行,switch 6 文件 15 行;加操作:两边都是 1 个新文件,23 行对 15 行;两条轴同时长:8 文件 63 行对 7 文件 31 行。成本基本相加,没有合算的窗口。

编译器。 sealed 层级下两种写法都在编译期点名(各 4 个操作文件)。层级不能 sealed 时,只有访问者还点名(仍 4 个错误),switch 编译通过、在运行期抛 IllegalStateException。这条差别与类型集封闭与否绑定,跟写法本身的优雅程度无关。

JDK 版本。 两个 LTS 的模式匹配实现换了一代:JDK 21 用 MethodHandles.guardWithTest 串判断链,JDK 25 用 Class-File API 生成隐藏类里的 typeSwitch 方法并内联。落到耗时上,JDK 25 的 depth 快 25%(121.4 到 91.5)、print 快 24%(293.9 到 223.5),eval 与 size 没动。同一份 sealed + switch 代码,跨 LTS 的性能特征会变。

判据还是前面那几句:先问类型集能不能封闭,再问两条轴谁先长,最后才轮到性能。同系列里策略模式换的是算法(策略:换算法),命令模式把调用变成对象(命令:把调用变成对象),访问者动的是类型分发的归属。

最要紧的一条:矩阵的形状决定写法,写法的性能差别是 1.5ns/节点这个量级的事。 类型轴封闭、操作轴会长,访问者;类型轴会长、能 sealed,穷尽 switch;两条都不确定,先把类型集封起来再选。

参考资料

  • Gamma 等,《设计模式:可复用面向对象软件的基础》,Visitor 章
  • JEP 441: Pattern Matching for switch,JDK 21
  • JEP 484: Class-File API,JDK 24
  • JDK 25 src.zipjava.base/java/lang/classfile/ClassFileTransform.javaClassModel.javaCompoundElement.javaClassFile.javajava.base/jdk/internal/classfile/impl/TransformImpl.javajava.base/java/lang/runtime/SwitchBootstraps.java
  • JDK 21 src.zipjava.base/java/lang/runtime/SwitchBootstraps.java
  • 测量源码与原始输出:/tmp/Visitor/,日志 /tmp/pattern-runs/Visitor.log

系列索引:设计模式系列