跳转到内容

JEP 409:密封类

原文:https://openjdk.org/jeps/409
翻译:张欢

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

历史

密封类由JEP 360提出并在JDK 15中作为预览特性。它们在JEP 397中再次提出,并带有优化,在JDK 16中作为预览特性交付。本JEP提出在JDK 17中完成密封类,并与JDK 16相比无变化。

目标

  • 允许类或接口的作者可以控制哪些代码有责任实现它。
  • 提供比访问修饰符更具声明性的方式来限制父类的使用。
  • 通过为模式的详尽分析提供一个标准,以支持模式匹配的未来方向。

非目标

  • 提供诸如“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,使其具有零个子类,要么将类或其构造器设为包级私有,因此它只能在同一个包中包含子类。包级私有超类的示例出现在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 {...}

密封一个类会限制它的子类。用户代码可以使用if-else链检查密封类的实例,每个子类一个测试;不需要捕获所有的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);
// 不需要else!
}

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

  1. 密封类及其允许的子类必须属于同一个模块,并且,如果在未命名的模块中声明,则属于同一个包。
  2. 每个允许的子类必须直接扩展密封类。
  3. 每个允许的子类必须使用修饰符来描述它如何传播由其父类发起的密封:
    • 允许的子类可以声明为final,以防止其在类层次结构中的部分被进一步向下继承。(记录类(JEP 395)是隐式final的。)
    • 允许的子类可以声明为sealed,以允许其层次结构的一部分比其密封的父类所设想的扩展得更远,但以受限制的方式。
    • 一个允许的子类可以被声明为non-sealed,这样它的层次结构部分就会恢复到对未知子类的扩展开放。(密封类不能阻止其允许的子类这样做。)

作为第三条约束的例子,Circle可以为final的,Rectanglesealed的,而Squarenon-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的子类,前提是它们是sealed的或non-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 {...}

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

密封类与转换

强制转换表达式将值转换为类型。类型instanceof表达式根据类型测试值。Java对此类表达式中允许的类型极为宽容。例如:

interface I {}
class C {} // 没有实现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());
// 打印 "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的怎么办?它的直接子类被显式枚举,并且——根据被密封的定义——在同一个模块中,所以我们希望编译器查看它是否可以发现类似的编译时错误。考虑以下代码:

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的,并且C有一个允许的直接子类,即D。根据密封类型的定义,D必须是final的、sealed的或non-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

本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设计用于仅通过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编译器检查模式是否详尽。

例如,考虑此前面的代码:

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子句(或其他总模式)。此外,如果缺少以下三种情况中的任何一种,编译器将发出错误消息:

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语法

类声明的语法修改如下:

普通类声明:
{类修饰符} class 类型标识符 [类型参数]
[父类] [父接口] [允许的子类] 类体
类修饰符:
(其中之一)
Annotation public protected private
abstract static sealed final non-sealed strictfp
允许的子类:
permits 类类型列表
类类型列表:
类类型 {, 类类型}

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

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

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

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

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

备选方案

某些语言直接支持代数数据类型(ADT),例如Haskell的data特性。通过enum特性的变体,可以更直接地以Java开发人员熟悉的方式表达ADT,其中可以在单个声明中定义sum和product。但是,这不会支持所有所需的用例,例如sum范围跨越多个编译单元中的类,或者sum范围跨越非product类的那些用例。

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

依赖

密封类不依赖于记录(JEP 395)或模式匹配(JEP 394),但它可以很好地与两者配合使用。