Skip to content

JEP 397: Sealed Classes (Second Preview) | 密封类(第二个预览版)

摘要

通过 密封类和接口 来增强 Java 编程语言。密封类和接口限制了哪些其他类或接口可以扩展或实现它们。这是 JDK 16 中的一个 预览语言特性

历史

密封类由 JEP 360 提出,并在 JDK 15 中作为 预览特性 交付。

本 JEP 提议在 JDK 16 中重新预览此特性,并包含以下改进:

  • 引入 上下文关键字 的概念,取代 JLS 中先前的 受限标识符受限关键字 概念。将字符序列 sealednon-sealedpermits 作为上下文关键字引入。

  • 与匿名类和 lambda 表达式类似,在确定 sealed 类或 sealed 接口的隐式声明的允许子类时,局部变量类不能是密封类的子类。

  • 增强缩窄引用转换,以便在密封类型层次结构方面对类型转换进行更严格的检查。

目标

  • 允许类或接口的作者控制哪些代码负责实现它。

  • 提供一种比访问修饰符更声明性的方式来限制超类的使用。

  • 通过为模式的详尽分析提供基础,支持 模式匹配 的未来发展方向。

非目标

  • 不提供如“友元”这样的新型访问控制形式。

  • 不以任何方式改变 final

动机

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

然而,在某些情况下,这种表达能力可以被有效地限制。例如,Java 支持 枚举类 来模拟给定类只有固定数量实例的情况。在以下代码中,一个枚举类列出了一组固定的行星。它们是该类的唯一值,因此你可以对它们进行穷举切换——而无需编写 default 子句:

java
enum Planet { MERCURY, VENUS, EARTH }

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

使用枚举类来建模固定值集通常很有帮助,但有时我们想要建模固定种类的值集。这可以通过使用类层次结构来实现,不过不是作为代码继承和重用的机制,而是作为一种列出值种类的方式。以我们的行星示例为基础,我们可能会这样建模天文学领域中值的种类:

java
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 设计中经常出现。Java 语言在这方面提供的工具有限:要么将类声明为 final,这样它就没有子类;要么将类或其构造函数设置为包私有(package-private),这样它只能在同一个包内有子类。JDK 中有一个包私有超类的例子 出现在这里

java
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 修饰符来密封一个类。然后,在任何 extendsimplements 子句之后,permits 子句指定了被允许扩展密封类的类。例如,以下 Shape 的声明指定了三个允许的子类:

java
package com.example.geometry;

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

permits 指定的类必须位于超类附近:要么在相同的模块中(如果超类位于命名模块中),要么在相同的包中(如果超类位于未命名模块中)。例如,在以下 Shape 的声明中,其允许的子类都位于同一命名模块的不同包中:

java
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 编译器将从源文件中的声明中推断出允许的子类。(这些子类可能是辅助类或嵌套类。)例如,如果在 Shape.java 中发现以下代码,则密封类 Shape 被推断为具有三个允许的子类:

java
package com.example.geometry;

abstract sealed class Shape { ... }
... class Circle    extends Shape { ... }
... class Rectangle extends Shape { ... }
... class Square    extends Shape { ... }

密封一个类会限制其子类。用户代码可以使用 instanceof 测试的 if-else 链来检查密封类的实例,每个子类对应一个测试;不需要包含所有情况的 else 子句。例如,以下代码查找 Shape 的三个允许的子类:

java
Shape rotate(Shape shape, double angle) {
    if (shape instanceof Circle) return shape;
    else if (shape instanceof Rectangle) return shape.rotate(angle);
    else if (shape instanceof Square) return shape.rotate(angle);
    // 不需要 else!
}

密封类对其允许的子类施加了三个约束:

  1. 密封类及其允许的子类必须属于同一个模块,如果它们被声明在未命名模块中,则必须属于同一个包。

  2. 每个允许的子类都必须直接扩展密封类。

  3. 每个允许的子类都必须使用修饰符来描述它如何传播由其超类启动的密封性:

    • 允许的子类可以被声明为 final,以防止其类层次结构部分被进一步扩展。(记录类(JEP 395)被隐式声明为 final。)

    • 允许的子类可以被声明为 sealed,以允许其类层次结构部分以比其密封超类所设想的更受限制的方式进一步扩展。

    • 允许的子类可以被声明为 non-sealed,以便其类层次结构部分恢复到对未知子类开放扩展的状态。(密封类不能阻止其允许的子类执行此操作。)

作为第三个约束的示例,Circle 可以是 final 的,而 Rectanglesealed 的,Squarenon-sealed 的:

java
package com.example.geometry;

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

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 non-sealed class Square extends Shape { ... }

每个允许的子类必须使用 finalsealednon-sealed 修饰符中的恰好一个。一个类不能同时是 sealed(暗示有子类)和 final(暗示没有子类),也不能同时是 non-sealed(暗示有子类)和 final(暗示没有子类),或者同时是 sealed(暗示子类受限)和 non-sealed(暗示子类不受限)。

final 修饰符可以视为一种强形式的密封,其中完全禁止扩展 / 实现。也就是说,final 在概念上等同于 sealed 加上一个不指定任何内容的 permits 子句,尽管这样的 permits 子句无法编写。)

一个 sealednon-sealed 类可以是 abstract 的,并具有 abstract 成员。一个 sealed 类可以允许其子类是 abstract 的,只要这些子类随后被声明为 sealednon-sealed,而不是 final

