增强 Java 编程语言,引入密封类(sealed classes)和密封接口(sealed interfaces)。密封类和接口限制了哪些其他类或接口可以扩展或实现它们。从 Java 15 开始作为预览功能引入,经过改进后,在 Java 16 时作为二次预览版交付,并在 Java 17 中正式成为标准特性(与 16 时相比没有变化)。

密封类和密封接口允许类或接口的作者控制哪些代码负责实现它,提供一种比访问修饰符更声明式的方式来限制超类的使用,为模式匹配的未来发展提供基础,支持模式的详尽分析。

一、背景

面向对象的数据模型,类和接口的继承层次结构,已经被证明在建模现代应用程序处理的真实世界数据方面非常有效。这种表达能力是 Java 语言的一个重要方面。

然而,在某些情况下,这种表达能力会被通过某种方式有效地受到限制。例如,Java 支持使用枚举类来建模一个给定类只有固定数量实例的情况。在下面的代码中,一个枚举类列出了固定的一组行星。它们是该类唯一的值,因此可以在它们之间进行详尽的切换——不需要编写默认子句:

1
2
3
4
5
6
7
8
9
10
enum Planet {
MERCURY, VENUS, EARTH
}

Planet p = ...;
switch (p) {
case MERCURY: ...
case VENUS: ...
case EARTH: ...
}

使用枚举类来建模固定值集通常是有帮助的,但有时希望建模固定种类的值集合。这可以通过不将类层次结构用作代码继承和重用的机制,而是作为一种列出值种类的方式。基于行星例子,可以这样建模天文学领域中的值种类:

1
2
3
4
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,使其没有任何子类;要么使类或其构造函数包私有,使其只能在同一包中拥有子类。JDK 中出现了一个包私有的超类示例:

1
2
3
4
5
package java.lang;

abstract class AbstractStringBuilder { ... }
public final class StringBuffer extends AbstractStringBuilder { ... }
public final class StringBuilder extends AbstractStringBuilder { ... }

当目标是代码重用时,比如让 AbstractStringBuilder 的子类共享其 append 方法的代码,包私有方法是有用的。然而,当目标是建模替代方案时,这种方法毫无用处,因为用户代码无法访问关键抽象——超类——以对其进行切换。允许用户访问超类而不允许扩展它,除非借助非公共构造函数等脆弱的技巧,否则无法指定这一点——而这对于接口不起作用。在一个声明了 Shape 及其子类的图形库中,如果只有一个包可以访问 Shape 将是不幸的。

PS:JEP 里举例竟然就是 AbstractStringBuilder,我第一次看到这个 sealed 这个关键字就是你在 StringBuilder 这里。看的源码为 JDK 21,现在已经改为了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
abstract sealed class AbstractStringBuilder 
implements Appendable, CharSequence
permits StringBuilder, StringBuffer {
}

public final class StringBuilder
extends AbstractStringBuilder
implements Appendable, java.io.Serializable, Comparable<StringBuilder>, CharSequence
{
}

public final class StringBuffer
extends AbstractStringBuilder
implements Appendable, Serializable, Comparable<StringBuffer>, CharSequence
{
}

总之,应该允许超类广泛可访问(因为它代表了对用户的重要的抽象),但不允许广泛可扩展(因为它的子类应限制为作者所知的那些)。这类超类的作者应该能够表达它是与一组特定的子类共同开发的,既为了文档化意图供读者参考,也为了允许 Java 编译器执行这些限制。同时,超类不应该不必要地约束其子类,例如,强迫它们成为 final 或阻止它们定义自己的状态。

二、详情描述

密封类或接口只能由那些被允许扩展或实现它们的类和接口来继承或实现。

通过在类声明中应用 sealed 修饰符,可以将一个类定义为密封类。然后,在任何 extendsimplements 子句之后,使用 permits 子句指定哪些类被允许扩展密封类。例如,下面对 Shape 的声明指定了三个允许的子类:

1
2
3
4
package com.example.geometry;

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

permits 指定的类必须位于超类附近:如果超类位于命名模块(module)中,则这些类必须在同一模块(module)内;如果超类位于未命名模块中,则这些类必须在同一包内。

例如,在以下 Shape 的声明中,它允许子类都位于同一命名模块的不同包中:

1
2
3
4
5
6
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 被推断为有三个允许的子类:

