Categories
程式開發

Java新技术:封闭类


Java新技术的第二篇文章,我们一起来看看封闭类(sealed class)。

封闭类提议进入JDK 15

2020年5月13日,封闭类提案“提交审议,目前还没有反对的声音。不出意外的话,该提案会在两个星期内获得批准,并且成为JDK 15的一部分。过一段时间,大概三五个星期的样子,你如果想要先睹为快,可以去下载JDK 15的抢先体验版“。

由于封闭类已经提交审议,并且目前还没有反对的声音和修改的建议,该提案基本定型,我们可以先睹为快了。

我已经有点等不及,快一起来看看这个新技术。

什么是封闭类?

简单地说,封闭类和封闭接口限制可以扩展或实现它们的其他类或接口。封闭类和封闭接口使用类修饰符sealed。

到Java SE 14为止,java.lang.constant.ConstantDesc的声明还是一个常规的公开接口。它的接口声明看起来像下面的样子:

package java.lang.constant;

public interface ConstantDesc {
...
}

这个接口或许会在JDK 15变更为封闭类,到时候,它的接口声明看起来是下面的样子:

package java.lang.constant;

public sealed interface ConstantDesc
permits String, Integer, Float, Long, Double,
ClassDesc, MethodTypeDesc, DynamicConstantDesc {
...
}

这个封闭接口的声明表明,只有permits关键字后面的类,包括String, Integer等,能够实现这个接口。permits关键字限定了封闭类或者封闭接口的可扩展范围。

类修饰符sealed和permits关键字一起,使得封闭类和封闭接口只能在一个限定的、封闭的范围内,获得扩展性。

为什么需要封闭类?

可扩展性不是面向对象编程的一个重要指标吗?为什么要限制可扩展性呢?其实,面向对象编程的最佳实践之一,就是要把可扩展性的限制在可以预测和控制的范围内,而不是无限的可扩展性。

在极客时间专栏《代码精进之路》“里,我们讨论了继承的安全缺陷“。其中,主要有两点值得我们格外小心(原文摘录,如涉及版权问题,希望极客时间可以许可我这里少许的引用):

一个可扩展的类,子类和父类可能会相互影响,从而导致不可预知的行为。涉及敏感信息的类,增加可扩展性不一定是个优先选项,要尽量避免父类或者子类的影响。

我们使用了 Java 语言来讨论继承的问题,其实这是一个面向对象机制的普遍的问题,甚至它也不单单是面向对象语言的问题,比如使用 C 语言的设计和实现,也存在类似的问题。

由于继承的安全问题,我们在设计API时,有两个要反省思考的问题“:

一个类,有没有真实的可扩展需求,能不能使用 final 修饰符?一个方法,子类有没有重写的必要性,能不能使用 final 修饰符?

限制住不可预测的可扩展性,是实现安全代码的一个重要目标。

目前而言,限制住可扩展性只有两个方法,使用私有类或者final修饰符。显而易见,私有类不是公开接口,只能内部使用。而final修饰符彻底放弃了可扩展性。要么全开放,要么全封闭,可扩展性只能在可能性的两个极端游走。全封闭彻底没有了可扩展性,全开放又面临固有的安全缺陷,这种二选一的状况有时候很让人抓狂,特别是设计公开接口的时候。

通过把可扩展性的限制在可以预测和控制的范围内,封闭类打开了全开放和全封闭两端之间的中间地带,为接口设计和实现提供了新的可能性。

我一点都不会奇怪,这一项技术很快、很快、很快就会被大面积采用。因为,苦可扩展性久矣!你大概可以猜到,我为什么这么着急地要聊聊封闭类了。

怎么使用封闭类?

怎么声明封闭类

封闭类的声明使用sealed类修饰符,然后在所有的extends和implements语句之后,使用permits指定允许扩展该封闭类的子类。 比如:

public sealed class Shape
permits Circle, Rectangle, Square {
...
}

