JEP 360:密封类(预览)
原文:https://openjdk.org/jeps/360
翻译:张欢
使用密封类和接口增强Java编程语言。密封类和接口可以限制哪些其他类或接口可以扩展或实现它们。
目标
- 允许类或接口的作者可以控制哪些代码有责任实现它。
- 提供比访问修饰符更具声明性的方式来限制父类的使用。
- 通过对模式进行详尽的分析来支持模式匹配的未来方向。
非目标
- 提供诸如“friends”等新形式的访问控制不是目标。
- 以任何方式改变
final
不是目标。
动机
在Java中,类层次结构可以通过继承来实现代码的复用:父类的方法可以被许多子类继承(并因此被复用)。但是,类层次结构的目的并不总是复用代码。有时,其目的是对域中存在的各种可能性进行建模,例如图形库支持的形状类型或金融应用程序支持的贷款类型。当以这种方式使用类层次结构时,限制子类集可以简化建模。
例如,在图形库中,Shape
类的作者可能希望只有特定的类可以继承Shape
,因为该库的大部分工作都涉及以适当的方式处理每种形状。作者感兴趣的是处理Shape
的已知子类的代码的清晰度,而不是编写代码来防御未知的Shape
子类。允许任意类继承Shape
,从而继承其代码以供重用,在这种情况下不是目标。不幸的是,Java假定代码重用始终是一个目标:如果Shape
可以扩展,那么它可以由任意数量的类继承。放宽这个假设是有帮助的,这样作者就可以声明一个类层次结构,它不能被任意类扩展。在这样一个封闭的类层次结构中,代码重用仍然是可能的,但不能超越一切。
Java开发者很熟悉限制子类范围的想法,因为它经常出现在API设计中。该语言在这方面提供了有限的工具:要么将类设为final
,使其具有零个子类,要么将类或其构造器设为包级私有,因此它只能在同一个包中包含子类。包级私有超类的示例出现在JDK中:
当目标是代码重用时,包级私有方法很有用,例如AbstractStringBuilder
的子类共享它的append
代码。然而,当目标是对备选进行建模时,包级私有的处理就没用了,因为用户代码无法访问关键抽象——父类——以便对其进行switch
处理。不可能在允许用户访问父类时,又不让他们继承它。(即使在声明Shape
及其子类的图形库中,只有一个包可以访问Shape
也是不幸的。)
总之,父类应该可以被广泛访问(因为它代表用户的重要抽象)但不能广泛扩展(因为它的子类应该仅限于作者已知的那些)。这样的父类应该能够表示它是与一组给定的子类共同开发的,既可以记录读者的意图,又可以让Java编译器强制执行。同时,父类不应过度限制其子类,例如强制它们为final
或阻止它们定义自己的状态。
描述
密封类或接口只能由允许的类和接口进行继承或实现。
一个类通过对它的声明应用sealed
修饰符来密封。然后,在任何extends
和implements
子句之后,用permit
子句指定允许扩展密封类的类。例如,以下Shape
声明指定了三个允许的子类:
由permit
指定的类必须位于父类附近:在同一个模块中(如果父类在已命名的模块中),或在同一个包中(如果父类在未命名的模块中)。例如,在下面的Shape
声明中,其允许的子类都位于同名模块的不同包中:
当允许的子类的大小和数量都较小时,将它们声明在与密封类相同的源文件中可能会很方便。当它们以这种方式声明时,sealed
类可能会省略permit
子句,Java编译器将从源文件中的声明(可能是辅助类或嵌套类)推断出允许的子类。例如,如果在Shape.java
中找到以下代码,则推断密封类Shape
具有三个允许的子类:
密封一个类的目的是让客户端代码对所有允许的子类进行清晰和结论性的推断。推断子类的传统方法是使用instanceof
测试的if-else
链,但是编译器很难分析这样的链,因此无法确定测试是否涵盖所有允许的子类。例如,以下方法会导致编译期错误,因为编译器并不认同开发者的信念,即Shape
的每个子类都经过测试并导致返回语句:
附加一个捕获所有的else
子句将违背开发者的初衷,即测试已经详尽无遗。此外,如果他们的初衷被证明是错误的,编译器也没有能力拯救开发者。假设上面的代码被意外编辑忽略了,比如说,instanceof Rectangle
测试;不会发生编译时错误。(使用三个允许的子类可能很容易发现遗漏,但10或20个则不然。即使只有三个,代码编写起来也令人沮丧,阅读起来也很乏味。)
将在支持模式匹配的未来版本中实现对允许的子类进行清晰和结论性推理的能力。客户端代码将能够使用类型测试模式(JEP 375)切换实例,而不是使用if-else
检查密封类的实例。 这允许编译器检查模式是否详尽。例如,给定以下代码,编译器将推断Shape
的每个允许的子类都被覆盖,因此不需要default
子句(或其他总模式);此外,如果缺少以下三种情况中的任何一种,编译器都会报错:
密封类对其允许的子类(由其permit
子句指定的类)施加三个约束:
- 密封类及其允许的子类必须属于同一个模块,并且,如果在未命名的模块中声明,则属于同一个包。
- 每个允许的子类必须直接扩展密封类。
- 每个允许的子类必须选择一个修饰符来描述它如何延续由其父类发起的密封:
- 允许的子类可以声明为
final
,以防止其在类层次结构中的部分被进一步向下继承。 - 允许的子类可以声明为
sealed
,以允许其层次结构的一部分比其密封的父类所设想的扩展得更远,但以受限制的方式。 - 一个允许的子类可以被声明为
non-sealed
,这样它的层次结构部分就会恢复到对未知子类的扩展开放。(密封类不能阻止其允许的子类这样做。)
- 允许的子类可以声明为
作为第三条约束的例子,Circle
可以为final
的,Rectangle
为sealed
的,而Square
为non-sealed
的:
每个允许的子类必须使用一个且仅一个修饰符final
、sealed
和non-sealed
。一个类不可能既是sealed
的(暗示有子类)又是final
的(暗示没有子类),或者既是non-sealed
的(暗示子类)又是final
的(暗示没有子类),或者既是sealed
的(暗示受限制的子类)又是non-sealed
的(暗示不受限制的子类)。
(final
修饰符可以被认为是一种强密封形式,其中完全禁止扩展/实现。也就是说,final
在概念上等同于sealed
加上一个不指定任何内容的permits
子句;注意这样的permits
子句不能用Java编写。)
抽象类。sealed
或non-sealed
的类可以是abstract
的,并且具有abstract
的成员。sealed
类可以允许abstract
的子类(前提是它们是sealed
的或non-sealed
的,而不是final
的)。
*类可访问性。*由于extends
和permits
子句使用类名,因此允许的子类及其密封超类必须可以相互访问。但是,允许的子类彼此之间不需要具有相同的可访问性,也不需要与密封类具有相同的可访问性。特别是,子类可能比密封类更难访问;这意味着,在未来版本中,当switch
支持模式匹配时,除非使用default
子句(或其他总模式),否则某些用户将无法彻底switch
子类。将鼓励Java编译器检测用户的switch
何时不像用户想象的那样详尽,并自定义错误消息以推荐default
子句。
密封接口
与类的情况类似,通过将sealed
修饰符应用于接口来密封接口。在任何用于指定超接口的extends
子句之后,使用permits
子句指定实现类和子接口。例如:
密封类与记录
密封类与记录(JEP 384)配合得很好,这是Java 15的另一个预览特性。记录是隐式final
的,因此带有记录的密封层次结构比上面的示例稍微简洁一些:
密封类和记录的组合有时被称为代数数据类型:记录允许我们表达product类型,密封类允许我们表达sum类型。
JDK中的密封类
在JDK中如何使用密封类的一个例子是在java.lang.constant
包中,它为JVM实体的描述符
进行建模:
Java语法
JVM对密封类的支持
Java虚拟机在运行时识别sealed
类和接口,并防止未经授权的子类和子接口进行扩展。
尽管sealed
是一个类修饰符,但ClassFile
结构中没有ACC_SEALED
标志。相反,密封类的class
文件具有PermittedSubclasses
属性,该属性隐式指示sealed
修饰符并显式指定允许的子类:
允许的子类列表是强制性的——即使编译器推断出允许的子类,这些推断的子类也明确包含在PermittedSubclasses
属性中。
允许的子类的class
文件没有新属性。
当JVM尝试定义其父类或父接口具有PermittedSubclasses
属性的类时,所定义的类必须由该属性命名。否则,将引发IncompatibleClassChangeError
。
反射API
下面的public
方法将被添加到java.lang.Class
中:
java.lang.constant.ClassDesc[] getPermittedSubclasses()
boolean isSealed()
方法getPermittedSubclasses()
返回一个包含java.lang.constant.ClassDesc
对象的数组,如果该类是密封的,则表示该类的所有允许的子类;如果该类未密封,则返回一个空数组。
如果给定的类或接口是密封的,则方法isSealed
返回true
。(就像isEnum
。)
备选方案
某些语言直接支持代数数据类型(ADT),例如Haskell的data
特性。通过enum
特性的变体,可以更直接地以Java开发人员熟悉的方式表达ADT,其中可以在单个声明中定义sum和product。但是,这不会支持所有所需的用例,例如sum范围跨越多个编译单元中的类,或者sum范围跨越非product类的那些用例。
permits
子句允许密封类,例如前面显示的Shape
类,可以由任何模块中的代码访问以进行调用,但只能由与密封类(或相同包,如果在未命名的模块中)。这使得类型系统比访问控制系统更具表现力。单独使用访问控制,如果Shape
可以被任何模块中的代码访问以调用(因为它的包被导出),那么Shape
在任何模块中也可以被访问以实现;如果Shape
在任何其他模块中不可访问以进行实现,那么Shape
在任何其他模块中也不可访问以进行调用。