1
2
3
4
5
abstract sealed class Root { ... 
final class A extends Root { ... }
final class B extends Root { ... }
final class C extends Root { ... }
}

permits 指定的类必须具有规范名称,否则会导致编译时错误。这意味着匿名类和局部类不能成为密封类的允许子类型

密封类对其允许的子类施加了三个限制:

  • 模块和包的归属:密封类及其允许的子类必须属于同一个模块,并且如果是在未命名模块中声明的,则必须属于同一个包。
  • 直接继承:每个允许的子类必须直接继承密封类。
  • 传播密封状态的方式:每个允许的子类必须使用修饰符来描述它如何传播其超类发起的密封:
    • 允许的子类可以被声明为 final 以防止其部分的类层次结构进一步扩展。(Record 类隐式地声明为 final。)
    • 允许的子类可以被声明为 sealed 以允许其部分的层次结构在受限的情况下进一步扩展,超出其密封超类的预期。
    • 允许的子类可以被声明为 non-sealed 以使其部分的层次结构重新开放给未知子类的扩展。密封类无法阻止其允许的子类这样做。(non-sealed 是提议用于 Java 的第一个连字符关键字。)

作为第三个约束的例子,CircleSquare 可能是 final 的,而 Rectangle 是密封的,并且添加了一个新的子类 WeirdShape,它是非密封的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 实例是否为 CircleRectangleSquareWeirdShape 的代码仍然是详尽无遗的。

每个允许的子类必须精确使用 finalsealednon-sealed 之一作为修饰符。一个类不可能同时是 sealed 的(意味着有子类)和 final 的(意味着没有子类),或者同时是 non-sealed 的(意味着有子类)和 final 的(意味着没有子类),或者同时是 sealed 的(意味着受限制的子类)和 non-sealed 的(意味着不受限制的子类)。

final 修饰符可以被视为密封的一种特殊情况,其中完全禁止扩展/实现。也就是说,final 在概念上等同于 sealed 加上一个不指定任何内容的 permits 子句,尽管这样的 permits 子句不能被写出。

密封或非密封的类可以是抽象的,并且可以有抽象成员。密封类可以允许抽象的子类,前提是这些子类随后被声明为密封或非密封,而不是最终的。

如果任何类试图扩展密封类但未被允许,这将导致编译时错误。

1、类的可访问性

由于 extendspermits 子句使用了类名,允许的子类和其密封超类之间必须是相互可访问的。然而,允许的子类之间不必具有相同的可访问性,也不必与密封类相同。特别是,一个子类可以比密封类更不公开。这意味着,在未来支持模式匹配的版本中,当使用 switch 语句时,如果没有默认子句(或其他全量模式),某些代码将无法详尽地遍历所有子类。Java 编译器将被鼓励检测到 switch 语句不如原作者预期的那样详尽,并定制错误消息建议添加默认子句。

2、密封接口

如同类一样,接口也可以通过应用 sealed 修饰符来密封。在指定超接口的 extends 子句之后,使用 permits 子句指定实现类和子接口。例如,上面提到的行星例子可以重写为:

1
2
3
4
5
6
sealed interface Celestial 
permits Planet, Star, Comet { ... }

final class Planet implements Celestial { ... }
final class Star implements Celestial { ... }
final class Comet implements Celestial { ... }

这里有一个经典的类层次结构示例,其中有一组已知的子类:建模数学表达式。

1
2
3
4
5
6
7
8
9
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 { ... }

3、密封和 Record

密封类与 Record 类配合得很好。Record 类隐式地声明为 final,因此由记录类组成的密封层次结构比上述例子稍微简洁一些:

1
2
3
4
5
6
7
8
9
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 { ... }

密封类和记录类的组合有时被称为代数数据类型(algebraic data types):Record 类允许表达产品类型(product types),而密封类允许表达和类型(sum types)。

4、密封和类型转换

类型转换表达式将值转换为类型,而 instanceof 表达式则测试值是否属于某种类型。Java 对这些表达式中允许使用的类型非常宽松。例如:

1
2
3
4
5
6
7
8
interface I {}
class C {} // 不实现 I

void test(C c) {
if (c instanceof I) {
System.out.println("It's an I");
}
}

这段程序是合法的,即使目前 C 对象不可能实现接口 I。当然,随着程序的发展,这可能会发生:

1
2
3
4
class B extends C implements I {}

