JEP 409:密封类
总结
通过密封类和接口增强 Java 编程语言。密封类和接口限制了其他哪些类或接口可以扩展或实现它们。
历史
目标
-
允许类或接口的作者控制哪些代码负责实现它。
-
提供一种比访问修饰符更具声明性的方式来限制超类的使用。
-
通过为模式匹配中的模式穷举分析提供基础,支持未来的发展方向。
非目标
-
提供诸如“friends”之类的新访问控制形式并不是目标。
-
以任何方式改变
final
并不是目标。
动机
事实证明,类和接口的继承层次结构的面向对象数据模型在对现代应用程序处理的现实世界数据进行建模时非常有效。这种表达能力是 Java 语言的一个重要方面。
然而,也存在一些可以有效控制这种表达性的情况。例如,Java 支持使用 枚举类 来建模某个类只有固定数量实例的情形。在下面的代码中,一个枚举类列出了行星的固定集合。它们是该类仅有的值,因此你可以全面地对它们进行 switch
操作 —— 而无需编写 default
子句:
enum Planet { MERCURY, VENUS, EARTH }
Planet p = ...
switch (p) {
case MERCURY: ...
case VENUS: ...
case EARTH: ...
}
使用枚举类来建模固定的一组值通常很有帮助,但有时我们希望对一组固定的类型的值进行建模。我们可以通过使用类层次结构来实现这一点,不是作为代码继承和重用的机制,而是作为一种列出值类型的方式。在我们的行星示例基础上,可以按照以下方式对天文学领域中的值类型进行建模:
interface Celestial { ... }
final class Planet implements Celestial { ... }
final class Star implements Celestial { ... }
final class Comet implements Celestial { ... }
然而,这种层次结构并没有反映出我们的模型中只有三种天体这一重要领域知识。在这种情况下,限制子类或子接口的集合可以简化建模过程。
考虑另一个例子:在图形库中,Shape
类的作者可能希望只有特定的类可以继承 Shape
,因为库的许多工作涉及以适当的方式处理每种形状。作者关注的是处理已知 Shape
子类的代码清晰性,而不关心编写代码来防御未知的 Shape
子类。允许任意类扩展 Shape
并因此继承其代码以供重用,在这种情况下并不是目标。然而,Java 假设代码重用始终是一个目标:如果 Shape
可以被扩展,那么它可以被任意数量的类扩展。放宽这一假设将会很有帮助,这样作者就可以声明一个不对任意类开放扩展的类层次结构。在这种封闭的类层次结构中,代码重用仍然是可能的,但超出该范围则不行。
Java 开发者对限制子类集合的概念并不陌生,因为这在 API 设计中经常出现。语言在此领域提供的工具有限:要么将一个类声明为 final
,使其没有子类;要么将类或其构造函数设为包私有(package-private),这样它只能在同一个包中拥有子类。JDK 中有一个包私有超类的示例 出现在 JDK 中:
package java.lang;
abstract class AbstractStringBuilder { ... }
public final class StringBuffer extends AbstractStringBuilder { ... }
public final class StringBuilder extends AbstractStringBuilder { ... }
包级私有方法在目标是代码重用时非常有用,例如让 AbstractStringBuilder
的子类共享其 append
方法的代码。然而,当目标是建模替代选项时,这种方法就毫无用处了,因为用户代码无法访问关键的抽象——即超类——从而无法在其上进行 switch
操作。允许用户访问超类但不允许他们扩展它,如果不使用涉及非 public
构造函数的脆弱技巧(这些技巧对接口无效),就无法明确指定这种行为。在声明了 Shape
及其子类的图形库中,如果只有一个包可以访问 Shape
,那将是非常遗憾的。
总之,一个超类应该能够被广泛地 访问(因为它对用户来说代表了一个重要的抽象),但不能被广泛地 扩展(因为它的子类应该限制在作者已知的范围内)。这样的超类的作者应该能够表达出它与一组特定的子类共同开发的意图,既为了向读者记录设计意图,也为了让 Java 编译器能够进行约束。同时,超类不应通过例如强制子类为 final
或阻止它们定义自己的状态等方式,对其子类施加不必要的限制。
描述
密封类或接口只能由允许这样做的类和接口扩展或实现。
通过在类声明中应用 sealed
修饰符,可以将类密封。然后,在任何 extends
和 implements
子句之后,permits
子句指定了允许扩展密封类的类。例如,以下 Shape
的声明指定了三个允许的子类:
package com.example.geometry;
public abstract sealed class Shape
permits Circle, Rectangle, Square { ... }
permits
指定的类必须位于超类附近:要么在同一个模块中(如果超类位于命名模块中),要么在同一个包中(如果超类位于未命名模块中)。例如,在下面的 Shape
声明中,其允许的子类都位于同一命名模块的不同包中:
package com.example.geometry;
public abstract sealed class Shape
permits com.example.polar.Circle,
com.example.quad.Rectangle,
com.example.quad.simple.Square { ... }
当允许的子类在大小和数量上都较小时,可以方便地将它们与密封类声明在同一个源文件中。以这种方式声明时,sealed
类可以省略 permits
子句,Java 编译器会从源文件中的声明推断出允许的子类。(这些子类可以是辅助类或嵌套类。)例如,如果在 Root.java
中找到以下代码,则推断出密封类 Root
具有三个允许的子类:
abstract sealed class Root { ...
final class A extends Root { ... }
final class B extends Root { ... }
final class C extends Root { ... }
}
permits
指定的类必须具有规范名称,否则会报告编译时错误。这意味着匿名类和局部类不能作为密封类的允许子类型。
密封类对其允许的子类施加了三个限制:
-
密封类及其允许的子类必须属于同一个模块,并且,如果声明在未命名的模块中,则必须属于同一个包。
-
每个允许的子类必须直接扩展密封类。
-
每个允许的子类必须使用一个修饰符来描述它如何传播由其超类发起的密封:
作为第三个约束的示例,Circle
和 Square
可能是 final
的,而 Rectangle
是 sealed
的,并且我们添加了一个新的子类 WeirdShape
,它是 non-sealed
的:
package com.example.geometry;
public abstract sealed class Shape
permits Circle, Rectangle, Square, WeirdShape { ... }
public final class Circle extends Shape { ... }
public sealed class Rectangle extends Shape
permits TransparentRectangle, FilledRectangle { ... }
public final class TransparentRectangle extends Rectangle { ... }
public final class FilledRectangle extends Rectangle { ... }
public final class Square extends Shape { ... }
public non-sealed class WeirdShape extends Shape { ... }
即使 WeirdShape
对未知类开放扩展,那些子类的所有实例也仍然是 WeirdShape
的实例。因此,编写代码来测试 Shape
的某个实例是 Circle
、Rectangle
、Square
还是 WeirdShape
仍然是详尽无遗的。
每个允许的子类必须使用 final
、sealed
和 non-sealed
这三个修饰符中的一个。一个类不可能同时是 sealed
(意味着有子类)和 final
(意味着没有子类),也不可能是 non-sealed
(意味着有子类)和 final
(意味着没有子类),或者同时是 sealed
(意味着受限制的子类)和 non-sealed
(意味着不受限制的子类)。
(final
修饰符可以被视为密封的一个特例,它完全禁止了扩展或实现。也就是说,final
在概念上等同于 sealed
加上一个未指定任何内容的 permits
子句,尽管这样的 permits
子句无法被写出。)
一个被声明为 sealed
或 non-sealed
的类可以是 abstract
的,并且可以包含 abstract
成员。一个 sealed
类可以允许子类是 abstract
的,只要这些子类本身被声明为 sealed
或 non-sealed
,而不是 final
。
如果任何类扩展了 sealed
类,但未被允许这样做,则会产生编译时错误。
类的可访问性
由于 extends
和 permits
子句使用了类名,因此允许的子类和其密封的超类必须能够相互访问。然而,允许的子类之间不需要具有相同的可访问性,也不需要与密封类相同。特别是,子类的可访问性可以比密封类更低。这意味着,在未来的版本中,当 switch
支持模式匹配时,除非使用了 default
子句(或其他全模式),否则某些代码将无法穷尽地对子类进行 switch
操作。Java 编译器会被鼓励去检测 switch
是否没有原作者想象的那样穷尽,并定制错误消息来推荐使用 default
子句。
密封接口
至于类,可以通过对接口应用 sealed
修饰符来密封接口。在任何用于指定超接口的 extends
子句之后,实现类和子接口通过 permits
子句来指定。例如,上面的行星示例可以重写如下:
sealed interface Celestial
permits Planet, Star, Comet { ... }
final class Planet implements Celestial { ... }
final class Star implements Celestial { ... }
final class Comet implements Celestial { ... }
这是一个已知子类集的类层次结构的另一个经典示例:对数学表达式进行建模。
package com.example.expression;
public sealed interface Expr
permits ConstantExpr, PlusExpr, TimesExpr, NegExpr { ... }
public final class ConstantExpr implements Expr { ... }
public final class PlusExpr implements Expr { ... }
public final class TimesExpr implements Expr { ... }
public final class NegExpr implements Expr { ... }
密封类和记录类
密封类与记录类配合使用效果很好。记录类是隐式 final
的,因此记录类的密封层次结构比上面的示例稍微简洁一些:
package com.example.expression;
public sealed interface Expr
permits ConstantExpr, PlusExpr, TimesExpr, NegExpr { ... }
public record ConstantExpr(int i) implements Expr { ... }
public record PlusExpr(Expr a, Expr b) implements Expr { ... }
public record TimesExpr(Expr a, Expr b) implements Expr { ... }
public record NegExpr(Expr e) implements Expr { ... }
密封类和记录类的组合有时被称为 代数数据类型:记录类允许我们表达 乘积类型,而密封类允许我们表达 和类型。
密封类与转换
类型转换表达式将一个值转换为某种类型。instanceof
类型表达式则将一个值与某个类型进行测试。Java 在这些类型表达式中允许的类型非常宽松。例如:
interface I {}
class C {} // does not implement I
void test (C c) {
if (c instanceof I)
System.out.println("It's an I");
}
即使目前 C
对象无法实现接口 I
,该程序仍然是合法的。当然,随着程序的发展,可能会实现:
...
class B extends C implements I {}
test(new B());
// Prints "It's an I"
类型转换规则包含了一个开放可扩展性的概念。Java 类型系统并不假设一个封闭的世界。类和接口可以在未来的某个时间进行扩展,而强制类型转换会编译为运行时测试,因此我们可以安全地保持灵活性。
然而,在另一端的转换规则中,确实涉及到了一个类明确地不能被扩展的情况,即当该类是一个 final
类时。
interface I {}
final class C {}
void test (C c) {
if (c instanceof I) // Compile-time error!
System.out.println("It's an I");
}
方法 test
无法编译,因为编译器知道 C
不可能有子类,所以既然 C
没有实现 I
,那么 C
类型的值永远不可能实现 I
。这是一个编译时错误。
如果 C
不是 final
,而是 sealed
,该怎么办?它的直接子类是明确枚举出来的,并且根据 sealed
的定义,这些子类位于同一个模块中,因此我们期望编译器能够检查并发现类似的编译时错误。请看以下代码:
interface I {}
sealed class C permits D {}
final class D extends C {}
void test (C c) {
if (c instanceof I) // Compile-time error!
System.out.println("It's an I");
}
类 C
并未实现 I
,且不是 final
的,因此根据现有规则,我们可能会得出转换是可行的结论。然而,C
是 sealed
的,并且有一个允许的直接子类,即 D
。根据密封类型(sealed types)的定义,D
必须是 final
、sealed
或 non-sealed
中的一种。在本例中,C
的所有直接子类都是 final
的,并且没有实现 I
。因此,应该拒绝此程序,因为不可能存在实现 I
的 C
的子类型。
相比之下,考虑一个类似的程序,其中密封类的一个直接子类是 non-sealed
:
interface I {}
sealed class C permits D, E {}
non-sealed class D extends C {}
final class E extends C {}
void test (C c) {
if (c instanceof I)
System.out.println("It's an I");
}
这是类型正确的,因为 non-sealed
类型 D
的子类型有可能实现 I
。
因此,支持 sealed
类会导致 窄化引用转换 定义的更改,以便在编译时导航密封的类层次结构,从而确定哪些转换是不可能的。
JDK 中的密封类
密封类在 JDK 中的使用示例可以参考 java.lang.constant
包,该包对 JVM 实体的描述符 进行了建模:
package java.lang.constant;
public sealed interface ConstantDesc
permits String, Integer, Float, Long, Double,
ClassDesc, MethodTypeDesc, DynamicConstantDesc { ... }
// ClassDesc is designed for subclassing by JDK classes only
public sealed interface ClassDesc extends ConstantDesc
permits PrimitiveClassDescImpl, ReferenceClassDescImpl { ... }
final class PrimitiveClassDescImpl implements ClassDesc { ... }
final class ReferenceClassDescImpl implements ClassDesc { ... }
// MethodTypeDesc is designed for subclassing by JDK classes only
public sealed interface MethodTypeDesc extends ConstantDesc
permits MethodTypeDescImpl { ... }
final class MethodTypeDescImpl implements MethodTypeDesc { ... }
// DynamicConstantDesc is designed for subclassing by user code
public non-sealed abstract class DynamicConstantDesc implements ConstantDesc { ... }
密封类与模式匹配
密封类的一个重要好处将在 JEP 406 中实现,该提案建议通过模式匹配来扩展 switch
。用户代码将能够使用增强了模式的 switch
,而不是通过 if
-else
链来检查密封类的实例。使用密封类将允许 Java 编译器检查这些模式是否详尽无遗。
例如,考虑使用前面声明的 sealed
层次结构的代码:
Shape rotate(Shape shape, double angle) {
if (shape instanceof Circle) return shape;
else if (shape instanceof Rectangle) return shape;
else if (shape instanceof Square) return shape;
else throw new IncompatibleClassChangeError();
}
Java 编译器无法确保 instanceof
测试覆盖了 Shape
的所有允许的子类。最终的 else
子句实际上不可达,但编译器无法验证这一点。更重要的是,如果省略了 instanceof Rectangle
测试,编译器不会发出任何编译时错误消息。
相比之下,对于 switch
的模式匹配(JEP 406),编译器可以确认 Shape
的每个允许的子类都被覆盖了,因此不需要 default
子句或其他完整模式。此外,如果缺少三种情况中的任何一种,编译器都会发出错误消息:
Shape rotate(Shape shape, double angle) {
return switch (shape) { // pattern matching switch
case Circle c -> c;
case Rectangle r -> shape.rotate(angle);
case Square s -> shape.rotate(angle);
// no default needed!
}
}
Java 语法
类声明的语法修改为以下内容:
NormalClassDeclaration:
{ClassModifier} class TypeIdentifier [TypeParameters]
[Superclass] [Superinterfaces] [PermittedSubclasses] ClassBody
ClassModifier:
(one of)
Annotation public protected private
abstract static sealed final non-sealed strictfp
PermittedSubclasses:
permits ClassTypeList
ClassTypeList:
ClassType {, ClassType}
JVM 对 sealed 类的支持
Java 虚拟机在运行时识别 sealed
类和接口,并防止未经授权的子类和子接口进行扩展。
虽然 sealed
是一个类修饰符,但在 ClassFile
结构中并没有 ACC_SEALED
标志。相反,密封类的 class
文件具有一个 PermittedSubclasses
属性,该属性隐式地表示 sealed
修饰符,并显式指定允许的子类:
PermittedSubclasses_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 number_of_classes;
u2 classes[number_of_classes];
}
允许的子类列表是必需的。即使允许的子类是由编译器推断出来的,这些推断出的子类也会被明确包含在 PermittedSubclasses
属性中。
允许的子类的 class
文件不携带新的属性。
当 JVM 尝试定义一个其超类或超级接口具有 PermittedSubclasses
属性的类时,被定义的类必须由该属性命名。否则,将抛出 IncompatibleClassChangeError
。
反射 API
我们在 java.lang.Class
中添加了以下 public
方法:
Class<?>[] getPermittedSubclasses()
boolean isSealed()
方法 getPermittedSubclasses()
返回一个包含 java.lang.Class
对象的数组,这些对象表示该类的允许子类(如果该类是密封的)。如果该类不是密封的,则返回一个空数组。
方法 isSealed
在给定的类或接口是密封的情况下返回 true。(与 isEnum
进行比较。)
未来工作
一个常见的模式(尤其是在编写 API 时)是将公共类型定义为接口,并使用单一的私有类来实现它。通过密封类,可以更精确地表达这种模式,即一个密封的公共接口,仅允许一个私有的实现类。这样,该类型可以被广泛访问,但其具体实现则保持私有,且无法以任何方式被扩展。
public sealed interface Foo permits MyFooImpl { }
private final class MyFooImpl implements Foo { }
这种方法的一个笨拙之处在于,接受 Foo
对象的实现方法需要显式转换,例如:
void m(Foo f) {
MyFooImpl mfi = (MyFooImpl) f;
...
}
这里的类型转换似乎没有必要,因为我们知道它应该总是成功的。然而,在这个类型转换中存在一个隐式的语义假设,即类 MyFooImpl
是 Foo
的唯一实现。作者没有办法捕捉到这种直觉,以便在编译时进行检查。如果将来 Foo
允许额外的实现,这个类型转换仍将保持类型正确,但可能在运行时失败。换句话说,语义假设会被破坏,但编译器无法提醒开发者这一事实。
通过密封层次结构的精确性,值得为开发者提供表达此类语义假设的手段,并由编译器对其进行检查。这可以通过在赋值上下文中添加一种新的引用转换形式来实现,例如允许将密封的超类型转换为其唯一的子类型:
MyFooImpl mfi = f; // Allowed because the compiler sees that MyFooImpl
// is the only permitted subtype of Foo.
// (A synthetic cast would be added for safety.)
或者,我们可以提供一种新的转换形式,例如:
MyFooImpl mfi = (total MyFooImpl) f;
在这两种情况下,如果接口 Foo
被更改以允许另一个实现,那么两者在重新编译时都会导致编译时错误。
替代方案
一些语言对代数数据类型(ADTs)有直接的支持,例如 Haskell 的 data
特性。通过 enum
特性的一个变体,可以更直接地以 Java 开发者熟悉的方式表达 ADT,在单个声明中定义一个乘积的和。然而,这并不能支持所有期望的使用场景,例如那些在多个编译单元中的类上进行求和的场景,或者在非乘积类上进行求和的场景。
permits
子句允许像前面展示的 Shape
类这样的密封类被任何模块中的代码调用,但只能被与该密封类相同模块(或在未命名模块中的相同包)中的代码实现。这使得类型系统比访问控制系统更具表达力。仅使用访问控制时,如果 Shape
可以被任何模块中的代码调用(因为其包已被导出),那么 Shape
也可以在任何模块中被实现;而如果 Shape
在其他任何模块中都不能被实现,那么 Shape
在其他任何模块中也不能被调用。
依赖
密封类不依赖于任何其他的 JEP。如前所述,JEP 406 提议通过模式匹配扩展 switch
,并基于密封类来改进 switch
的完备性检查。