写点什么

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:202386

评论

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

大型软件交付项目注意事项53条

IT民工大叔

项目管理 SaaS

实战大数据,HBase 性能调优指南

编程江湖

HBase

参加过 4 届 TiDB Hackathon 是一种什么体验? | TiDB Hackathon 选手访谈

PingCAP

用户文章转载:P4 Rmdir 会自动删除空目录?不,没那么简单

龙智—DevSecOps解决方案

perforce P4 P4 Rmdir

智汇华云 | 使用Kubeadm进行Kubernetes集群版本升级

华云数据

科技令生活“焕然一新”|年末清洁黑科技:美菱洗地机!

联营汇聚

美菱洗地机

走访数年,编撰3年:你能看到的互联网企业案例最多的一本书

博文视点Broadview

给弟弟的信第27封|学会说不,别让面子害了你

大菠萝

28天写作

直播预告|智能运维管理平台OMP核心特性及落地场景介绍

云智慧AIOps社区

DevOps 运维 AIOPS 智能运维 运维管理

智汇华云 | 桌面云卓越体验下的协议技术解析

华云数据

观点 | NoSQL 产品的 SaaS 化之路

RadonDB

数据库 nosql NoSQL 数据库

如何使用Kubernetes里的NetworkPolicy

汪子熙

Kubernetes k8s 28天写作 docker build 12月日更

Java序列化与反序列化

编程江湖

低代码行业浅析

凹凸实验室

前端 低代码

探索 Design Token

凹凸实验室

前端 设计

带你详细了解 Node.js 中的事件循环

编程江湖

前端开发 node,js

Linux之find常用命令汇总

入门小站

盘点 2021|从零开始,向前出发

Middleware

生涯规划 个人成长 盘点2021 2021年终总结

netty系列之:好马配好鞍,为channel选择配套的selector

程序那些事

Java Netty 程序那些事 12月日更

AI实战分享 | 基于CANN的辅助驾驶应用案例

华为云开发者联盟

目标检测算法 CANN 昇腾 辅助驾驶 AscendCL

【转】前端开发之React Native SDK 升级问题

@零度

前端 React Native

带你了解家居智能的心脏:物联网关

华为云开发者联盟

物联网 智能家居 物联网关 智能网关 家庭网络

SphereEx 亮相 openGauss Summit 2021,同云和恩墨签订战略合作协议

SphereEx

开源 ShardingSphere SphereEx 云和恩墨 战略合作

从翻硬币游戏看敏捷开发

华为云开发者联盟

敏捷 敏捷开发 软件开发 团队 开发

seata入门介绍与seata-service部署与验证(一)

恒生LIGHT云社区

架构 分布式 seata

计划被打乱怎么办?

Tiger

28天写作

【转】java开发之批处理框架 Spring Batch

@零度

JAVA开发 spring batch

拍乐云解析融合语音通话技术实践

拍乐云Pano

音视频 RTC 拍乐云 技术干货 融合语音通话

中国联通、欧莱雅和钉钉都在争相打造的秘密武器?虚拟IP未来还有怎样的可能

行者AI

人工智能 虚拟

智汇华云 | ArSDN给VMware带来了什么

华云数据

行业分析| 实时音视频的未来

anyRTC开发者

音视频 WebRTC 实时通信 语音通话 视频通话

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