JEP 409: Sealed Classes | 密封类
摘要
通过 密封类和接口 增强 Java 编程语言。密封类和接口限制哪些其他类或接口可以扩展或实现它们。
历史
密封类由 JEP 360 提出,并在 JDK 15 中作为 预览功能 发布。随后,JEP 397 对其进行了改进并再次提出,并在 JDK 16 中作为预览功能发布。本 JEP 提议在 JDK 17 中最终确定密封类,与 JDK 16 相比不做任何更改。
目标
允许类或接口的作者控制哪些代码负责实现它。
提供一种比访问修饰符更声明性的方式来限制超类的使用。
通过为模式的详尽分析提供基础,支持 模式匹配 的未来发展方向。
非目标
不提供如“友元”这样的新访问控制形式。
不以任何方式改变
final
。
动机
类和接口的继承层次结构的面向对象数据模型已被证明在建模现代应用程序处理的现实世界数据方面非常有效。这种表达能力是 Java 语言的一个重要方面。
然而,在某些情况下,这种表达能力需要得到合理的控制。例如,Java 支持 枚举类 来模拟给定类只有固定数量实例的情况。在以下代码中,一个枚举类列出了一组固定的行星。它们是该类的唯一值,因此您可以对它们进行穷举切换——而无需编写 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
并因此继承其代码以进行重用,并不是此情况下的目标。不幸的是,Java 假定代码重用始终是一个目标:如果 Shape
可以扩展,那么它可以被任意数量的类扩展。如果能够放宽这一假设,使作者能够声明一个不对任意类开放的类层次结构,那将非常有帮助。在这样一个封闭的类层次结构内部,代码重用仍然是可能的,但仅限于该结构内部。
Java 开发者对限制子类集的想法并不陌生,因为这经常出现在 API 设计中。Java 语言在这一领域提供的工具有限:要么将类声明为 final
,这样它就没有子类;要么将类或其构造函数声明为包私有(package-private),这样它只能在同一个包内拥有子类。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)的类或接口只能被那些被允许这样做的类或接口扩展或实现。
通过在类的声明中应用 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 { ... }
当允许的子类数量少且规模小时,将它们与密封类声明在同一源文件中可能很方便。以这种方式声明时,密封类可以省略 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 {} // 不实现 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());
// 打印 "It's an I"
类型转换规则体现了一种 开放可扩展性 的概念。Java 类型系统不假设一个封闭的世界。类和接口可以在未来的某个时间点进行扩展,并且类型转换编译为运行时测试,因此我们可以安全地保持灵活性。
然而,在另一极端,转换规则确实处理了类绝对无法被扩展的情况,即当它是一个 final
类时。
interface I {}
final class C {}
void test(C c) {
if (c instanceof I) // 编译时错误!
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) // 编译时错误!
System.out.println("It's an I");
}
类 C
没有实现接口 I
,并且也不是 final
的,因此根据现有规则,我们可能会认为类型转换是可能的。然而,C
是 sealed
的,并且 C
有一个被允许的直接子类,即 D
。根据密封类型的定义,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 仅设计为 JDK 类的子类
public sealed interface ClassDesc extends ConstantDesc
permits PrimitiveClassDescImpl, ReferenceClassDescImpl { ... }
final class PrimitiveClassDescImpl implements ClassDesc { ... }
final class ReferenceClassDescImpl implements ClassDesc { ... }
// MethodTypeDesc 仅设计为 JDK 类的子类
public sealed interface MethodTypeDesc extends ConstantDesc
permits MethodTypeDescImpl { ... }
final class MethodTypeDescImpl implements MethodTypeDesc { ... }
// DynamicConstantDesc 设计为允许用户代码进行子类化
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) { // 模式匹配 switch
case Circle c -> c;
case Rectangle r -> shape.rotate(angle);
case Square s -> shape.rotate(angle);
// 不需要 default!
}
}
Java 语法
类声明的语法修改为如下:
NormalClassDeclaration:
{ClassModifier} class TypeIdentifier [TypeParameters]
[Superclass] [Superinterfaces] [PermittedSubclasses] ClassBody
ClassModifier:
(以下之一)
Annotation public protected private
abstract static sealed final non-sealed strictfp
PermittedSubclasses:
permits ClassTypeList
ClassTypeList:
ClassType {, ClassType}
JVM 对密封类的支持
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; // 允许,因为编译器看到 MyFooImpl
// 是 Foo 的唯一允许的子类型。
// (为了安全起见,会添加一个合成转换。)
或者,我们可以提供一种新形式的转换,例如:
MyFooImpl mfi = (total MyFooImpl) f;
在这两种情况下,如果接口 Foo
被更改为允许另一个实现,则在重新编译时两者都会导致编译时错误。
替代方案
有些语言直接支持 代数数据类型(ADT),如 Haskell 的 data
特性。通过 enum
特性的变体,可以更直接地并以 Java 开发者熟悉的方式表达 ADT,其中可以在单个声明中定义多个产品的和。然而,这并不支持所有期望的用例,比如那些跨多个编译单元类的和,或者那些跨非产品类的和。
permits
子句允许一个密封类(如前面所示的 Shape
类)被任何模块中的代码调用,但只能被与密封类位于同一模块(或在未命名模块中位于同一包)中的代码实现。这使得类型系统比访问控制系统更具表现力。仅通过访问控制,如果 Shape
可以被任何模块中的代码调用(因为其包被导出),则 Shape
也可以在任何模块中被实现;如果 Shape
不能在任何其他模块中被实现,则 Shape
也不能在任何其他模块中被调用。
依赖项
密封类不依赖于任何其他 JEP。如前所述,JEP 406 提议通过模式匹配扩展 switch
,并建立在密封类的基础上以改进 switch
的详尽性检查。