跳到主要内容

JEP 360:密封类(预览版)

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

总结

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

目标

  • 允许类或接口的作者控制哪些代码负责实现它。
  • 提供一种比访问修饰符更具声明性的方式来限制超类的使用。
  • 通过支撑模式的穷尽性分析,支持 模式匹配 的未来方向。

非目标

  • 提供诸如“friends”之类的新访问控制形式并不是目标。
  • 以任何方式改变 final 并不是目标。

动机

在 Java 中,类层次结构通过继承实现了代码的重用:超类的方法可以被许多子类继承(从而实现重用)。然而,类层次结构的目的并不总是为了重用代码。有时,它的目的是对某一领域中存在的各种可能性进行建模,例如图形库支持的形状种类或金融应用程序支持的贷款种类。当以这种方式使用类层次结构时,限制子类的集合可以简化建模过程。

例如,在一个图形库中,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 操作。如果不允许用户扩展超类,就无法允许他们访问超类。(即使在声明了 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 {...}

密封类的目的在于让客户端代码能够清晰且确定地推断出所有允许的子类。传统上,推断子类的方式是通过 instanceof 测试组成的 if-else 链,但这样的链式结构对编译器来说分析起来较为困难,因此它无法确定这些测试是否覆盖了所有允许的子类。例如,以下方法会导致编译时错误,因为编译器并不能像开发者那样确信每个 Shape 的子类都经过了测试,并且都导向了一个 return 语句:

int getCenter(Shape shape) {
if (shape instanceof Circle) {
return ... ((Circle)shape).center() ...
} else if (shape instanceof Rectangle) {
return ... ((Rectangle)shape).length() ...
} else if (shape instanceof Square) {
return ... ((Square)shape).side() ...
}
}

追加一个万能的 else 子句将违背开发者认为测试已经穷尽的信念。此外,如果开发者的信念最终被证明是错误的,编译器也无法挽救开发者。假设上面的代码被意外编辑,遗漏了 instanceof Rectangle 测试;不会发生编译时错误。(如果有三个允许的子类,遗漏可能很容易发现,但有 10 个或 20 个时就不容易了。即使只有三个,代码写起来也令人沮丧,读起来也乏味。)

在未来的版本中,将实现对允许的子类进行清晰且结论性推理的能力,该版本将支持模式匹配。客户端代码将能够使用 类型测试模式 (JEP 375) 对密封类的实例进行切换,而不是使用 if-else 来检查密封类的实例。这允许编译器检查这些模式是否 详尽无遗。例如,给定以下代码,编译器将推断出 Shape 的每个允许的子类都被覆盖了,因此不需要 default 子句(或其他总模式);此外,如果缺少三种情况中的任何一种,编译器都会报错:

int getCenter(Shape shape) {
return switch (shape) {
case Circle c -> ... c.center() ...
case Rectangle r -> ... r.length() ...
case Square s -> ... s.side() ...
};
}

密封类对其允许的子类(即其 permits 子句指定的类)施加了三个限制:

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

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

  3. 每个允许的子类必须选择一个修饰符来描述它如何继续其超类发起的密封:

  • 允许的子类可以声明为 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 子句在 Java 中无法写出。)

抽象类。 一个被声明为 sealednon-sealed 的类可以是 abstract 的,并且可以包含 abstract 成员。一个 sealed 类可以允许其子类为 abstract(前提是这些子类本身被声明为 sealednon-sealed,而不是 final)。

类的可访问性。 由于 extendspermits 子句使用了类名,允许的子类和其密封的超类必须相互可访问。然而,允许的子类不需要具有彼此相同的可访问性,也不需要与密封类相同。特别是,子类的可访问性可以比密封类更低;这意味着,在未来支持模式匹配的发布中,除非使用了 default 子句(或其他全模式),否则一些用户将无法详尽地对子类进行 switch 操作。Java 编译器会被鼓励去检测用户的 switch 是否没有像用户想象的那样详尽,并定制错误消息来推荐使用 default 子句。

密封接口

与类的情况类似,接口是通过将 sealed 修饰符应用于接口来密封的。在任何用于指定超接口的 extends 子句之后,使用 permits 子句来指定实现类和子接口。例如:

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 384) 配合得很好,这是 Java 15 的另一个预览功能。记录是隐式 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 {...}

密封类和记录的组合有时被称为 代数数据类型:记录允许我们表达 乘积类型,而密封类允许我们表达 和类型

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

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

下面这些 public 方法将被添加到 java.lang.Class 中:

  • java.lang.constant.ClassDesc[] getPermittedSubclasses()
  • boolean isSealed()

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

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

替代方案

一些语言对*代数数据类型*(ADTs)有直接的支持,例如 Haskell 的 data 特性。通过 enum 特性的一种变体,可以更直接地以 Java 开发者熟悉的方式表达 ADT,在单个声明中定义一个乘积的和(sum of products)。然而,这种方式并不能支持所有的期望用例,例如那些在多个编译单元中的类上进行求和的情况,或者在非乘积类上进行求和的情况。

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

依赖

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