跳到主要内容

JEP 409:密封课程

概括

使用密封类和接口增强 Java 编程语言。密封类和接口限制其他类或接口可以扩展或实现它们。

历史

密封类由JEP 360提出,并作为预览功能在JDK 15中提供。它们由JEP 397再次提出并进行了改进,并作为预览功能在JDK 16中提供。此 JEP 建议在 JDK 17 中最终确定密封类,与 JDK 16 相比没有任何更改。

目标

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

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

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

非目标

  • 提供新形式的访问控制(例如“朋友”)并不是目标。

  • final以任何方式改变都不是目标。

动机

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

然而,在某些情况下,这种表达能力可以有效地被驯服。例如,Java 支持_枚举类_来模拟给定类仅具有固定数量实例的情况。在下面的代码中,枚举类列出了一组固定的行星。它们是该类的唯一值,因此您可以彻底切换它们,而无需编写子句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,因此它有零个子类,要么使该类或其构造函数为包私有,因此它只能在同一包中包含子类。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通过将修饰符应用于其声明来密封类。然后,在任何extendsandimplements子句之后,该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 编译器将从源文件中的声明推断出允许的子类。 (子类可以是辅助类或嵌套类。)例如,如果在中找到以下代码,Root.java则推断密封类Root具有三个允许的子类:

abstract sealed class Root { ... 
final class A extends Root { ... }
final class B extends Root { ... }
final class C extends Root { ... }
}

由 指定的类permits必须具有规范名称,否则会报告编译时错误。这意味着匿名类和本地类不能成为密封类的子类型。

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

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

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

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

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

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

    • 可以声明允许的子类,non-sealed以便其层次结构的一部分恢复为对未知子类的扩展开放。密封类无法阻止其允许的子类执行此操作。 (修饰符non-sealed是为 Java 建议的第一个连字符关键字。)

作为第三个约束的例子,CircleSquare可能是finalwhileRectanglesealed,我们添加一个新的子类 ,WeirdShapenon-sealed

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为 a Circle、 a Rectangle、 aSquare或 a 而编写的代码WeirdShape仍然是详尽的。

每个允许的子类必须使用修饰符finalsealed和之一。non-sealed一个类不可能既是sealed(隐含子类)又是final(隐含无子类),或者既是(non-sealed隐含子类)又是final(隐含无子类),又或者既是sealed(隐含受限制子类)又是non-sealed(隐含非限制子类)。

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

一个类,它是sealednon-sealed可能是abstract,并且有abstract成员。一个sealed类可以允许子类是abstract,只要它们是sealednon-sealed,而不是final

sealed如果任何类扩展了一个类但不允许这样做,则这是一个编译时错误。

班级可访问性

由于extendsandpermits子句使用类名,因此允许的子类及其密封超类必须可以相互访问。然而,允许的子类彼此之间或密封类不需要具有相同的可访问性。特别是,子类可能比密封类更难访问。这意味着,在未来版本中,当开关支持模式匹配时,某些代码将无法详尽地switch遍历子类,除非default使用子句(或其他总模式)。我们将鼓励 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 { ... }

密封和记录等级

密封课程与记录课程配合得很好。记录类是隐式的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) // Compile-time error!
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) // Compile-time error!
System.out.println("It's an I");
}

C不实现I,也不是final,因此根据现有规则,我们可以得出结论:转换是可能的。然而,Csealed,并且有一个允许的直接子类C,即D。根据密封类型的定义,D必须是finalsealednon-sealed。在此示例中, 的所有直接子类C都实现final且未实现I.因此,该程序应该被拒绝,因为不可能有C该实现的子类型I

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

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

JDK 中的密封类

JDK 中如何使用密封类的一个示例位于为 JVM 实体java.lang.constant建模描述符的包中:

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

密封类和模式匹配

密封类的一个显着好处将在JEP 406中实现,它建议switch通过模式匹配进行扩展。用户代码将能够使用增强的 with 模式,而不是使用if-链检查密封类的实例。使用密封类将允许 Java 编译器检查模式是否详尽。else``switch

例如,考虑使用sealed前面声明的层次结构的代码:

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();
}

Java 编译器无法确保测试instanceof涵盖Shape.最后一个else子句实际上是无法到达的,但这无法被编译器验证。更重要的是,如果instanceof Rectangle省略测试,则不会发出编译时错误消息。

相反,通过switch( JEP 406 ) 的模式匹配,编译器可以确认Shape覆盖了每个允许的子类,因此default不需要子句或其他总模式。此外,如果缺少这三种情况中的任何一种,编译器将发出错误消息:

Shape rotate(Shape shape, double angle) {
return switch (shape) { // pattern matching switch
case Circle c -> c;
case Rectangle r -> shape.rotate(angle);
case Square s -> shape.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没有标志。相反,密封类的文件有一个属性,隐式指示修饰符并显式指定允许的子类:ACC_SEALED``ClassFile``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

  • Class<?>[] getPermittedSubclasses()
  • boolean isSealed()

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

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

未来的工作

一种常见的模式(尤其是在编写 API 时)是将公共类型定义为接口并使用单个私有类来实现它。使用密封类,可以更精确地表达为具有单个允许的私有实现的密封公共接口。因此,该类型可以广泛访问,但实现却不能,并且不能以任何方式扩展。

public sealed interface Foo permits MyFooImpl { } 
private final class MyFooImpl implements Foo { }

这种方法的一个笨拙之处在于接受Foo对象的实现方法需要显式强制转换,例如:

void m(Foo f) { 
MyFooImpl mfi = (MyFooImpl) f;
...
}

这里的演员阵容似乎没有必要,因为我们知道它总是会成功。然而,在转换中有一个隐含的语义假设,即该类MyFooImplFoo.作者没有办法捕捉这种直觉,以便在编译时进行检查。如果及时Foo允许额外的实现,此转换将保持类型正确,但可能在运行时失败。换句话说,语义假设将被打破,但编译器无法提醒开发人员该事实。

由于密封层次结构的精确性,可能值得为开发人员提供表达此类语义假设的方法,并让编译器检查它们。这可以通过为赋值上下文添加一种新形式的引用转换来实现,该转换允许将密封超类型转换为其唯一的子类型,例如:

MyFooImpl mfi = f; // Allowed because the compiler sees that MyFooImpl
// is the only permitted subtype of Foo.
// (A synthetic cast would be added for safety.)

或者,我们可以提供一种新的强制转换形式,例如:

MyFooImpl mfi = (total MyFooImpl) f;

Foo在这两种情况下,如果更改接口以允许另一种实现,那么两者都会在重新编译时导致编译时错误。

备择方案

有些语言直接支持代数数据类型(ADT),例如 Haskell 的data功能。通过该功能的变体,可以更直接地以 Java 开发人员熟悉的方式表达 ADT enum,其中可以在单个声明中定义乘积之和。然而,这并不能支持所有所需的用例,例如求和范围涵盖多个编译单元中的类的用例,或者求和范围涵盖非产品的类的用例。

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

依赖关系

密封类不依赖于任何其他 JEP。如前所述,JEP 406建议switch通过模式匹配进行扩展,并构建在密封类的基础上来改进switch.