类的可访问性

由于 extendspermits 子句使用类名,因此允许的子类及其密封的超类必须能够相互访问。然而,允许的子类之间不必具有相同的可访问性,也不必与密封类具有相同的可访问性。特别是,子类可能比密封类的可访问性更低。这意味着,在未来的版本中,当 switch 语句支持模式匹配时,除非使用 default 子句(或其他完整模式),否则某些代码将无法穷尽地遍历子类。Java 编译器将鼓励检测 switch 是否不如其原始作者想象的那样穷尽,并自定义错误消息以推荐 default 子句。

密封接口

与类类似,接口可以通过在接口上应用 sealed 修饰符来密封。在任何指定超接口的 extends 子句之后,使用 permits 子句指定实现类和子接口。例如,引言中的行星示例可以重写如下:

java
sealed interface Celestial
    permits Planet, Star, Comet { ... }

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

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

java
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 { ... }

密封类和记录类

密封类与记录类(JEP 395)配合使用效果良好。记录类是隐式 final 的,因此记录类的密封层次结构比上面的示例稍微简洁一些:

java
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 在这些类型的表达式中对于允许的类型极为宽松。例如:

java
interface I {}
class C {} // 不实现 I

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

尽管目前 C 对象不可能实现接口 I,但这个程序是合法的。当然,随着程序的演变,可能会变成这样:

java
...
class B extends C implements I {}

test(new B());
// 打印 "It's an I"

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

然而,在另一极端,类型转换规则也处理了类绝对不能被扩展的情况,即当它是一个 final 类时。

方法 test 无法编译通过,因为编译器知道 C 不可能有子类(因为它是 final 的),且 C 没有实现 I 接口,所以 C 类型的值永远不可能实现 I。这是一个编译时错误。

但如果 C 不是 final 的,而是 sealed 的呢?它的直接子类被明确列出,并且根据 sealed 的定义,这些子类必须在同一个模块中。因此,我们期望编译器能够检查是否存在类似的编译时错误。考虑以下代码:

java
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 的,根据现有规则,我们可能会认为类型转换是可能的。但 Csealed 的,且有一个被允许的直接子类 D。根据 sealed 类型的定义,D 必须是 finalsealednon-sealed 的。在这个例子中,C 的所有直接子类都是 final 的,并且没有实现 I 接口。因此,这个程序应该被拒绝,因为不可能存在 C 的子类实现 I 接口。

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

java
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

这个 JEP 将扩展 缩窄引用转换 的定义,以遍历密封层次结构,从而在编译时确定哪些转换是不可能的。

JDK 中的密封类

密封类在 JDK 中的一个应用示例是在 java.lang.constant 包中,该包用于模拟 JVM 实体的描述符

java
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 { ... }

密封类和模式匹配

密封类的一个显著优势将在未来的版本中与 模式匹配 相结合时得到体现。用户代码将能够使用带有 类型测试模式 的增强型 switch 语句,而不是使用 if-else 链来检查密封类的实例。这将允许 Java 编译器检查模式是否完整。

例如,考虑之前的这段代码:

java
Shape rotate(Shape shape, double angle) {
    if (shape instanceof Circle) return shape;
    else if (shape instanceof Rectangle) return shape.rotate(angle);
    else if (shape instanceof Square) return shape.rotate(angle);
    // 不需要 else!
}

Java 编译器无法确保 instanceof 测试涵盖了 Shape 的所有允许子类。例如,如果省略了 instanceof Rectangle 测试,则不会发出编译时错误消息。

相比之下,在以下使用模式匹配 switch 表达式的代码中,编译器可以确认 Shape 的每个允许子类都被覆盖,因此不需要 default 子句(或其他完全模式)。此外,如果缺少三个案例中的任何一个,编译器将发出错误消息:

java
Shape rotate(Shape shape, double angle) {
    return switch (shape) {   // 模式匹配 switch
        case Circle c    -> c;
        case Rectangle r -> r.rotate(angle);
        case Square s    -> s.rotate(angle);
        // 不需要 default!
    }
}

Java 语法

类声明的语法修改如下:

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 修饰符,并显式地指定了允许的子类:

java
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 方法:

  • java.lang.Class[] getPermittedSubclasses()
  • boolean isSealed()

getPermittedSubclasses() 方法如果类被密封,则返回一个包含 java.lang.Class 对象的数组,这些对象代表类的允许子类。如果类没有被密封,则返回一个空数组。

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

备选方案

一些语言直接支持 代数数据类型(ADT),如 Haskell 的 data 特性。通过 enum 特性的变体,可以更直接且以 Java 开发者熟悉的方式表达 ADT,其中可以在单个声明中定义乘积的和。然而,这不会支持所有期望的用例,例如那些和跨越多个编译单元的类或者不是乘积的类进行求和的用例。

permits 子句允许一个被密封的类(如之前展示的 Shape 类)被任何模块中的代码调用,但只允许在相同模块(或在未命名模块中的相同包)中的代码实现它。这使得类型系统比访问控制系统更具表现力。仅使用访问控制时,如果 Shape 类因其包被导出而可由任何模块中的代码调用,则 Shape 也可在任何模块中实现;而如果 Shape 类不可在任何其他模块中实现,则 Shape 也不可在任何其他模块中调用。

依赖项

密封类不依赖于记录类(JEP 395)或模式匹配(JEP 394),但它们与这两者都能很好地协作。