Java 17 新特性之密封类型

工作中所有项目都已升级到了当前的 LTS 版 Java 21, 眼看 Java 快要来到了下一个 LTS 版本 - Java 25,将于今年 9 月份发布。四年前记录过一篇 Java 10 ~ 16 一路向前冲(新特性一箩筐),其中夹杂着孵化,预览中的以及正式的特性。现在继续跟随着 Java 16 之后版本的特性,主要讲述正式的,可直观体验到的特性,孵化与预览中的特性只会简单提及。


先还是看下 Java 的发布日期安排 Oracle Java SE Support Roadmap
版本                     发布日                        原定支持至               延期支持至
Java 17(LTS)      2021/9                       2026/9                      2029/12
Java 18-20         2022/3 - 2023/3      2022/9 - 2023/9     N/A
Java 21(LTS)      2023/9                       2028/9                     2031/9
Java 22               2024/3                       2024/9                      N/A
Java 23               2024/9                       2025/3                      N/A
Java 24               2025/3                       2025/9                      N/A
Java 25(LTS)      2025/9                       2030/9                     2033/9
也就是每两年(9月份)会有一个 LTS 版本,中间若干过度版本。在正式项目中尽可能只用 LTS 版本,因为 LTS 版更稳定,且有长期的补丁,不能项目进行中不得不在进行非 LTS 主版本升级。

为了迎接两个月后的 Java 25(LTS) 版本,开始回顾总结在 Java 17 与 Java 24 之间的关键新特性。又要用到 IntelliJ IDEA 中显示的不同版本 Java 的 Language level 的一句话描述


转换成文字描述
  • 15 - Text blocks
  • 16 - Records, patterns, local enums and interfaces
  • 17 - Sealed types, always-strict floating-point semantics
  • 18 - JavaDoc snippets
  • 19, 20 - No new language features
  • 21 - Record patterns, pattern matching for switch, (Preview) - String templates, unnamed classes and instance main methods, etc.
  • 22 - Unnamed variables and patterns
  • 23 - Markdown documentation comments, (Preview) - Primitive types in patterns, implicitly declared classes, etc.
  • 24 - Stream gatherers, (Preview) - Flexible constructor bodies, simple sources files, etc. 

探索 Java 17 的新特性依然由官方的 What's New in JDK 17 - New Features and Enhancements 开始

JEP 409: 密封类型(Sealed classes)

在 Kotlin, Scala 和 C# 都有 sealed 类型。Java 17 的 Sealed 类型有以下几个特性
  1. sealed 可用于修饰的类或接口
  2. sealed 声明的类或接口,必须有子类型
  3. sealed 类型的子类型必须使用 non-sealed, final, 或 sealed 修饰(non-sealed 子类可衍生孙子类, final 则不可再派生, sealed 则进一步要求明确的子类)
  4. sealed 类型用  permits 指定的子类型的当然是已定义的

密封类型的用意大约是要创建一个基类以及有限的已知子类。在没有密封类型的情况下,只能用 final 去修饰子类型,例如
1// Shape.java
2public abstract Shape {}
3
4// Circle.java
5public final class Circle extends Shape {}
6
7// Square.java
8public final class Square extends Shape {}

由于作为 SDK 使用, 所以 Shape, Circle, 和  Square  都需要声明为 public, 虽然 Circle 和  Square 是 final, 但仍然无法阻止由 Shape 衍生出其他的子类。有了 sealed 关键字的话就可以改为
1// Shape.java
2public sealed class Shape permits Circle, Square {}
3
4// Circle.java
5public final class Circle extends Shape {}
6
7// Square.java
8public final class Square extends Shape {}

这样就能彻底杜绝再创建新的 Shpe, Circle, 或  Square 的子类了。

密封类涉及到 sealed, non-sealed, permits, 以及已有的 final 关键字。

Java 在防止创建子类的方式还有一些,如 final 类型完全杜绝子类,package-private 构造函数只允许同包中创建子类(但会影响外部访问基类信息),private 构造函数比 final 类还严格些。

Sealed 类型有些像枚举,只不过后者限定的是有限的实例,sealed 要达成的目的是有限的子类。

逐步理解密封类型

我们可通过在 IntelliJ IDEA 中的提示错误来感受 Sealed Classes 的用法

首先我们只引用 sealed 关键字,只写出下面的代码
1public sealed class Shape {}

IntelliJ IDEA 会有 Shape 的错误提示
Sealed class must have subclasses
Make 'Shape' non-sealed
这里所说的 non-sealed 其实是说移除 sealed 关键字,而非真把 sealed 换成 non-sealed,如此写成
1public non-sealed class Shape {}

又会看到对 non-sealed 的错误提示
Modifier 'non-sealed' is not allowed on classes that do not have a sealed superclass
所以必须要给它声明一个 subclass
1public sealed class Shape {
2}
3
4class Rectangle extends Shape {}

这时候对 Rectangle 有错误提示
Modifier 'sealed', 'non-sealed' or 'final' expected
相应的有以下三种选择
1final class Rectangle extends Shape {}
2non-sealed class Rectangle extends Shape {}
3sealed class Rectangle extends Shape {}

final 表示 Rectangle 不可被继承

non-sealedRectangle 成为一个普通,可被任意继承的类,重新开放,如由它创建一个 Square 的子类

sealed 又让 Rectangle 又像之前的 Shape 那样要求有子类的编译错误
Sealed class must have subclasses 
这时候还没有引入 permits 关键字,其实下面两种写法是等效的

1public sealed class Shape {}
2final class Rectangle extends Shape {}
3final class Circle extends Shape {}
1public sealed class Shape permits Rectangle, Circle {}
2final class Rectangle extends Shape {}
3final class Circle extends Shape {}

