写点什么

Java 新特性前瞻:封印类

  • 2020-08-04
  • 本文字数:6276 字

    阅读完需:约 21 分钟

Java新特性前瞻:封印类

本文要点


  • 即将于 2020 年 9 月发布的 Java SE 15 将引入“封印类(sealed class)”(JEP 360),并将其作为预览特性。

  • 封印类是一种类或接口,对哪些类或接口可以扩展它们进行了限制。

  • 封印类就像枚举一样,可以捕获领域模型中的可选项,让程序员和编译器可以控制穷举。

  • 通过解耦可访问性和可扩展性,封印类有助于创建安全的继承结构,让程序库开发人员既可以公开接口,又能够控制所有的实现。

  • 封印类与记录类和模式匹配一起,为以数据为中心的编程模式提供支持。


Java SE 15(即将于 2020 年 9 月发布)引入 封印类作为预览特性。封印类和接口对可扩展它们的子类型具有更多的控制权, 这对于一般的领域建模和构建更安全的平台库来说都是很有用的。


我们可以用 sealed 来声明一个类或接口,这意味着只有一组特定的类或接口可以直接对其进行扩展:


sealed interface Shape     permits Circle, Rectangle { ... } 
复制代码


这段代码声明了一个叫作 Shape 的封印接口。permits 列表限制了只有“Circle”和“Shape”可以实现 Shape。(在某些情况下,编译器可以为我们推断出 permits 子句)。任何其他尝试扩展 Shape 的类或接口都将收到编译错误(如果你试图通过其他方式生成 Shape 子类,会在运行时出现错误)。


我们都知道可以通过 final 来限制扩展,而封印类可以被认为是广义的 final。限制可扩展的子类型将带来两个好处:超类型可以更好地指定可能的实现,而编译器可以更好地控制穷举(例如在 switch 语句或进行类型转换时)。封印类可与 记录类配对使用。

求和类型和乘积类型

上面的接口声明了 Shape 可以是 Circle 或 Rectangle,但不能是其他东西。换句话说,Shape 的集合等于 Circle 的集合加上 Rectangle 的集合。因此,封印类通常被称为求和(sum)类型,因为它们的值的集合是其他固定几种类型的值集合的总和。求和类型和封印类并不是什么新生事物,Scala 也有封印类,Haskell 和 ML 有用于定义求和类型的原语,有时候也被叫作标记联合(tagged union)或区分联合(discriminated union)。


求和类型经常与乘积类型一起使用。最近在 Java 中引入的记录类就是乘积类型,之所以被叫作乘积类型,是因为它们的状态空间是其组件的状态空间的笛卡尔乘积。(如果这么说听起来有点复杂,那么请将乘积类型看成元组,并将记录类看成名义上的元组)。让我们使用记录类继续声明 Shape 的子类型:


sealed interface Shape     permits Circle, Rectangle {       record Circle(Point center, int radius) implements Shape { }       record Rectangle(Point lowerLeft, Point upperRight) implements Shape { }  } 
复制代码


我们可以看到求和类型与乘积类型是如何结合在一起使用的。我们可以说“圆形是通过一个中心点和半径来定义的”、“矩形是通过两个点来定义的”以及“形状可以是圆形或矩形”。因为我们认为以这种方式共同声明基类及其实现是很常见的,所以当所有子类型都声明在同一编译单元中时,就可以省略 permits:


sealed interface Shape {       record Circle(Point center, int radius) implements Shape { }       record Rectangle(Point lowerLeft, Point upperRight) implements Shape { }  } 
复制代码

这样是不是违反了封装性原则?

面向对象建模鼓励我们隐藏抽象类的实现,不建议我们问“Shape 可能的子类型是什么”之类的问题,并告诉我们向下转换到特定的实现类是一种“代码坏味道”。那么,为什么我们要引入这个似乎违反了这些原则的语言特性呢?(我们也可以针对记录类提出同样的问题:要求在类表示与其 API 之间建立特定关系是不是违反了封装性原则?)


答案当然是“视情况而定”。在对抽象服务进行建模时,客户端通过抽象类型与服务进行交互可以降低耦合度,并最大限度地提高系统的演化灵活性。但是,在对特定领域进行建模时,如果该领域的特性已经是众所周知的,那么封装性原则可能就不一定会给我们带来多大好处。正如我们在记录类中所看到的那样,在对一些很普通的事物(例如点或 RGB 颜色)进行建模时,使用通用性对数据进行建模既需要做大量低价值的工作,而且更糟糕的是,这样通常会造成混淆。对于这种情况,封装性原则的成本已经超过了它的优势。


同样的结论也适用于封印类。在为一个简单且稳定的领域建模时,封装性原则并不一定会为我们带来好处,甚至还可能让客户端更加难以使用简单的领域内容。


