Hacker News 中文摘要

RSS订阅

Java sealed classes and exhaustive pattern matching

Java 17 引入了密封类,可以显式列出接口或基类允许的子类型。示例用一个密封接口加记录,若不显式列出,内部类会自动成为允许的子类型;代码定义了 SealedType 接口以及 TypeA、TypeB 两个记录,并用 switch 按字符串返回对应实例。熟悉函数式语言里的代数数据类型的话,这类似于 Haskell 或 ML 里的数据类型声明。随后在 Main 类中通过 switch 对值分别匹配 TypeA 和 TypeB。

正文

Java 17 引入了密封类(sealed classes),允许你明确列出某个接口或基类所允许的子类型。比如有一个用密封接口和 record 写的简单例子:如果不显式给出允许的子类型列表,内部类会被隐式加入允许的子类型中。这个例子定义了 sealed interface SealedType,其中包含 record TypeA() 和 record TypeB() 两个实现,还有一个静态工厂方法 of(String type),里面用 switch 根据字符串 "A" 或 "B" 返回对应的 TypeA 或 TypeB 实例,其他情况则抛出 IllegalArgumentException。

如果你熟悉带有代数数据类型的函数式语言,可以把这看作类似于 Haskell 或 ML 里的 datatype 声明:data SealedType = TypeA | TypeB。

接着我们在一个简单的 Main 类里使用它。main 方法从 args[0] 取得字符串,调用 SealedType.of(args[0]) 得到 val,然后在 System.out.println 里用 switch 表达式对 val 做模式匹配:case SealedType.TypeA() 时返回 "A",case SealedType.TypeB() 时返回 "B"。

这个例子本身并没什么惊人之处,但关键在于:main 方法里的 switch 表达式并没有 default 子句。这是因为密封类(和枚举)支持穷尽性检查:编译器确切知道有哪些可能的情况,因此可以检查你是否覆盖了所有情况。如果覆盖了,就不需要 default 子句;如果漏了某个情况且没有 default,就会得到编译时错误。

当你想确保某个类型的所有使用都覆盖全部情况时,这很有用,但它也引入了一种新的破坏性变更:给密封类或接口添加新子类型,可能会破坏使用这些代码的一方。例如在上面的例子里增加 TypeC,就会导致 main 方法因缺少对应 case 而编译失败。因此,如果你在 API 中导出密封类型,那么添加新子类型就是一种破坏性变更,如果遵循 SemVer,就需要做大版本升级。

编译时错误还是运行时错误?

虽然 Java 在编译使用者(这里的 main)时会因为 switch 不穷尽而报编译时错误,但如果密封类型发生变化,而使用者没有重新编译,编译器就无法做到这一点。假设我们给 SealedType 扩展了另一个 case,加入 record TypeC() 实现,并在 of 方法的 switch 里加上 case "C" -> new TypeC()。

如果只是重新编译了 SealedType.java 而没有重新编译 Main,那么一旦走到新的 case,就会出现运行时异常。这里抛出的是新的 MatchException。Javadoc 里提到了单独编译带来的这个潜在问题,也提到了模式中 null 的一些极端情况。所以,即便你希望用密封类在添加新 case 时静态地确保所有使用者都得到更新,事实也并非如此,除非你把全部代码都重新编译一遍。

结论

我认为结论是:密封类型很可能在一个组件的内部实现中最有用,而在组件对外提供的公共 API(比如库暴露给其他组件的接口)中就没那么有用。对于内部使用,你通常会把所有代码一起重新编译,因此能享受到穷尽性检查和更高的编译时安全保证。但一旦跨模块边界使用,你可能只是引入了新的破坏代码的方式,而且这些破坏往往只能在运行时才能被发现。

我是在审查 PEM 编码的加密对象的预览支持 时发现这些微妙之处的。那个 JEP 正好犯了这个错误:把密封接口放进公共 API,并推荐客户端针对该类型做模式匹配。我预测,如果他们以后想增加新 case,极有可能会造成破坏。

评论

整体气氛较多肯定 Java 在模式匹配与密封类型上的进展,认为其能力已相当完整。分歧主轴则是:这些特性应算 Java 的实质进步,还是只是在追赶 Kotlin;少数人进一步抬举 Kotlin、贬抑 Scala,也有人因全栈统一更看好 TypeScript。

认为 Java 的模式匹配已相当完善、进步明显

  • 认可 Java 的进步与能力:Java 不仅支持递归模式等嵌套解构,配合密封类型时模式匹配分支也不再需要默认分支,穷尽性由编译器检查;有人明确表示喜欢 Java 至今的进展。(较多人) > Java 其实也已支持递归模式,以及可能仍在预览中的字面量模式,可在模式匹配分支里直接解构嵌套值。(@armchairhacker)

认为 Java 只是在追赶 Kotlin,Scala 也不值得回归

  • Java 仍在试图赶上 Kotlin;在 Scala(被认为因不加选择地堆砌特性而失败)和尤其是 Kotlin 之后,作者找不到回到 Java 的理由。(少数人) > Java 不过是在继续追赶 Kotlin;在 Scala 和 Kotlin 之后,没有回到 Java 的理由。(@Larrikin)
  • 该看法下出现一条代表性玩笑,把 Scala 写成“正对着空气挥拳”,以拟人化方式表达它在这场 Java 与 Kotlin 比较中被顺带提及、又被评价为失败后的情绪。(少数人)

因全栈同语言更偏好 TypeScript,但仍肯定 Java

  • 尽管喜欢 Java 至今的进步,但因为做大量前端工作、希望前后端使用同一种语言,仍更偏好 TypeScript。(少数人) > 我热爱 Java 至今取得的进步,但因为做很多前端、希望全栈用同一门语言,我仍更喜欢 TypeScript。(@shepherdjerred)

有价值的补充

  • Java 的字面量模式可能仍处于预览阶段,并非一定已是正式稳定特性。(@armchairhacker)
  • 密封类型下模式匹配分支的默认分支应属多余,只是提出者并未用编译器验证,而是出于推断。(@panny)
  • TypeScript 通过 ts-pattern 这类库也能实现穷尽模式匹配,说明这类能力并非 Java 或 Kotlin 等语言独有。(@shepherdjerred)