Java17-Sealed Classes(密封类)
序言
概括
使用密封类/接口增强 Java 编程语言 。密封类和接口限制哪些类或接口可以扩展或实现它们。
目标
-
允许类或接口的作者控制哪些代码负责实现它。
-
提供比访问修饰符更具声明性的方法来限制超类的使用。
-
通过为模式的详尽分析提供基础,支持模式匹配的未来发展方向。
非目标
- 项目不打算引入新的访问控制形式,比如friend这种关系。换句话说,项目不会实现基于用户间友谊关系的访问权限管理。
final
无论如何改变都不是目标。项目不会对 Java 中的final
关键字进行任何修改。
动机
类和接口的继承层次结构和面向对象的数据模型已证明在现代应用程序处理的真实世界数据进行建模方面非常有效。这种表现力是 Java 语言的一个重要方面。
然而,有些情况下,这种表达能力可以得到有效控制。例如,Java 支持枚举类来模拟给定类只有固定数量实例的情况。在下面的代码中,枚举类列出了一组固定的行星。它们是该类的唯一值,因此你可以尽情地切换它们——而无需编写子句default(因为Planet中的值是确定的)
:
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 设计中。Java在这方面提供了有限的工具:要么创建一个类,使其没有子类,要么使该类或其构造函数成为包私有的,因此它只能在同一个包中拥有子类。JDK中出现了final
一个包私有超类的示例 :
package java.lang;
abstract class AbstractStringBuilder { ... }
public final class StringBuffer extends AbstractStringBuilder { ... }
public final class StringBuilder extends AbstractStringBuilder { ... }
包私有的方式在代码重用方面是有用的,特别是当目标是让 AbstractStringBuilder
的子类共享 append
方法的实现时。这意味着可以在同一个包内的类之间共享代码。然而,当目标是建模不同的替代方案(模板)时,这种方法是无用的,因为用户代码无法访问关键抽象类(如超类),从而无法根据需要在不同的实现之间切换(模板)。允许用户访问超类,但不允许他们扩展它,无法通过简单的方式实现。这通常会涉及到使用非公共构造函数的脆弱方法,但这种方法对于接口是不适用的。在声明Shape及其子类的图形库中,如果只有一个包可以访问Shape,那将是不幸的。
例子:
假设有一个类 Shape
,只希望其他解决方案使用其中的方法,而不是扩展该类,如果它的构造函数是私有的,外部代码就无法创建 Shape
的实例。如果你想让 Shape
只能通过子类来实例化,可能需要使用工厂方法,这样就会增加复杂性,而单纯的接口就更不可能实现,简单的来说就是希望一个类或接口,被已知的子类(作者编写的子类)扩展,而不希望被未知的其他类扩展,而只是单纯的使用。
总之,超类应该可以被广泛访问 (因为它代表了用户的重要抽象),但不能被广泛扩展(因为它的子类应该仅限于作者所知的子类)。这种超类的作者应该能够表明它是与给定的一组子类共同开发的,这既是为了向读者记录意图,也是为了允许 Java 编译器强制执行。同时,超类不应过度限制其子类,例如,强迫它们定义自己的final
或阻止它们定义自己的状态。
描述
密封的类或接口只能由允许的类或接口来继承或实现。
通过将修饰符sealed应用于类的声明中,表示这是一个密封类。然后,在任何extends和implementations子句之后,permits子句指定允许扩展密封类的类。例如,以下声明Shape
指定了三个允许的子类:
package com.example.geometry;
public abstract sealed class Shape
permits Circle, Rectangle, Square { ... }
指定的类必须位于超类附近:在同一模块中(如果超类在的命名模块中)或在同一包中(如果超类在命名的模块中)。例如,在Shape的以下声明中,其允许的子类都位于同一命名模块(示例中example包)的不同包中:
package com.example.geometry;
public abstract sealed class Shape
permits com.example.polar.Circle,
com.example.quad.Rectangle,
com.example.quad.simple.Square { ... }
当permits指定的子类在占用大小和数量上都很小时,声明内部类使用它们可能会很方便。当它们以这种方式声明时,密封类可能会省略permissions子句,Java编译器将从源文件中的声明中推断出允许的子类。(子类可以是辅助类或嵌套类。)例如,如果在Root.java中找到以下代码,则推断密封类Root有三个允许的子类:
abstract sealed class Root { ...
final class A extends Root { ... }
final class B extends Root { ... }
final class C extends Root { ... }
}
permits指定的类必须具有规范名称,否则将报告编译时错误。这意味着匿名类和本地类不能作为密封类的子类型。
一个密封类对其允许的子类施加了三个约束:
-
密封类及其允许扩展的子类必须属于同一个模块,如果在未命名的模块中声明,则必须属于相同的包。
-
每个允许的子类都必须直接扩展密封类。
-
每个允许的子类都必须使用修饰符来描述它如何传播其超类发起的密封:
-
允许的子类可以被声明为final,以防止其类层次结构的一部分被进一步扩展。(Record classes隐式声明为final)Record classes
-
可以声明一个允许的子类,
sealed
允许其层次结构的一部分比其密封的超类所设想的扩展得更远,但要以受限制的方式。具体来说,密封子类只能允许特定的类作为其子类,通常通过在其声明中指定这些允许的子类来实现。这种方式可以精确控制类层次结构。 -
可以声明允许的子类使用
non-sealed
,以便其层次结构的一部分恢复为对未知子类的扩展开放。如果一个允许的子类被声明为non-sealed
,那么它的层次结构重新开放,可以被任何未知的子类扩展。这意味着其他类可以从这个non-sealed
子类继承,恢复了继承的灵活性。需要注意的是,密封类无法阻止其允许的子类使用non-sealed
修饰符。(修饰符non-sealed
是第一个为 Java 提出的带连字符的关键字 。)
举第三个约束的例子,Circle
和Square
可能是final,而Rectangle是sealed,我们添加了一个新的非密封子类WeirdShape:
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
的实例。因此,当编写代码来检查一个 Shape
实例是否为 Circle
、Rectangle
、Square
或 WeirdShape
时,仍然是完整的(exhaustive)。这意味着没有遗漏任何可能的情况。
- 完整性:由于
Shape
的密封特性,编译器可以知道所有直接的子类是什么。这种设计使得开发者可以信心十足地处理所有Shape
类型的实例,而不会遗漏任何可能的子类。 - 开放性与可扩展性:尽管
WeirdShape
是非密封的并允许未知类的扩展,但由于所有这些类仍然是Shape
的子类,因此在逻辑上,代码仍然可以对所有类型进行有效的分类和处理。
每个允许的子类必须使用final、sealed和non-sealed中的一个修饰符。一个类不可能既是密封的(包含子类)又是final的(不包含子类),或者既是非密封的(任意子类),也是final(不包含子类),或者同时是密封的(象征着受限的子类)和非密封(任意子类)的。
(final修饰符可以被视为密封的一种特殊情况,完全禁止扩展/实现。也就是说,final在概念上相当于密封加上一个没有具体说明的许可条款,尽管这样的许可条款不能写成)
一个密封或非密封的类可以是抽象的,并且有抽象成员。一个密封类可以允许抽象的子类,只要它们是密封的或非密封的,而不是final的。
如果其他类(未被允许的类)扩展了密封类,则这是一个编译时错误。
类的访问性(Class Accessibility)
- extends 和 permits 子句:
当我们在 Java 中使用extends
(继承)和permits
(允许)子句时,它们需要类名来定义继承关系或允许子类。因此,密封类和其允许的子类必须相互访问,否则无法建立这样的继承或许可关系。 - 访问级别不需要一致:
尽管密封类和允许的子类需要能够互相访问,这些子类的访问级别不必相同。也就是说,某个子类的访问权限可以比密封类更低。例如,密封类可能是public
的,而某个子类可能是package-private
,即只能在同一包内访问。 - 对 switch 语句的影响:
在未来 Java 版本中,模式匹配(pattern matching)可能会被引入到switch
语句中,用来对不同类型的子类进行匹配。但由于某些子类的访问权限可能较低,导致某些代码无法访问所有的子类,这样switch
语句就无法穷尽(exhaustive)所有可能的子类。在这种情况下,Java 编译器会建议开发者在switch
中添加default
子句来处理那些无法访问的情况。编译器可能会生成定制的错误信息,提醒开发者添加default
子句来确保完整性。
密封接口(Sealed interfaces)
与类类似,接口也可以通过 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 { ... }
密封和记录类(Sealing and record classes)
密封类与记录类配合得很好。记录类是隐式的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 { ... }
密封类和记录类的组合有时被称为代数数据类型(Algebraic Data Types, ADTs):记录类允许我们表示乘积类型(Product Type),密封类允许我们表达求和类型(Sum Types)。
代数数据类型(Algebraic Data Types, ADTs)
1. 乘积类型(Product Type)
乘积类型 表示多个属性的组合。你可以把它理解为“这个类型包含多个字段,每个字段都有值”。乘积类型的名称来源于数学中的笛卡尔积,意味着组合了多个独立的值。每一个属性或字段都是必须存在的。
举例
比如一个 Person
类:
public record Person(String name, int age) {}
这里的 Person
是一个乘积类型,因为它由两个字段组成:name
和 age
。每个 Person
都会有这两个值。换句话说,Person
类型是 String
和 int
的“乘积”。每一个实例都需要同时有 name
和 age
,这是一个组合的结构。
乘积类型的核心特点:每个字段的值都必须存在,且它们是组合在一起的。
用图示表示乘积类型:
Person = (String × int)
其中只要任意一个值(字段)改变,则结果就会不同 ,每个 Person
实例都必须有 name
和 age
,两者缺一不可。
2. 求和类型(Sum Type)
求和类型 表示多个可能的选择项,也称为 联合类型。你可以把它理解为“这个类型可以是几种类型之一,但只能是其中一个”。求和类型的名字来源于数学中的求和,表示不同可能的情况之一。
举例
比如一个 Shape
类:
public sealed class Shape permits Circle, Square, Rectangle {}
public final class Circle extends Shape {}
public final class Square extends Shape {}
public final class Rectangle extends Shape {}
这里的 Shape
是一个求和类型,因为它可以是 Circle
、Square
或 Rectangle
之一,但只能是其中一个。每个 Shape
实例只能表示一种形状(Circle
、Square
或 Rectangle
),这是一种选择的结构。
求和类型的核心特点:它表示可能的几种类型之一,而不是组合。
用图示表示求和类型:
Shape = Circle + Square + Rectangle
每个 Shape
实例只能是 Circle
、Square
或 Rectangle
中的一种。
3.结合乘积类型和求和类型
乘积类型和求和类型通常可以结合使用,来更好地表达复杂的数据结构。例如:
public sealed interface Shape permits Circle, Square {}
public record Circle(double radius) implements Shape {}
public record Square(double sideLength) implements Shape {}
这里的 Shape
是求和类型,因为它可以是 Circle
或 Square
之一。而 Circle
和 Square
本身是乘积类型,因为它们有不同的字段,如 radius
和 sideLength
。
总结
- 乘积类型:多个字段的组合,表示的是“并存的多个属性”。
- 求和类型:多个可能的类型选择,表示的是“互斥的可能性”。
这两者的组合可以帮助设计清晰且结构化的数据模型,使程序的逻辑和数据表示更具可读性和安全性。
密封类和类型转换(Sealed classes and conversions)
类型转换(cast expression)和 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是一个密封类,并且允许D类对其进行扩展,而D类继承了C类并且为final类,C和D都没有实现I接口,而这两个类都不允许被其他类扩展,所以这里C instanceof I是不成立的。
因此,应拒绝此程序,因为不可能存在 实现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类,未来可能被O类继承,并且实现 I接口。所以这里并不会抛出异常,因为继承D类的O类,同时也是C/I的子类.
因此,支持sealed
类会导致缩小引用转换的定义发生变化 ,以确定密封层次结构,从而在编译时确定哪些转换是不可能的。
密封类允许 Java 编译器在编译时检测某些不可能的类型转换。通常,类型转换的错误在运行时才会抛出异常(如 ClassCastException
),而通过密封类的特性,编译器可以提前知道所有可能的子类,从而在编译时就能捕获那些不可能成功的转换。这不仅提高了代码的安全性,还能减少运行时错误。
希望这段解释帮助你更好地理解类型转换与密封类在 Java 中的关系,以及如何在编译时避免潜在的转换错误。
密封类和匹配模式(Sealed classes and pattern matching)
JEP 406提出了扩展模式匹配,从而实现了密封类的显著优势。用户代码将能够使用模式增强的switch,而不是使用if-else链检查密封类的实例。使用密封类将允许Java编译器检查模式是否完整。
例如,考虑这段代码使用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编译器无法确保测试实例覆盖Shape的所有允许子类。最后一个else子句实际上是不可访问的,但编译器无法验证这一点。更重要的是,如果省略了Rectangle测试的实例,则不会发出编译时的错误消息。
相比之下,通过switch的模式匹配,编译器可以确认Shape的每个允许的子类都被覆盖,因此不需要默认子句或其他总模式。此外,如果缺少以下三种情况中的任何一种,编译器将发出错误消息:
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!
}
}
总结
Sealed类提供了一种更细粒度的控制机制,允许开发者限制继承的范围,从而实现更安全、更明确的类层次设计。
- 安全性提升:通过对继承进行控制,Sealed类提高了系统的安全性,减少了意外扩展和子类滥用。
- 简化代码:通过限定子类,Sealed类可以让编译器更好地处理模式匹配、类型推断等操作,简化代码逻辑。
- 性能优化:编译器可以利用已知的子类信息,在处理类型判断和转换时做出更多的优化。
以上内容大部分摘于JEP409 ,有兴趣的小伙伴可以拜读一下.