跳到主要内容

JEP 397:密封类(第二预览版)

QWen Max 中英对照 JEP 397: Sealed Classes (Second Preview)

总结

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

历史

密封类由 JEP 360 提出,并在 JDK 15 中作为 预览功能 提供。

本 JEP 提议在 JDK 16 中重新预览该特性,并进行了以下改进:

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

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

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

目标

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

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

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

非目标

  • 目标并非提供诸如“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 中有一个包级私有的超类示例 出现在这里

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 的声明指定了三个允许的子类:

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

package com.example.geometry;

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

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

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);
// no else needed!
}

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

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

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

  3. 每个允许的子类都必须使用一个修饰符来描述它如何传递由其超类发起的密封:

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

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

    • 允许的子类可以声明为 non-sealed,以便其部分层次结构恢复为对未知子类开放扩展。(密封类不能阻止其允许的子类这样做。)

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

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 子句(或其他完整模式),否则某些代码将无法详尽地对子类进行 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 { ... }

密封类和记录类

密封类与记录类 (JEP 395) 配合得很好。记录类是隐式 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)
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 的,因此根据现有规则,我们可能会得出转换是可能的。然而,Csealed 的,并且有一个允许的直接子类,即 D。根据密封类型(sealed types)的定义,D 必须是 finalsealednon-sealed 的。在此示例中,C 的所有直接子类都是 final 的,并且没有实现 I。因此,应该拒绝此程序,因为不可能存在实现 IC 的子类型。

相比之下,考虑一个类似的程序,其中密封类的一个直接子类是 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

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

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

密封类与模式匹配

密封类的一个重要好处将在未来的版本中与模式匹配 结合时实现。用户代码将能够使用增强了类型测试模式switch,而不是通过 if-else 链来检查密封类的实例。这将允许 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);
// no else needed!
}

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

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

Shape rotate(Shape shape, double angle) {
return switch (shape) { // pattern matching switch
case Circle c -> c;
case Rectangle r -> r.rotate(angle);
case Square s -> s.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 对密封类的支持

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

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

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

方法 isSealed 在给定的类或接口是密封的情况下返回 true。(与 isEnum 比较。)

替代方案

一些语言直接支持*代数数据类型*(ADTs),例如 Haskell 的 data 特性。通过 enum 特性的一种变体,可以更直接地以 Java 开发者熟悉的方式表达 ADTs,其中可以在单个声明中定义一个积的和。然而,这种方式并不能支持所有期望的使用场景,例如那些和跨越多个编译单元的类相关的和,或者跨越非积类的和。

permits 子句允许像前面展示的 Shape 类这样的密封类在任何模块中的代码都可以调用,但仅允许与密封类位于同一模块(或在未命名模块中的同一包)中的代码进行实现。这使得类型系统比访问控制系统更具表达力。单独使用访问控制时,如果 Shape 在任何模块中的代码都可调用(因为其包已导出),那么 Shape 在任何模块中也可用于实现;而如果 Shape 在其他任何模块中都不能用于实现,那么 Shape 在其他任何模块中也不能被调用。

依赖

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