Skip to content
公众号 - 佳佳的博客

JEP 360: Sealed Classes (Preview) | 密封类(预览版)

摘要

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

目标

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

非目标

  • 不提供“友元”等新的访问控制形式。
  • 不以任何方式更改 final

动机

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

例如,在图形库中,Shape 类的作者可能打算只允许特定的类扩展 Shape,因为库的大部分工作都涉及以适当的方式处理每种形状。作者关心的是处理 Shape已知 子类的代码的清晰性,而不关心编写代码来防御 Shape未知 子类。允许任意类扩展 Shape,从而继承其代码以进行复用,在这种情况下并不是目标。不幸的是,Java 假设代码复用始终是一个目标:如果 Shape 可以被扩展,那么它就可以被任意数量的类扩展。放松这一假设将很有帮助,以便作者可以声明一个不向任意类开放的类层次结构。在这样的封闭类层次结构内部仍然可以实现代码复用,但超出这个范围则不可以。

Java 开发人员对限制子类集合的概念很熟悉,因为在 API 设计中经常会遇到这种情况。Java 语言在这方面提供的工具很有限:要么将类设为 final,这样它就没有子类;要么将类或其构造函数设为包私有的,这样它只能在同一个包中有子类。JDK 中就存在包私有超类的一个例子 位于这里

java
package java.lang;

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

当目标是代码复用时,包私有方法很有用,例如 StringBufferStringBuilder 子类共享 AbstractStringBuilderappend 代码。但是,当目标是模拟替代方案时,这种方法就毫无用处了,因为用户代码无法访问关键的抽象——即超类——以便在其上进行 switch 操作。如果不允许用户扩展超类,就不可能让用户访问它。(即使在声明了 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 {...}

当允许的子类在大小和数量上都比较小时,将它们与封闭类声明在同一个源文件中可能会很方便。当以这种方式声明时,sealed 类可以省略 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 {...}

封闭类的目的是让客户端代码能够清晰且确凿地推断出 所有 允许的子类。传统上,我们通过 if-else 链和 instanceof 测试来推断子类,但编译器分析这样的链很困难,因此无法确定测试覆盖了 所有 允许的子类。例如,以下方法会导致编译时错误,因为编译器不认同开发者的观点,即 Shape 的每个子类都被测试并导致了 return 语句:

java
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 个就不一定了。即使只有三个,编写这样的代码也很令人沮丧,读起来也很乏味。)

关于允许的子类的清晰且确凿的推理能力将在支持 模式匹配 的未来版本中实现。客户端代码将不再使用 if-else 来检查封闭类的实例,而是可以使用 类型测试模式JEP 375)来切换实例。这允许编译器检查模式是否 穷尽。例如,给定以下代码,编译器将推断出 Shape 的每个允许的子类都已被覆盖,因此不需要 default 子句(或其他完整模式);此外,如果缺少这三个情况中的任何一个,编译器将给出错误:

java
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 的,而 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 子句;请注意,Java 中不能编写这样的 permits 子句。)

抽象类。 一个 sealednon-sealed 的类可以是 abstract 的,并且具有 abstract 成员。一个 sealed 类可以允许其子类为 abstract(只要这些子类也是 sealednon-sealed 的,而不是 final)。

类的可访问性。 由于 extendspermits 子句使用了类名,因此一个允许的子类及其封闭的超类必须能够相互访问。但是,允许的子类之间不需要具有相同的可访问性,也不需要与封闭类具有相同的可访问性。特别是,一个子类可能比封闭类的可访问性更低;这意味着在将来支持开关中的模式匹配时,除非使用 default 子句(或其他完全模式),否则一些用户将无法完全地 switch 遍历子类。Java 编译器将被鼓励在检测到用户的 switch 不如用户想象的那么全面时,定制错误消息以推荐一个 default 子句。

封闭的接口

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

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 384)配合使用得很好,记录是 Java 15 的另一个预览特性。记录默认是 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 {...}

将封闭类和记录结合起来有时被称为 代数数据类型:记录允许我们表达 乘积类型,而封闭类允许我们表达 和类型

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

Java 语法

txt
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

以下 public 方法将被添加到 java.lang.Class 中:

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

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

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

备选方案

一些语言直接支持 代数数据类型(ADTs),如 Haskell 的 data 特性。通过 enum 特性的变体,可以在单个声明中定义产品的总和,以更直接且熟悉于 Java 开发人员的方式表达 ADTs。但是,这不会支持所有期望的用例,例如那些总和跨越多个编译单元中的类,或者总和跨越不是产品的类的情况。

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

依赖项

密封类不依赖于记录(JEP 384)或模式匹配(JEP 375),但它们与这两者配合使用效果良好。