当然,这并不说封装性原则是错误的,而是说成本和收益之间的权衡有时候不是那么明显。我们可以自己判断什么时候可以从中获得好处,什么时候会给我们造成阻碍。在选择是公开还是隐藏实现时,我们必须清楚封装性原则的好处和成本。通常,封装性是有好处的,但在为简单的领域建模时,封装性的好处可能会大打折扣。


如果一个类型,比如 Shape,限定了接口和实现类,我们就可以更放心地把它转成 Circle,因为 Shape 将 Circle 列为它的已知子类型之一。就像记录类是一种更透明的类,求和类型是一种更透明的多态性。这就是为什么求和类型和乘积类型会如此频繁一起出现。它们都代表了透明性和抽象性之间的某种折衷,因此,适合使用其中一个类型的地方也适合使用另一个类型。乘积和类型通常被称为 代数数据类型

穷举

像 Shape 这样的封印类限定了一系列子类型,有助于程序员和编译器作出推断,而如果没有这些信息,我们就做不到。其他工具也可以利用这些信息。Javadoc 工具在生成的文档页面中列出了封印类允许的子类型。


Java SE 14 引入了一种有限定的 模式匹配,在未来会进一步扩展。第一个版本允许我们在 instanceof 中使用类型模式:


if (shape instanceof Circle c) {     // 编译器已经为我们将shape转成Circle类型,并赋值给c     System.out.printf("Circle of radius %d%n", c.radius());  } 
复制代码


这离在 switch 中使用类型模式已经不远了。(Java SE 15 还不支持,但很快就会出现。)到了那个时候,我们可以使用 switch 表达式(case 后面直接是类型)来计算一个形状的面积,如下所示:


float area = switch (shape) {     case Circle c -> Math.PI * c.radius() * c.radius();     case Rectangle r -> Math.abs((r.upperRight().y() - r.lowerLeft().y())                                  * (r.upperRight().x() - r.lowerLeft().x()));     // 不需要提供默认情况! } 
复制代码


封印类在这里的作用是可以不使用默认子句,因为编译器从 Shape 的声明中已经知道 Circle 和 Rectangle 覆盖了所有形状,因此默认子句不会被执行。(编译器仍然会悄悄地在 switch 表达式中插入一个默认子句,这样做是为了防止在编译和运行这段时间内子类型发生变化,但没有必要让程序员来做这件事情。)这类似于对枚举进行 switch,因为枚举覆盖了所有已知的常量,所以也不需要使用默认子句。(对于这种情况,忽略默认子句通常会更好,因为使用默认子句好像在提醒我们是不是错过了某种情况)。


Shape 的继承结构给了客户端一个选择:它们可以完全通过抽象接口使用形状,也可以“展开”抽象,并在必要时与更具体的形状发生交互。模式匹配等特性使这种“展开”更易于阅读和编写。

代数数据类型示例

“乘积和”模式非常强大。最好的情况是,子类型列表不发生变化,并预计客户端会直接区分子类型,这样会更容易,也更有用。


限定一组固定的子类型,并鼓励客户端直接使用这些子类型,这是一种紧耦合的形式。在所有条件相同的情况下,我们鼓励使用松耦合的设计,以最大限度地提高灵活性,但这种松耦合也是要付出代价的。在编程语言中同时使用“不透明”和“透明”的抽象可以让我们根据实际情况选择合适的工具。


我们可能已经在 java.util.concurrent.Future API 中使用了一系列乘积和(如果当时这是一种选择的话)。Future 表示可以与其发起者并发执行的计算,Future 所代表的计算可能还没有开始、已经开始但还没有完成、已经成功完成(或已经完成但出现异常)、已经超时或被中断取消。Future 的 get()方法反映了所有这些可能性:


interface Future<V> {     ...     V get(long timeout, TimeUnit unit)         throws InterruptedException, ExecutionException, TimeoutException; } 
复制代码


如果计算尚未完成,get()会一直阻塞,直到完成。如果是成功的,则返回计算结果。如果抛出异常,异常将被封装在 ExecutionException 中。如果计算超时或被中断,则会抛出另一种异常。这个 API 非常精确,但使用起来有些痛苦,因为它有多个控制路径,不管是普通路径(get()返回一个值)还是失败路径,都必须在 catch 块中处理:


try {     V v = future.get();     // 处理一般的完成情况 } catch (TimeoutException e) {     // 处理超时 } catch (InterruptedException e) {     // 处理取消 } catch (ExecutionException e) {     Throwable cause = e.getCause();     // 处理失败 } 
复制代码


如果在 Java 5 引入 Future 时,我们已经有封印类、记录类和模式匹配,那么我们可能会这样定义返回类型:


sealed interface AsyncReturn<V> {     record Success<V>(V result) implements AsyncReturn<V> { }     record Failure<V>(Throwable cause) implements AsyncReturn<V> { }     record Timeout<V>() implements AsyncReturn<V> { }     record Interrupted<V>() implements AsyncReturn<V> { } } ... interface Future<V> {     AsyncReturn<V> get(); } 
复制代码


在这里,异步结果可以是成功(包含返回值)、失败(包含异常)、超时或取消。这是对可能出现的结果更为统一的描述,而不是用返回值描述其中的一些结果,再用异常描述另一些结果。客户端仍然需要处理所有的情况——无法回避任务可能会失败的事实——但我们可以统一地(并更紧凑地)处理这些情况(见脚注):


AsyncResult<V> r = future.get(); switch (r) {     case Success(var result): ...     case Failure(Throwable cause): ...     case Timeout(), Interrupted(): ... } 
复制代码

乘积和是一种广义的枚举

我们可以把乘积和看成是一种广义的枚举。枚举声明了一种类型,包含一组完整的常量实例:


enum Planet { MERCURY, VENUS, EARTH, ... } 
复制代码


我们可以将数据与每个常数关联起来,例如行星的质量和半径:


enum Planet {     MERCURY (3.303e+23, 2.4397e6),     VENUS (4.869e+24, 6.0518e6),     EARTH (5.976e+24, 6.37814e6),     ... } 
复制代码


封印类枚举的不是固定的实例列表,而是固定的实例类型列表。例如,这个封印接口列出了各种天体,以及与各种天体相关的数据:


sealed interface Celestial {     record Planet(String name, double mass, double radius)         implements Celestial {}     record Star(String name, double mass, double temperature)         implements Celestial {}     record Comet(String name, double period, LocalDateTime lastSeen)         implements Celestial {} } 
复制代码


正如我们可以对枚举常量进行 switch,我们也可以对各种天体进行 switch:


switch (celestial) {     case Planet(String name, double mass, double radius): ...     case Star(String name, double mass, double temp): ...     case Comet(String name, double period, LocalDateTime lastSeen): ... } 
复制代码


这种模式的例子随处可见:UI 系统中的事件、服务系统中的返回代码、协议中的消息,等等。

更安全的继承结构

到目前为止,我们已经讨论了在什么情况下封印类对领域建模是有帮助的。封印类还有另一个完全不同的应用:更安全的继承结构。


在 Java 里,我们通过将类标记为 final 来表示“这个类不能被继承”。final 在语言中的存在说明了一个关于类的基本事实:有时候类被设计为可扩展的,有时候则不是,我们希望同时支持这两种模式。实际上,《 Effective Java》建议我们“为扩展而设计,否则就禁止扩展”。这是一个很好的建议,如果编程语言在这方面为我们提供更多的帮助,我们可能会更容易接受这个建议。


可惜的是,编程语言在两方面未能帮到我们:默认的类是可扩展的,而 final 机制实际上非常弱,因为它迫使程序员在约束扩展和使用多态性之间做出选择。以 String 为例,字符串是不可变的,因此 String 不能被继承,这对平台的安全性来说至关重要——但对于实现来说,拥有多个子类型会更为方便。解决这个问题的成本是巨大的。 紧凑字符串对仅由 Latin-1 字符组成的字符串进行了特殊处理,从而显著降低了占用空间,并提升了性能,但如果 String 是一个封印类而不是 final 的类,这样做会更容易、成本更低。


有一种方法可以模拟封印类(不是接口),即使用包内可见的构造函数,并将所有实现放在同一个包中。虽然这样做是可以的,但令人感到不是很舒服,因为你要公开一个抽象类,但又不希望被扩展。程序库作者更喜欢使用接口来公开不透明的抽象,但抽象类是用来为实现提供辅助的,并不是建模工具(参见《Effective Java》的“Prefer interfaces to abstract classes”)。


有了封印接口,程序库作者不需要再纠结是使用多态性、是允许不受控制的扩展还是将抽象公开为接口——他们可以同时拥有这三种技术。作者可能会选择让实现类可访问,但更有可能让实现类保持封装性。


封印类允许程序库作者将可访问性与可扩展性解耦。这种灵活性很好,但我们应该在什么时候使用呢?当然,我们不希望将 List 变成封印接口,因为对于用户来说,创建新类型的 List 是完全合理和可取的。封印既有成本(用户不能创建新的实现)也有好处(可以全局控制实现),我们应该在好处高过成本的时候使用封印。

其他说明

sealed 可以用于修饰类或接口,但试图对一个 final 类添加 sealed 修饰符是不行的,不管这个类是显式地使用 final 声明,还是隐式地使用 final(比如枚举和记录类)。


一个封印类有一个允许扩展它的子类型列表,这些子类型必须在编译封印类时可用,必须是封印类的子类型,并且必须与封印类位于同一个模块中(如果是未命名的模块,就必须在同一个包中)。实际上这意味着它们必须与封印类一同维护,对于这种紧密的耦合,这样的要求是合理。


如果允许扩展的子类型都与封印类位于相同的编译单元中,那么 permit 子句可以省略。封印类不能作为 lambda 表达式的函数接口,也不能作为匿名类的基类。


封印类的子类型必须更明确地说明它们的可扩展性。封印类的子类型必须是 sealed、final 或显式标记为 non-sealed。(记录类和枚举是隐式 final,因此不需要显式标记。)如果类或接口的超类型不是 sealed,那么就不能将其标记为 non-sealed 的。


将已有的 final 类变成 sealed 的,不管是在二进制文件还是源码方面都是兼容的。但将非 final 类变成 sealed,不管是在二进制还是源代码方面都是不兼容的。在封印类中添加新的允许子类型是二进制兼容的,但不是源代码兼容的(这可能会破坏 switch 表达式的穷举性)。

总结

封印类有多种用途。如果有必要捕获领域模型中的一组完整可选项,可以将它们可以作为一种领域建模技术。如果需要解耦可访问性和可扩展性,可以将它们可以作为一种实现技术。封印类是对记录类的自然补充,因为它们一起形成了代数数据类型。它们也很适合用于模式匹配。Java 也很快会带来模式匹配。

脚注

这个示例使用了某种 switch 表达式形式——它使用模式作为 case——Java 还不支持这种形式。每六个月的发布周期允许我们同时设计功能,但可以单独交付。我们非常期待在不久的将来 switch 能够使用模式作为 case。

作者简介

Brian Goetz 是 Oracle 的 Java 语言架构师,JSR-335 (Java Lambda 表达式)规范负责人。他是畅销书《Java 并发实践》一书的作者,自 Jimmy Carter 担任美国总统以来,他就一直痴迷于编程。


英文链接


Java Feature Spotlight: Sealed Classes


2020-08-04 15:202171

评论

发布
暂无评论
发现更多内容

SLO(服务等级目标)与SLA(服务等级协议)

一个大红包

8月日更

云原生之可观测性【日志篇】 Logstash组件初探

路上的小崔哥

云原生 Logstash 日志 可观测性

我能行我能行!字节三面 + 微信四面 +PayPal 四面, 大厂面经分享

Java 编程 程序员 架构 IT

架构训练营毕业总结

冬天的树

流处理基本概念(二)

Databri_AI

大数据 flink 窗口函数

数字化加速碳基文明向硅基文明的演进

Taylor

数字化 数字孪生 碳基文明 硅基文明

总结

杨彬

#架构实战营

2021年最新最全:30W字!千道Java 后端面试大全(值得收藏)

Java 编程 程序员 架构 面试

年薪50W阿里P7架构师就会点这?并发丨JVM丨多线程丨Netty丨MySQL!

编程 架构 面试 IT 计算机

看完必让你直呼好家伙!阿里巴巴 6 月新作:“Java架构手册”

Java 编程 程序员 IT 计算机

架构师实战营模块四作业

袁小芬

架构设计能力提升

arctec

趁着课余时间学点Python(六)终止循环,阻断循环

ベ布小禅

8月日更

Docker可视化管理工具Portainer

xcbeyond

Docker Portainer 8月日更

13年培训出身!八年后成功坐上了阿里P7架构师的位置

Java 编程 程序员 架构 计算机

前端之数据结构(四)

Augus

数据结构 8月日更

【LeetCode】用两个栈实现队列Java题解

Albert

算法 LeetCode 8月日更

期末设计

Geek_9cf7b5

【架构实战营】毕业总结

swordman

架构实战营

我找遍了全网,总结出足足60W字“阿里大厂面试手册”

Java 程序员 IT 计算机 知识分享

三十多岁跳槽无路,晋升无门,濒临绝望之际受贵人指点,成功上岸阿里(Java 岗)

Java 编程 程序员 架构 计算机

Linux之crontab命令

入门小站

Linux

Java进程cpu100%问题排查

陈皮的JavaLib

Java Linux 面试 8月日更

白手起家之搜索利器Elastic search

卢卡多多

ES 8月日更

喜获蚂蚁金服、拼多多、字节跳动offer!纠结之后入职拼多多。

Java 编程 程序员 面试 计算机

毕业设计电商秒杀系统

梦寐凯旋

#架构实战营

业务架构图的定位:表达业务层级和关系

arctec

每个人都可以说不

escray

学习 极客时间 朱赟的技术管理课 8月日更

模块10作业

杨彬

#架构实战营

一周拿下百度Offer!211本+985硕+计算机专业~

Java 编程 面试 IT 计算机

Tensorflow随笔(二)

毛显新

人工智能 深度学习 tensorflow keras

Java新特性前瞻:封印类_语言 & 开发_Brian Goetz_InfoQ精选文章