test(new B());
// 输出 "It's an I"

类型转换规则捕捉到了开放扩展性的概念。Java 类型系统并不假设封闭世界;类和接口可以在将来某个时间点被扩展,类型转换编译为运行时测试,因此可以安全地保持灵活性。

然而,转换规则确实处理了类绝对不能被扩展的情况,即当它是最终类 (final class) 的时候。

1
2
3
4
5
6
7
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 不是最终类,而是密封类呢?它的直接子类是显式枚举的,并且根据密封类型的定义,它们位于同一模块中,因此期望编译器检查是否有类似的编译时错误。考虑以下代码:

1
2
3
4
5
6
7
8
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,并且不是最终类,按照现有规则,可能得出结论认为转换是可能的。然而,C 是密封的,并且它只有一个允许的直接子类 D。根据密封类型的定义,D 必须是最终的、密封的或非密封的。在这个例子中,C 的所有直接子类都是最终的,并且不实现 I。因此,这个程序应该被拒绝,因为不可能存在实现 IC 的子类型。

相比之下,考虑一个类似的程序,其中一个密封类的直接子类是非密封的:

1
2
3
4
5
6
7
8
9
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");
}

这是类型正确的,因为 D 的非密封子类型有可能实现 I

因此,支持密封类会导致缩小引用转换的定义发生变化,以遍历密封层次结构并在编译时确定哪些转换是不可能的。

5、JDK 中的密封类

密封类在 JDK 中的一个例子是在 java.lang.constant 包中,该包建模了 JVM 实体的描述符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 { ... }

6、密封类与模式匹配

密封类带来的一个重大好处将在 JEP 406 中实现,该提案提议扩展 switch 语句以支持模式匹配。用户代码将能够使用增强模式的 switch 来代替对密封类实例的 if-else 链进行检查。使用密封类将允许 Java 编译器检查模式是否详尽。

例如,考虑以下使用前面声明的密封层次结构的代码:

1
2
3
4
5
6
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();
}

编译器无法确保 instanceof 测试覆盖了所有允许的子类。最后的 else 子句实际上是不可达的,但编译器无法验证这一点。更重要的是,如果省略了 instanceof Rectangle 测试,则不会发出编译时错误消息。

相比之下,使用带有模式匹配的 switch(JEP 406),编译器可以确认每个允许的子类都被覆盖,因此不需要默认子句或其他模式。此外,如果缺少三个案例中的任何一个,编译器会发出错误消息:

1
2
3
4
5
6
7
8
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);
// 不需要默认子句!
}
}

7、Java 语法

类声明的语法已更改为如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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}

8、JVM 对密封类的支持

Java 虚拟机在运行时识别密封类和接口,并防止未经授权的子类和子接口扩展它们。

虽然 sealed 是一个类修饰符,但在 ClassFile 结构中没有 ACC_SEALED 标志。相反,密封类的类文件有一个 PermittedSubclasses 属性,它隐式地指示了 sealed 修饰符并显式地指定了允许的子类:

1
2
3
4
5
6
PermittedSubclasses_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 number_of_classes;
u2 classes[number_of_classes];
}

允许的子类列表是强制性的。即使允许的子类是由编译器推断出来的,这些推断出的子类也会明确包含在 PermittedSubclasses 属性中。

被定义的类的类文件不携带任何新的属性。

当 JVM 尝试定义一个其超类或超接口具有 PermittedSubclasses 属性的类时,正在定义的类必须由属性命名。否则,将抛出 IncompatibleClassChangeError

9、反射 API

java.lang.Class 中添加了以下公共方法:

1
2
3
Class<?>[] getPermittedSubclasses();

boolean isSealed();

方法 getPermittedSubclasses() 返回一个包含表示类的允许子类的 java.lang.Class 对象数组,如果类是密封的。如果类不是密封的,则返回空数组。

方法 isSealed 如果给定的类或接口是密封的则返回 true。(与 isEnum 类比。)

三、依赖关系

密封类不依赖于任何其他的 JEP。如前所述,JEP 406 提议扩展 switch 以支持模式匹配,并基于密封类来改进 switch 的详尽性检查。

相关链接

JEP 409: Sealed Classes

JEP 360: Sealed Classes (Preview)

JEP 397: Sealed Classes (Second Preview)

[[JEP 395 Records]]

OB tags

#Java #JDK新特性