permits 关键字在基类 Shape 这一层就明确了允许的子类,而不需要到子类中寻找。建议不要省略 permits 关键字。

如果少量的实现代码可写在一个源文件中,如
1public abstract sealed class Shape {
2    final class Rectangle extends Shape {}
3    final class Circle extends Shape {}
4}

如果希望 Shape 为抽象类,就加上 abstract 关键字

Sealed Class 与包,模块

sealed 类与 permits 的子类必须在同一模块(named module) 或相同的包( package - unnamed module)。比如我们在不同的包中分别创建 Shape 和 Circle 类
1package a;
2
3public abstract sealed class Shape permits b.Circle{}
1package b;
2
3public final class Circle extends a.Shape {}

我们看到上面两段代码的错误都是
Class 'b.Circle' from another package not allowed to extend sealed class 'a.Shape' in unnamed module
要么放在同一个包中(unnamed module), 如果我们创建一个 named module, Shape 和  Circle 就能在不同的 package 了。

以 Maven 项目为例,具体做法是创建下面的文件布局
 1.
 2├── pom.xml
 3└── src
 4    └── main
 5        └── java
 6            ├── module-info.java
 7            └── xyz
 8                ├── a
 9                │   └── Shape.java
10                └── b
11                    └── Circle.java

module-info.java 中内容为
1module xyz {
2}

命令模块名为 xyz

Shape.java 代码
1package xyz.a;
2
3import xyz.b.Circle;
4
5public abstract sealed class Shape permits Circle {
6}

Circle.java 代码
1package xyz.b;
2
3import xyz.a.Shape;
4
5public final class Circle extends Shape {
6}

这样编译就没问题了,想要使用 Circle 类,可创建 src/main/java/com/example/Client.java
1package com.example;
2
3import xyz.b.Circle;
4
5public class Client {
6    public static void main(String[] args) {
7        System.out.println(new Circle());
8    }
9}

正常使用模块 xyz 中的 Circle 类。

sealed 关键也可用于 interface,只需要注意 interface 与 class 的区别就是了,例如下面代码演示了 sealed interface 的用法
1sealed interface Shape permits Circle, Rectangle, Triangle {}
2final class Circle implements Shape {}
3non-sealed class Rectangle implements Shape {}
4final class Square extends Rectangle {}
5record Triangle(float x, float y, float z) implements Shape {}

由于 record 天生是 final, 所以实现 Shape 的 Triangle 不用再写 final。

Sealed Class 与模式匹配

这要穿越到 Java 21,因为 Java 21 才开始正式支持模式匹配。Sealed Class 像枚举一样有着限定数目的类型,所以针对前面的代码,我们可以写下面 switch-case
1String matchName(Shape shape) {
2    return switch (shape) {
3        case Circle c -> "Circle";
4        case Square s -> "Square";
5        case Rectangle r -> "Rectangle";
6    };
7}

正如模式对于枚举类型在编译期就能推断出有没有覆盖所有的选项,没有则必需要有 default 分支,对于 Sealed Class 也一样,编译期也知道所有的子类型,所以这段代码覆盖了所有的分支,无须 default, 可通过编译。

Sealed Class 与字节码,反射

Sealed Class 编译产生的字节码中有一个 PermittedSubclasses 表示了它是不是一个 Sealed Class, 有值(非空数组)则是, null 值则不是。

测试下面的代码,String 为非 Sealed Class, Shape 为 Sealed Class
1System.out.println(String.class.getPermittedSubclasses());
2System.out.println(String.class.isSealed());
3System.out.println(Shape.class.getPermittedSubclasses());
4System.out.println(Shape.class.isSealed());

执行的输出为
null
false
[Ljava.lang.Class;@2e0fa5d3
true

JDK 中的 Sealed class

在 java.lang.constant 包中
 1package java.lang.constant;
 2
 3public sealed interface ConstantDesc
 4        permits ClassDesc,
 5                MethodHandleDesc,
 6                MethodTypeDesc,
 7                Double,
 8                DynamicConstantDesc,
 9                Float,
10                Integer,
11                Long,
12                String {
13......
14}

它封装了 JVM class 文件中所有合法的常量描述类型,对于我们编写代码好像没什么用。

小结 Sealed class 的几条约束

基本是从 JEP 409 中转述的内容
    1. sealed 类与 permits 允许的子类必须在同一个模块中(named module), 或在同一个包中(unnamed module)
    2. 每一个 permits 子类必须直接继承自 sealed 类
    3. 每个 permits 子类必须使用 final, sealednon-sealed 中的一个关键字修饰

      • final 修饰的让该子类不再被继承(record 暗含着 final)
      • sealed 让子类又成为一个 sealed class, 将适用一样的 sealed 类规则
      • non-sealed 又让子类复原为普通的类,即解除了 sealed 的限制。就像没有用 sealed 修饰的类一样(class Person),它将不在对任何未知子类对它的继承。 

Sealed Class 对我们以后使用框架编写应用似乎没多大用处,理解它将有助于我们读懂第三方库使用了 sealed 特性的代码,或者自己要写 SDK 时又不希望使用者任意发挥时有些帮助。

通过写本文顺便把自 Java 9 开始引入的模块概念也略微有些体验。本想一文综述 Java 17 的特别关键新特性,没想第一个 Sealed Class 就占满了篇幅,所以不得已到此为止。 永久链接 https://yanbin.blog/java-17-new-features-sealed-classes/, 来自 隔叶黄莺 Yanbin's Blog
[版权声明] 本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。

Posts in this series