Java 密封类和穷尽模式匹配
Java 17 引入了密封类,它允许你显式列出接口或基类的允许子类型。例如,这里有一个使用密封接口和记录(如果未给出显式列表,则隐式将内部类添加到允许的子类型列表中的玩具示例):public sealed interface SealedType { record TypeA() implements SealedType {} record TypeB() implements SealedType {} static SealedType of(String type) { return switch (type) { case "A" -> new TypeA(); case "B" -> new TypeB(); default -> throw new IllegalArgumentException(); }; } } 如果你熟悉具有代数数据类型的函数式编程语言,你可以将其视作类似于 Haskell 或 ML 中的数据类型声明:data SealedType = TypeA | TypeB 然后我们可以在一个简单的 Main 类中使用它:void main(String[] args) { var val = SealedType.of(args[0]); System.out.println(switch (val) { case SealedType.TypeA() -> "A"; case SealedType.TypeB() -> "B"; }); } 好吧,这没有那么令人兴奋。但这里需要注意的一点是,我们在主方法中的 switch 表达式中不需要添加默认块。这是因为密封类(和枚举)启用了穷尽性检查:编译器确切地知道可能的情况是什么,因此可以检查你是否覆盖了所有情况。如果你覆盖了,那么你就不需要一个默认块。如果你忘记了一个(并且没有默认块),那么你会得到编译时错误。当你希望确保某种类型的所有使用都覆盖所有情况时,这是非常好的,但这确实引入了新类型的破坏性更改:向密封类/接口添加新的子类型可能会破坏使用该代码的消费者。例如,向我们的示例添加新的 TypeC 情况将导致主方法因缺少情况而无法编译。因此,如果您在 API 中导出密封类型,则添加新的子类型是一个需要主要版本提升的破坏性更改(如果您遵循 SemVer)。 编译时错误还是运行时错误?虽然 Java 会在编译消费者(在这种情况下为 main)时针对非穷尽的 switch 产生编译时错误,但如果消费者在密封类型更改时没有重新编译,则无法做到这一点。例如,假设我们用另一个情况扩展我们的 SealedType: public sealed interface SealedType { record TypeA() implements SealedType {} record TypeB() implements SealedType {} record TypeC() implements SealedType {} static SealedType of(String type) { return switch (type) { case "A" -> new TypeA(); case "B" -> new TypeB(); case "C" -> new TypeC(); default -> throw new IllegalArgumentException(); }; } } 如果我们只是重新编译 SealedType.java 而不重新编译 Main,那么如果触发新情况就会出现运行时异常:这里抛出了新的 MatchException。Javadoc 提到了这种与单独编译相关的潜在问题,以及模式中 null 的一些边界情况。因此,即使你希望使用密封类可以静态保障在添加新情况时更新所有消费者,但除非你重新编译所有代码,否则情况并非如此。 结论 我认为我的结论是,密封类型可能在组件的实现中最有用,而在组件提供给其他组件的公共 API 中则不太有用(例如库)。对于内部使用,通常会一起重新编译所有内容,因此你可以获得穷尽性检查和更高编译时安全保证的良好特性。但在跨模块边界使用时,你可能只会引入破坏代码的新方式,这常常只能在运行时检测到。(我在审查 PEM 编码的加密对象的预览支持时发现了这些微妙之处,它确实犯了将密封接口嵌入公共 API 的错误,并建议客户端对该类型进行模式匹配。如果他们想添加新情况,我预测会有非常高的破坏率。)
本站免费、广告极少。如果觉得有帮助,可以请我们喝杯咖啡 —— 任何金额都对持续运营有实际帮助。
☕请我喝杯咖啡