第 37 篇用 Visitor 处理“节点类型稳定、操作会增加”的场景。Java 21 的 record、sealed 和 switch 模式匹配把这类模型的另一种写法放进语言里:节点可以是数据载体,类型集合可以被封闭,switch 可以在编译期检查是否覆盖所有已知类型。

record 和 sealed 改变的是数据边界

Oracle JDK 21 文档说明,record 会为组件生成字段、访问器、规范构造器、equals、hashCode 和 toString;record 字段是 final,适合作为简单数据载体。sealed class/interface 则限制哪些类可以继承或实现它。labs/E02 里,Line 只允许 Product 和 Bundle 两种实现,Product 校验 SKU 和金额,Bundle 保存子节点副本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public sealed interface Line permits Product, Bundle {
<R> R accept(Visitor<R> visitor);
}

public record Product(String sku, int cents) implements Line {
public Product {
if (sku == null || sku.isBlank() || cents < 0) {
throw new IllegalArgumentException("bad product");
}
}

@Override
public <R> R accept(Visitor<R> visitor) {
return visitor.product(this);
}
}

这段只截取 Product,完整实验还包含复制子列表的 Bundle。record 组件字段是 final,但这只是浅层不可变;如果组件本身是可变集合,仍要像 Bundle 那样在构造时 List.copyOf。这不是把 Visitor 删除,而是让“类型集合稳定”落在语言结构里。visitorAndSwitchKeepTheSameResultForStableNodeTypes 证明 Visitor 与 switch 对同一棵订单行树都算出 1250 分。

当节点集合真的稳定时,sealed 层次能把扩展边界交给编译器;当外部插件要继续增加节点时,sealed 会变成不合适的门。

新增操作时 switch 很直接

新增“列出 SKU”这个操作,switch 写成一个旁路函数即可:

1
2
3
4
5
6
return switch (line) {
case Product product -> product.sku();
case Bundle bundle -> bundle.children().stream()
.map(Scenario::skusWithSwitch)
.collect(Collectors.joining(","));
};

newOperationCanBeAddedNextToSwitchWithoutChangingRecords 断言结果为 book,pen。这正好命中 Visitor 的经典取舍:新增操作时,Visitor 需要新增一个 visitor 实现;switch 只需要新增函数。二者都不需要改 Product 和 Bundle。

flowchart TB
    Line[Line sealed层次] --> Product[Product]
    Line --> Bundle[Bundle]
    Product --> Accept["accept(visitor)"]
    Bundle --> Accept
    Accept --> Visitor[Visitor求金额]
    Line --> Switch[switch求金额/列SKU]

新增类型时让编译器说话

本篇没有用文字推断“会编译失败”,而是在测试里调用 JavaCompiler 编译负例。新增 Gift 但 switch 只处理 Product 时,JDK 21 诊断包含 switch 表达式未覆盖所有输入值;新增 Gift.accept(visitor) 但 Visitor 只有 visit(Product) 时,诊断包含 Gift 无法匹配现有 visit 方法。

这两个负例说明:sealed + switch 和 Visitor 都能在新增节点时逼迫调用方补齐操作,只是失败位置不同。switch 失败在穷尽性检查,Visitor 失败在新节点接入访问者时找不到对应方法。对稳定类型集合,switch 更短;对跨模块开放类型集合,Visitor 或普通多态更自然。

变化 Visitor sealed + switch
新增操作 新增 visitor 实现 新增函数
新增节点 改 visitor 接口和实现 改 sealed permits 与所有穷尽 switch
数据载体 可用普通类或 record record 更简短

Visitor 与模式匹配解决的是同一个二维变化问题:类型轴和操作轴。不要只比较语法行数,要先判断哪根轴更常变。

验证与练习

运行 ./mvnw -B -ntp -pl labs/E02 -am test;SealedSwitchVisitorTest 通过 3 个测试,其中包含两个负向编译诊断。原始日志在 examples/design-patterns/evidence/E02/targeted.stdout.txt。

  1. 新增 DiscountLine 节点,先让现有 switch 和 Visitor 负向测试失败,再补齐金额和 SKU 操作;记录要改的文件数量。
  2. 新增“统计叶子节点数量”操作,分别用 Visitor 和 switch 写一版。比较新增操作时哪一版更短,以及哪一版更容易复用已有递归逻辑。

参考资料:

上一节:E01 高阶函数替代了什么;下一节:E03 默认行为和规则组合。