permits关键字指定的许可子类(permitted subclasses),必须和封闭类处于同一模块(module)或者包空间(package)里。如果允许扩展的子类和封闭类在同一个源代码文件里,封闭类可以不使用permits语句,Java编译器将检索源文件,在编译期为封闭类添加上许可的子类。 比如下面的两种Shape封闭类的声明具有一样的运行时效果:

seal class Shape {
....
}

private final class Circle extends Shape {
...
}

private final class Rectangle extends Shape {
...
}

private final class Square extends Shape {
....
}

seal class Shape permits Circle, Rectangle, Square {
....
}

private final class Circle extends Shape {
...
}

private final class Rectangle extends Shape {
...
}

private final class Square extends Shape {
....
}

怎么声明许可类

许可类的声明需要满足下面的三个条件:

在编译期,封闭类和封闭接口必须可以访问它的许可类;许可类必须是封闭类或者封闭接口的直接扩展类或者直接实现类;许可类必须声明是否继续保持封闭。

许可类可以声明为终极类(final),从而关闭扩展性;许可类可以声明为封闭类(sealed),从而延续受限制的扩展性;许可类可以声明为解封类(non-sealed), 从而支持不受限制的扩展性。

比如下面的例子中,许可类Circle是终极类,Rectangle是封闭类,Square是解封类。

seal class Shape permits Circle, Rectangle, Square {
....
}

private final class Circle extends Shape {
...
}

private sealed class Rectangle extends Shape
permits TransparentRectangle, FilledRectangle{
...
}

private non-sealed class Square extends Shape {
....
}

需要注意的是,由于许可类必须是封闭类的直接扩展,因此许可类不具备传递性。也就是说,上面的例子中,TransparentRectangle是Rectangle的许可类,但不是Shape的许可类。

怎么判断许可类?

我们有时候需要判断一个封闭类声明的变量,是由哪一个子类实现的。由于许可类是子类的一个类型,我们的当然可以使用instanceof 关键字。

Shape flip(Shape shape) {
if (shape instanceof Circle) {
return shape;
} else if (shape instanceof Rectangle) {
return shape.flip();
} else if (shape instanceof Square) {
return shape;
}
}

不过,由于在编译期,封闭类已经知道了它所有的许可类,许可类的判断可以有更皮实的用法。上面的例子中,如果漏掉某一个if-else语句,编译器都不会报错。当然,也不应该报错。这其实也是一种的类的继承机制带来的常见问题。

不受限制的扩展性,使得我们没有办法穷举可能的子类。因此,不论我们怎么努力,我们都没有办法控制可扩展类的安全性和健壮性。封闭类为我们更好地控制子类提供了新的可能性。

通过和型式测试(JEP 375“,我们以后有时间聊聊这个新技术)技术的结合,编译器可以帮助我们检查穷举所有的许可类。比如,下面的例子中,如果遗漏掉了某一个case语句,编译器就会报错。

Shape flip(Shape shape) {
return switch (shape) {
case Circle c -> c;// no action needed
case Rectangle r -> r.flip();
case Square s -> s;// no action needed
};
}

所以,封闭类在把可扩展性的限制在可以预测和控制的范围内同时,还解决了子类穷举的问题。

我们可以学到什么?

最后,对于封闭类,我觉得有两点值得我们特别关注:

通过封闭类可以把可扩展性的限制在可以预测和控制的范围内,这为接口设计和实现提供了新的可能性;通过和型式测试的结合,许可类可以穷举,这使得我们可以更好地控制代码的安全性和健壮性。

作为对极客时间专栏《代码精进之路》“读者的回馈,编写安全的代码的评审清单“里,下面两项:

一个类,有没有真实的可扩展需求,能不能使用 final 修饰符?一个方法,子类有没有重写的必要性,能不能使用 final 修饰符?

可以添加两条内容,修改为:

一个类,有没有真实的可扩展需求,能不能使用 final 修饰符?一个类,如果有真实的可扩展需求,能不能枚举,可不可以使用sealed修饰符?对于instanceof的使用,需不需要关注遗漏的子类,能不能使用型式测试模式?一个方法,子类有没有重写的必要性,能不能使用 final 修饰符?

好的,今天我们就聊到这儿。