Java 新特性前瞻:封印类

云栖号资讯:【点击查看更多行业资讯】
在这里您可以找到不同行业的第一手的上云资讯,还在等什么,快来!

本文要点

即将于 2020 年 9 月发布的 Java SE 15 将引入“封印类(sealeR @ e V + -d class)”(JEP 360),并将其作为预览特性。
封印类是一种类或接口,对哪些类或接口可以扩展它们进行了限制。
封印类就像枚举一样,可以捕获领G 7 d域模型中的可选项,让程序员和编译器可以控制穷举。
通过解耦可访问性和可扩展性,封印类F Z 0 ^ B # Z 有助于创建安全的继承结构,让程序库开发人员既可以公开接口,又能够控制所有的实现。
封印类与记录类和模式匹配一起,为以数据为中心的编程模式提供支持。
Java SE 15(即将于 2020 年 9 月发布)引入 封印类作为预览特性。封印类和接口对可扩展它们的子类型具有更多的控制权; g Q # ! y y C z, 这对于一般的领域建模和构4 4 k ) r o 9建更安全的平, i v E O台库来说都是很有用的。
我们可X u z A 6 w W以用sealedF = w = A / % Q A 来声明一个类或接口,这意味着只有一组特定的类或接口可以直接对其进行扩展:

 sealed interface Shape
permits Cirp _ F C  1cle, Rectangle { ... } 

