JEP 513:灵活的构造函数体
JEP 513:灵活的构造函数体
原文:JEP 513- Flexible Constructor Bodies
作者:
日期:2025-10-26
| 作者 | 阿奇·科布斯(Archie Cobbs)和加文·比尔曼(Gavin Bierman) |
|---|---|
| 所有者 | 加文·比尔曼(Gavin Bierman) |
| 类型 | 特性 |
| 范围 | SE |
| 状态 | 已关闭 / 已交付 |
| 版本 | 25 |
| 组件 | 规范 / 语言 |
| 讨论组 | amber - dev@openjdk.org |
| 相关 | JEP 492:灵活的构造函数体(第三次预览) |
| 评审人 | 亚历克斯·巴克利(Alex Buckley)、布莱恩·戈茨(Brian Goetz) |
| 批准人 | 布莱恩·戈茨(Brian Goetz) |
| 创建时间 | 2024/11/21 12:03 |
| 更新时间 | 2025/09/25 23:48 |
| 问题编号 | 8344702 |
摘要
在构造函数的主体中,允许在显式构造函数调用(即 super(...) 或 this(...))之前出现语句。这些语句不能引用正在构造的对象,但可以初始化其字段并执行其他安全计算。此更改使得许多构造函数能够更自然地表达。它还允许在字段对类中的其他代码(例如从超类构造函数调用的方法)可见之前对其进行初始化,从而提高安全性。
历史
灵活的构造函数体最初由 JEP 447(JDK 22)作为预览特性提出,标题不同。它们经过修订并由 JEP 482(JDK 23)重新预览,然后由 JEP 492(JDK 24)再次预览,未做更改。我们在此提议在 JDK 25 中最终确定该特性,不做更改。
目标
- 消除对构造函数中代码的不必要限制,以便在调用超类构造函数之前可以轻松验证参数。
- 提供额外保证,确保新对象的状态在任何代码使用它之前已完全初始化。
- 重新审视构造函数如何相互交互以创建完全初始化对象的过程。
动机
类的构造函数负责创建该类的有效实例。通常,构造函数会验证并转换其参数,然后将其类中声明的字段初始化为合法值。在存在子类化的情况下,超类和子类的构造函数共同负责创建有效实例。
例如,考虑一个带有 Employee 子类的 Person 类。每个 Employee 构造函数都会隐式或显式调用一个 Person 构造函数,这两个构造函数应协同工作以构造一个有效实例。Employee 构造函数负责 Employee 类中声明的字段,而 Person 构造函数负责 Person 类中声明的字段。由于 Employee 构造函数中的代码可以引用 Person 类中声明的字段,因此只有在 Person 构造函数完成对这些字段赋值之后,Employee 构造函数访问这些字段才是安全的。
Java 语言通过自上而下运行构造函数来确保有效实例的构造:超类中的构造函数在子类中的构造函数之前运行。为实现这一点,该语言要求构造函数中的第一条语句必须是构造函数调用,即 super(...) 或 this(...)。如果不存在这样的语句,则 Java 编译器会插入一个超类构造函数,即 super()。
由于超类构造函数首先运行,因此超类中声明的字段会在子类中声明的字段之前初始化。因此,Person 构造函数会在 Employee 构造函数验证其参数之前完整运行,这意味着 Employee 构造函数可以假定 Person 构造函数已正确初始化 Person 类中声明的字段。
构造函数限制过多
构造函数的自上而下规则有助于确保新构造的实例有效,但它禁止了一些常见且合理的编程模式。开发人员常常因无法在构造函数中编写完全安全的代码而感到沮丧。
例如,假设我们的 Person 类有一个 age 字段,但要求员工年龄在 18 到 67 岁之间。在 Employee 构造函数中,我们希望在将 age 参数传递给 Person 构造函数之前对其进行验证 —— 但构造函数调用必须放在首位。我们可以在之后验证参数,但这意味着要做调用超类构造函数这一可能不必要的工作:
class Person {
...
int age;
Person(..., int age) {
if (age < 0)
throw new IllegalArgumentException(...);
...
this.age = age;
}
}
class Employee extends Person {
Employee(..., int age) {
super(..., age); // 可能不必要的工作
if (age < 18 || age > 67)
throw new IllegalArgumentException(...);
}
}
更好的做法是声明一个 Employee 构造函数,通过在调用 Person 构造函数之前验证其参数来快速失败。这显然是安全的,但由于构造函数调用必须放在首位,唯一的方法是内联调用一个辅助方法,作为构造函数调用的一部分:
class Employee extends Person {
private static int verifyAge(int value) {
if (age < 18 || age > 67)
throw new IllegalArgumentException(...);
return value;
}
Employee(..., int age) {
super(..., verifyAge(age));
}
}
超类构造函数必须放在首位的要求在其他场景中也会引发问题。例如,我们可能需要执行一些非平凡的计算来为超类构造函数调用准备参数。或者,我们可能需要准备一个复杂的值,以便在超类构造函数调用的几个参数之间共享。
超类构造函数可能破坏子类的完整性
每个类都对其自身字段的有效状态有一个明确表述或默认的规范。如果编写正确,类的实现仅建立并维护有效状态。无论其超类、子类以及程序中所有其他类采取何种操作,它都会这样做。换句话说,每个类都旨在具有 “完整性”。只要一个实例的类及其所有超类都具有完整性,那么该实例就具有完整性。
自上而下的规则确保超类构造函数始终在子类构造函数之前运行,从而确保超类的字段得到正确初始化。不幸的是,该规则不足以确保新实例整体的完整性。超类构造函数可以在子类构造函数初始化子类字段之前间接访问这些字段。例如,假设 Employee 类有一个 officeID 字段,并且 Person 类中的构造函数调用了一个在 Employee 类中被重写的方法:
class Person {
...
int age;
void show() {
System.out.println("Age: " + this.age);
}
Person(..., int age) {
if (age < 0)
throw new IllegalArgumentException(...);
...
this.age = age;
show();
}
}
class Employee extends Person {
String officeID;
@Override
void show() {
System.out.println("Age: " + this.age);
System.out.println("Office: " + this.officeID);
}
Employee(..., int age, String officeID) {
super(..., age); // 可能不必要的工作
if (age < 18 || age > 67)
throw new IllegalArgumentException(...);
this.officeID = officeID;
}
}
new Employee(42, "CAM - FORA") 会输出什么?你可能期望它输出 Age: 42,也许还会输出 Office: CAM - FORA,但实际上它输出 Age: 42 和 Office: null!这是因为 Person 构造函数在 officeID 字段被 Employee 构造函数初始化之前运行。Person 构造函数调用 show 方法,导致 Employee 类中重写的 show 方法在 Employee 构造函数将 officeID 字段初始化为 "CAM - FORA" 之前运行。结果,show 方法输出了 officeID 字段的默认值,即 null。
这种行为破坏了 Employee 类的完整性,该类要求其字段在被构造函数初始化为有效状态之前不被访问。甚至 Employee 类中的 final 字段在被初始化为其 final 值之前也可能被访问,因此可以观察到 final 字段的值发生变化!
由于构造函数可以 调用可重写方法,这个特定的例子就很麻烦。虽然这样做被认为是不良实践 —— 《Effective Java》 的第 19 条建议“构造函数绝不能调用可重写方法” —— 但这种情况并不少见,并且是现实世界中微妙错误和问题的来源。但这只是此类行为的一个例子。再举个例子,没有什么能阻止超类构造函数将当前实例传递给另一个在子类构造函数为子类字段赋值之前访问这些字段的方法。
追求更高的表达性和安全性
总之,自上而下的规则常常限制了构造函数的表达能力。此外,一个类几乎无法保护自身免受其超类或其他代码对其完整性的破坏。我们需要解决这两个问题的方案。
描述
我们提议取消自 Java 语言诞生以来就强制执行的简单语法自上而下规则,即每个构造函数体都必须显式或隐式地以构造函数调用(即 super(..) 或 this(..))开头。
这一改变使我们能够编写可读性强的构造函数,在调用超类构造函数之前验证其参数。例如,我们可以更直接、更清晰地编写 Employee 构造函数,使其快速失败:
class Employee extends Person {
String officeID;
Employee(..., int age, String officeID) {
if (age < 18 || age > 67)
// 现在快速失败!
throw new IllegalArgumentException(...);
super(..., age);
this.officeID = officeID;
}
}
这一改变还使我们能够确保子类构造函数在调用超类构造函数之前通过初始化其字段来建立完整性。例如,我们可以进一步修改 Employee 构造函数,在调用超类构造函数之前初始化 officeID 字段:
class Employee extends Person {
String officeID;
Employee(..., int age, String officeID) {
if (age < 18 || age > 67)
// 现在快速失败!
throw new IllegalArgumentException(...);
this.officeID = officeID; // 在调用超类构造函数之前初始化!
super(..., age);
}
}
现在,new Employee(42, "CAM - FORA") 如预期输出 Age: 42 和 Office: CAM - FORA。Employee 类的完整性得以维护。
构造函数体的新模型
摒弃自上而下规则代表了构造函数体的一种新语义模型。如今,构造函数体有两个不同阶段:_ 序幕 _ 是调用下一个构造函数之前的代码,_ 尾声 _ 是该调用之后的代码。
为说明这一点,考虑以下类层次结构:
class Object {
Object() {
// 对象构造函数体
}
}
class A extends Object {
A() {
super();
// A 构造函数体
}
}
class B extends A {
B() {
super();
// B 构造函数体
}
}
class C extends B {
C() {
super();
// C 构造函数体
}
}
class D extends C {
D() {
super();
// D 构造函数体
}
}
当前,通过 new D() 创建 D 类的新实例时,构造函数的调用及其函数体的执行流程如下:
D
--> C
--> B
--> A
--> 对象构造函数体
--> A 构造函数体
--> B 构造函数体
--> C 构造函数体
D 构造函数体
也就是说,构造函数自下而上调用,从层次结构的底部开始,但构造函数体自上而下运行,从层次结构顶部的 Object 类开始,逐个向下遍历子类。
当构造函数体同时有序幕和尾声时,我们可以对类声明进行概括:
class Object {
Object() {
// 对象构造函数体
}
}
class A extends Object {
A() {
// A 序幕
super();
// A 尾声
}
}
class B extends A {
B() {
// B 序幕
super();
// B 尾声
}
}
class C extends B {
C() {
// C 序幕
super();
// C 尾声
}
}
class D extends C {
D() {
// D 序幕
super();
// D 尾声
}
}
构造函数的调用以及序幕和尾声的执行流程如下:
D 序幕
--> C 序幕
--> B 序幕
--> A 序幕
--> 对象构造函数体
--> A 尾声
--> B 尾声
--> C 尾声
D 尾声
也就是说,序幕自下而上运行,然后尾声自上而下运行。我们可以通过编写序幕,自下而上确保每个子类的字段都被赋予有效值,从而创建有效的实例。这反过来使我们能够编写尾声,因为知道它们所观察的状态是有效的,所以它们可以自由引用正在构造的实例。
语法
我们修改了 构造函数体 的语法,以允许在显式构造函数调用之前有语句;即从:
ConstructorBody:
{ [ExplicitConstructorInvocation] [BlockStatements] }
改为:
ConstructorBody:
{ [BlockStatements] ExplicitConstructorInvocation [BlockStatements] }
{ [BlockStatements] }
省略一些细节,ExplicitConstructorInvocation 要么是 _ 超类构造函数调用 _,即 super(...),要么是 _ 替代构造函数调用 _,即 this(...)。
显式构造函数调用之前出现的语句构成构造函数体的 _ 序幕 _。
显式构造函数调用之后出现的语句构成构造函数体的 _ 尾声 _。
构造函数体不一定包含显式构造函数调用。在这种情况下,序幕为空,调用不带参数的直接超类的构造函数,即 super(),被认为隐式出现在构造函数体的开头,构造函数体中的所有语句构成尾声。
构造函数体的尾声中允许使用 return 语句,但不得包含表达式。也就是说,允许 return,但不允许 return e。return 语句出现在构造函数体的序幕中是编译时错误。
在构造函数体的序幕或尾声中抛出异常是允许的。在快速失败场景中,通常会在序幕中抛出异常。
早期构造上下文
当前,出现在显式构造函数调用参数列表中的代码被称为出现在 _静态上下文 _ 中。这意味着显式构造函数调用的参数被当作 static 方法中的代码处理,在其中没有实例可用。然而,静态上下文的技术限制比必要的更强,它们阻止了有用且安全的代码作为构造函数参数出现。
我们不修改静态上下文的概念,而是引入 _ 早期构造上下文 _ 的概念,它涵盖显式构造函数调用的参数列表以及构造函数体中该调用之前出现的任何语句,即在序幕中。早期构造上下文中的代码不得使用正在构造的实例,除非用于初始化没有自身初始化器的字段。
换句话说,早期构造上下文中的代码不得显式或隐式使用 this 来引用当前实例或访问当前实例的字段或调用当前实例的方法。此规则的唯一例外是,此类代码可以使用 简单赋值语句 对同一类中声明的字段进行赋值,前提是这些字段的声明没有初始化器。
例如:
class X {
int i;
String s = "hello";
X() {
System.out.print(this); // 错误 - 显式引用当前实例
var x = this.i; // 错误 - 显式引用当前实例的字段
this.hashCode(); // 错误 - 显式引用当前实例的方法
var y = i; // 错误 - 隐式引用当前实例的字段
hashCode(); // 错误 - 隐式引用当前实例的方法
i = 42; // 正确 - 对未初始化的声明字段进行赋值
s = "goodbye"; // 错误 - 对已初始化的声明字段进行赋值
super();
}
}
进一步的限制是,早期构造上下文中的代码不得使用 super 来访问超类的字段或调用超类的方法:
class Y {
int i;
void m() { ... }
}
class Z extends Y {
Z() {
var x = super.i; // 错误
super.m(); // 错误
super();
}
}
记录类
记录类的构造函数 相比普通类的构造函数,已经受到了更多限制。具体来说:
- 规范记录构造函数不得包含显式构造函数调用;
- 非规范记录构造函数必须包含替代构造函数调用,即
this(...),而不能包含超类构造函数调用,即super(...)。
这些限制依然存在。除此之外,记录构造函数能从上述变更中受益,主要原因是现在非规范记录构造函数在替代构造函数调用之前可以包含语句。
枚举类
枚举类的构造函数 可以包含替代构造函数调用,但不能包含超类构造函数调用。和记录类一样,枚举类也能从上述变更中受益,主要是因为其构造函数现在在替代构造函数调用之前可以包含语句。
嵌套类
当类声明是嵌套的时,内部类的代码可以引用外部类的实例。这是因为外部类的实例在内部类的实例之前创建。内部类的代码 —— 包括构造函数体 —— 可以使用简单名称或 限定的 this 表达式 来访问外部实例的字段并调用其方法。因此,在早期构造上下文中允许对外部实例进行操作。
在下面的代码中,Inner 的声明嵌套在 Outer 的声明中,所以 Inner 的每个实例都有一个 Outer 的外部实例。在 Inner 的构造函数中,早期构造上下文中的代码可以通过简单名称或 Outer.this 来引用外部实例及其成员。
class Outer {
int i;
void hello() { System.out.println("Hello"); }
class Inner {
int j;
Inner() {
var x = i; // 正确 - 隐式引用外部实例的字段
var y = Outer.this.i; // 正确 - 显式引用外部实例的字段
hello(); // 正确 - 隐式引用外部实例的方法
Outer.this.hello(); // 正确 - 显式引用外部实例的方法
super();
}
}
}
相比之下,在下面展示的 Outer 的构造函数中,早期构造上下文中的代码不能使用 new Inner() 来实例化 Inner 类。这个表达式实际上是 this.new Inner(),意味着它使用 Outer 的当前实例作为 Inner 实例的外部实例。根据前面的规则,早期构造上下文中的代码不得显式或隐式使用 this 来引用当前实例。
class Outer {
class Inner {}
Outer() {
var x = new Inner(); // 错误 - 隐式引用 Outer 的当前实例
var y = this.new Inner(); // 错误 - 显式引用 Outer 的当前实例
super();
}
}
测试
- 我们将使用现有的单元测试来测试编译器变更,除了那些验证变更行为的测试外,其他测试保持不变,并根据需要添加新的正向和负向测试用例。
- 我们将使用编译器的旧版本和新版本编译所有 JDK 类,并验证生成的字节码是否相同。
- 无需进行特定于平台的测试。
风险与假设
我们上面提出的变更在源代码和行为上都是兼容的。它们严格扩展了合法 Java 程序的集合,同时保留了所有现有 Java 程序的含义。
尽管这些变更本身不大,但它们代表了构造函数在安全对象初始化方面参与方式的重大变化。它们放宽了长期以来的要求,即如果存在构造函数调用,它必须始终是构造函数体中的第一条语句。这个要求在代码分析器、风格检查器、语法高亮器、开发环境以及 Java 生态系统中的其他工具中根深蒂固。与任何语言变更一样,在工具更新时可能会有一段阵痛期。
依赖项
Java 虚拟机
Java 语言中灵活的构造函数体依赖于 JVM 验证和执行构造函数中构造函数调用之前出现的任意代码的能力,只要该代码除了初始化未初始化的字段外,不引用正在构造的实例。
幸运的是,JVM 已经支持对构造函数体进行更灵活的处理:
- 只要在任何代码路径上恰好有一次调用,构造函数体中就可以出现多个构造函数调用;
- 只要代码除了给字段赋值外不引用正在构造的实例,构造函数调用之前就可以出现任意代码;
- 显式构造函数调用不得出现在
try块内,即在字节码异常范围内。
JVM 的规则仍然确保安全的对象初始化:
- 超类初始化始终恰好发生一次,要么直接通过超类构造函数调用,要么间接通过替代构造函数调用;
- 在超类初始化完成之前,除了不影响结果的字段赋值外,未初始化的实例不可访问。
因此,这个提议不需要对《Java 虚拟机规范》进行任何更改,只需要对《Java 语言规范》进行更改。
JVM(允许灵活的构造函数体)和 Java 语言(不允许)之间现有的不匹配是历史遗留问题。最初 JVM 的限制更严格,但这导致了诸如内部类等新语言特性的编译器生成字段的初始化问题。为了适应编译器生成的代码,我们多年前放宽了《JVM 规范》,但我们从未修订《Java 语言规范》以利用这种新的灵活性。
值类
来自 Project Valhalla 的 JEP 401 提出了 _ 值类 _ 并以此工作为基础。当值类的构造函数不包含显式构造函数调用时,隐式构造函数调用被认为隐式出现在构造函数体的末尾,而不是开头。因此,这样的构造函数中的所有语句构成其序幕,而尾声为空。