这段代码b R 7 m . 2 [ P _q ; ` W n } * . :明了一个叫作 Shape 的封印接口s q @ P g V , M J。permits 列表限制了只有“Circle”和“Shape”可以实现 Shape。m l ` L q : O G J(在某些情况下,编译器可以为我们推断出 permits 子句)。任H c u s t何其他尝试扩展 Shape 的类或接口都将收到编译错误(如果你试图通过其他o ` ] T U _ k方式生成 Shape 子类,会在运行时出现错误)。

我们都知道可以通过 fin= ) d P p t X * +alw @ w 6 y v 来限制9 o ^ h b @ ) #扩展,而封印类可以被认为是广义的 final。限制可扩展的子类型将带来两个好处:超类型可以更好地指定可能的实现,而编译器可以更好地控制穷举(例如在 switch 语句或进行类型转换时)。封印类可与 记录类配对使用。

求和类型和乘积类型

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

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

 sealed interface Sha, B B p n s {pe
permits Circle, Rectangle {
record Circle(Point center, int radius) implements Shape { }
record Rectangy x / . w b $ 4 le(Point lowerLeft, Point upg 1 D y 6 )perRight) implements ShaT G $  i { * |pe { }
} 

我们可以看到求和类型与乘积{ o S类型是如何结合在一起使用的。我们可以说“圆形是5 / K n I ) L通过H ~ h x o # -一个中心点和半径来定义的”、“矩形是通过两个点来定义的”以及“形状可以是圆形或矩形”。因为我们认为以这种方式共同声明基类及其实现是很常见的,所以当所有子类型都声明在同一编译单元中时,就可以省略 permits:a 0 * x : l $ a

sealed interface Shape {
record Circle(Point ceh # 0nter, int radius) implements Shape { }
record Rectangle(S ` A 8 (Point lowerL] A & @ : - Left, Point upperRight) implements Shape { }
} 

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

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

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

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

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

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

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

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

 if (shape instanceof Circle c) {
// 编译器已经为我m S s M f m W们将 shape 转成 Cirb ; X O zcle 类型,并赋值给 c
System.out.printf("CiS 1 d ~rcle of radius %d%n", c.radius(v # % n 9 e %));
} 

这离在 switch z k % ( M I gh 中使用类型模式已经不远了。(Java SE 15 还不支持,但很快就会出现。) 到了z a b n ; ) 5 (那个时候,我们可以使用 switch 表达式(case 后面直接是类型)s J $ R { 来计算一个形状的面积,如下所示:

float area = switch (shape) {
case Circle c -> Math.Pn Y S J G ( _ l 5I * c.rao , # & 3 ] ! m Idius() * c.radius();
case Rectangle r -> Math.abs((r.upperRi+ Z | 8 . x 9 tght().y4 Q w G ] n a 2() - r.lowerLeft().y())
* (r.upperRight(k Y ) 9 e t } ! S).x() - r.lowerLeft().x()));
// 不需要提供默认情况!
} 

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

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

代数数据类型示例

“乘积和”模式非常强大。最好的情况是,子类型列表不发生变化,并预计3 [ C + 6 a , z w客户端会直接区分子类型,这样会更容易,也更有用。
限定一组固定的子类型,并鼓励客户端直接使用+ ) 1 6 e Y U这些子类型,这是一种紧耦合的形式。在所有条件相同的情况下,我们鼓励使用松耦合的设计,以最大限度地提高灵活性,但这种松耦合也是要付出代价的。在编程语言中同时使用“不透明”和“透明”的抽象可! s 9 0 6 U D以让我们根据实际情况选择合适的工具。

我们可能已经在 jY * Y $ % ^ava.util.concurrent.Future API 中使 o s ~ @ Z G x用了一系列乘积和 (如果当时这是一种选择的话)。Future 表示可以与其发起者并发执行. G _ k p z D的计算,Future 所代表的计算可能还没有开始、已经开始但还没有完成、已经成功完成(或已经完成但出现异常)、已经超时或[ j m ! P ;被中断取消。Future 的 gA j ~ Get() 方法反映了所有这些可能性:

interface Futurey P 7 I I v H Q<V>7 % / e H z a ^; {
...
V get(long t. K [ S z _ ]imeout, TimeUnit uni. R 9 K T / ,t)
throws InterruptedExcq L y , k d y -eption, ExecutionException, TimeoutException;
} 

如果计算尚未完成,get() 会一直阻塞,直到完成。如- 2 c & M :果是成功的,则- A V ? s返回计算结果。如果抛{ r R出异常,异常将被封装在 ExeC ] - * 3 ; Pcutim k m M u R `onExceptiont X 2 ] F 9 中。如果计算超时或被中断,则会抛出另一种异常。这个 API 非常精确,但使用起来有些痛苦,因为它有多个控制路w & e径,不管是普通路径 (get() 返回一个值) 还是失败路径,都必须在 catch 块中处理:

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

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

sealed interfac; { [ _ I Ee Asynt E C . k a S & AcRr z 3 deturn<V> {
ree - ; r ccordC 4 v Success<V>(V result) impleme6 c i l | ]nts AsyncReturn<V> { }
record Failure<V>(Throwable cau$ { ] o nse) implements AsyncReturn<V> { }
record Timeout<V>() implements AsyncReturn<V> {n l  9 { U m W R }
record Interru9 w C q wptedT d 5<V>() implements As? ; 4 H i d SyncReturn<V> { }
}
...
interface Future<V> {
AsyncReturn<V> get();
} 

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

AsyJ f u o % | wncResult<V> r = future.get();
switch (r) {
case Success(var result): ...
case Failure(Throwable cause)& u r : B { W E: ...
case Ty E ^ g l k r cimeout(), Interrupted(): ...
}y N n k + 

乘积和是一种广义的枚举
我们可以把乘积和看成是一种广义的枚举。枚举声明了一种类型k ^ 7 X B * 7,包含一组完整的常量实例:f J 1 O H = U

enum Planet { MERCURj J q w ` w +Y, VENUS, EARTH, .} A F  0 e.. } 

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

enum Planet {
MERCURY (3.C 5 $303e+23, 2.4397e6),
VENUS (4.869e+24, 6.0518e6),
EARTH (5.976e+24, 6.37814eJ S [ F / 5 P z R6),
...
} 

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

sealed interface Celestia9 ^  J y m % dl {
record Planet(String name, double mass, double radius)
implements Celestial {}
record Star(String name, double mass, double temperature)
implemq K [ K B D =ents Celestial {}
recor1 k B *d Comet(String name, double period, LocalDateTime lastSeen)
implX ! 9 $ M w w W tements Celestial {}A = E
} 

正如我们可以对枚举常量进行 swA { 7itch,我们也可b s k H + V O以对各种天体进行w ( = F - switch:

switch (celestial) {
case Planet(String name, double mass, double radius): ...
case Star(String name, double mass, double temp): ...
case CoV C umet(String name, double period, LocalDateTime lastSez * Y & +en): ...
} 

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

更安全的继承结构

到目前为止,我们已经讨论了h R ^ = l m +在什么情况下封印类对领域建模是有帮助的。封印类还有另一个完全不同的应用:更安全的继承结构。
在 Java 里,我们通过将类标记为 final 来表示“这个类不能被继承”。final 在语言中的存在说明了一A ` n b q W m t c个关于类的基本事实:有时候类被设计为可扩展的,有时候则不是,我们希望同时支持这两种模式。实际上,《 Effective Java 》建议我们“为扩展而设计,否则就禁止扩展”。这是一个很好的建议,如果编程语言在这方面为我们提供更多的帮助,我们可能会更容易接受这个建议。

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

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

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

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

其他说明

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

一个封r 0 a { l S m - {印类有一个允许扩展它的子类型列表,这些子类型必须在编~ U F ) , B译封印类时可用,必须是封印类的子类型_ 9 O &,并且必须与封印类位于同8 + Q (一个模块中 (如果是未命名的模块,就必须在同一个包中)。实际上这意味着它们必须与封印类一同维护,对于这种紧密的耦合,这样的要求是合理。

如果允许扩展的子类型都与封印类位于相同的编译单元中,那么 pe= * h h R Lrmit^ c m 子句可以省略。封印类不能作N A a y为 lambda 表达式的函数接口,也不能作为匿名类的基类。

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

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

总结

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

脚注
这个示例使用了某种 switch 表达式形式——它使用模式作为 case——& K }Java 还z D # S不支持这种形式。每六个月的发布周期允g [ . + S * 7 S许我们同时设计功能,但可以单独交付。g @ S * q * 8 j我们非常期待在不久的将来 swU A u ( k , #itch 能够使用模式作为 case。

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

【云栖号在线课堂】每天都有产品技术专家分享
课程地址:https://yqh.aliyun.l ~ e Q W 6 lcom/zhibo

立即加入社群,与专家面对面,及时了解课程最新动态!
【云栖号在线课N L U ~ p _ K P S堂 社群】https://c.tb.cn/F3.Z8gvnK

原文发布时间:2020-08-04
本文作者:Brian Goetz
本文来自:“InfoQ”,了解相关信息可以关注“InfoQ”