JEP 492: Flexible Constructor Bodies (Third Preview) | 灵活的构造函数主体(第三次预览)
概述
在 Java 编程语言的构造函数中,允许语句出现在显式构造函数调用之前,即 super(..)
或 this(..)
。这些语句不能引用正在构建的实例,但可以初始化其字段。在调用另一个构造函数之前初始化字段可以在方法被覆盖时使类更加可靠。这是一个 预览语言特性。
历史
此功能首次在 JDK 22 中通过 JEP 447 以不同的标题预览。它通过 JEP 482 第二次在 JDK 23 中预览。我们在此提议第三次预览它,没有重大变化。
目标
重新构想构造函数在对象初始化过程中的角色,使开发人员能够更自然地放置他们目前必须分解到辅助静态方法、辅助中间构造函数或构造函数参数中的逻辑。
在构造函数体中引入两个不同的阶段:前导 包含在超类构造函数被调用之前执行的代码,而 后记 则是在超类构造函数被调用之后执行的代码。
保持现有保证,即子类构造函数中的代码不能干扰超类实例化。
动机
一个类的构造函数负责创建该类的有效实例。例如,假设 Person
类的实例有一个 age
字段,其值永远不能为负数。接受与年龄相关的参数(如出生日期)的构造函数必须验证该参数,并将其写入 age
字段,从而确保实例有效,或者抛出异常。
此外,一个类的构造函数还负责确保在存在子类化情况下的有效性。例如,假设 Employee
是 Person
的一个子类。每个 Employee
构造函数将隐式或显式地调用一个 Person
构造函数。这两个构造函数必须协同工作以确保实例的有效性:Employee
构造函数负责 Employee
类中声明的字段,而 Person
构造函数负责 Person
类中声明的字段。由于 Employee
构造函数中的代码可以引用 Person
类中声明的字段,因此重要的是确保这些字段在允许 Employee
构造函数访问它们之前得到正确的初始化。
Java 编程语言使用一个简单的解决方案来提供这种保证,即要求构造函数必须从上至下运行:在子类构造函数运行之前,超类中的构造函数必须首先运行,以确保超类中声明的字段的有效性。在前面的例子中,为了保证 Employee
构造函数总是看到一个有效的 age
字段,Person
构造函数必须在 Employee
构造函数之前完整运行。
为了保证构造函数从上至下运行,Java 语言要求构造函数体中的第一个语句是对另一个构造函数的显式调用,即 super(..)
或 this(..)
。如果构造函数体中没有出现显式的构造函数调用,则编译器会在构造函数体的第一条语句插入 super()
。
语言进一步要求,对于任何显式的构造函数调用,它的任何参数都不能以任何方式使用正在构建的实例。
这两个要求保证了新实例构建过程中的一些可预测性和整洁性。然而,它们过于严格,因为它们禁止某些熟悉的编程模式,如下例所示。
示例:验证超类构造函数参数
有时我们需要验证传递给超类构造函数的参数。我们可以在调用超类构造函数后验证参数,但这意味着可能会做不必要的工作:
public class PositiveBigInteger extends BigInteger {
public PositiveBigInteger(long value) {
super(value); // 可能不必要的工作
if (value <= 0) throw new IllegalArgumentException(..);
}
}
最好声明一个快速失败的构造函数,在调用超类构造函数之前验证其参数。今天,我们只能通过作为 super(..)
调用的一部分在线调用辅助方法来做到这一点:
public class PositiveBigInteger extends BigInteger {
private static long verifyPositive(long value) {
if (value <= 0) throw new IllegalArgumentException(..);
return value;
}
public PositiveBigInteger(long value) {
super(verifyPositive(value));
}
}
如果我们可以把验证逻辑放在构造函数体中,代码会更具可读性:
public class PositiveBigInteger extends BigInteger {
public PositiveBigInteger(long value) {
if (value <= 0) throw new IllegalArgumentException(..);
super(value);
}
}
示例:准备超类构造函数参数
有时我们必须执行非平凡计算以准备超类构造函数的参数。同样,我们必须求助于作为 super(..)
调用的一部分在线调用辅助方法。例如,假设一个构造函数接受一个 Certificate
参数,但必须将其转换为超类构造函数所需的 byte
数组:
public class Sub extends Super {
private static byte[] prepareByteArray(Certificate certificate) {
var publicKey = certificate.getPublicKey();
if (publicKey == null) throw new IllegalArgumentException(..);
return switch (publicKey) {
case RSAKey rsaKey -> ...
case DSAPublicKey dsaKey -> ...
default -> ...
};
}
public Sub(Certificate certificate) {
super(prepareByteArray(certificate));
}
}
如果我们能够在构造函数体中直接准备参数,代码会更具可读性:
public Sub(Certificate certificate) {
var publicKey = certificate.getPublicKey();
if (publicKey == null) throw ...
byte[] certBytes = switch (publicKey) {
case RSAKey rsaKey -> ...
case DSAPublicKey dsaKey -> ...
default -> ...
};
super(certBytes );
}
示例:共享超类构造函数参数
有时我们需要将相同的值多次传递给超类构造函数的不同参数。唯一的方法是通过辅助构造函数:
public class Super {
public Super(C x, C y) { ... }
}
public class Sub extends Super {
private Sub(C x) { super(x, x); } // 将参数两次传递给超类构造函数
public Sub(int i) { this(new C(i)); } // 准备超类构造函数的参数
}
如果我们可以安排在构造函数体内进行共享,则代码将更易于维护,从而消除对辅助构造函数的需求:
public class Sub extends Super {
public Sub(int i) {
var x = new C(i);
super(x, x);
}
}
概述
在所有这些示例中,我们希望编写的构造函数体包含了一个我们希望在调用另一个构造函数之前运行的独立阶段。这个初始阶段由不使用正在构建实例的代码组成,因此在显式构造函数调用之前运行它是安全的。不幸的是,即使所有这些构造函数体都确保了对象初始化的安全性,它们目前在 Java 语言中都是被禁止的。
如果语言能够以更灵活的规则保证对象初始化的安全性,那么构造函数体将更容易编写和维护。构造函数体可以更自然地进行参数验证、参数准备和参数共享,而无需调用笨拙的辅助方法或构造函数。现在是时候重新构想如何编写构造函数体以安全地执行对象初始化了。
描述
我们提议超越自 Java 1.0 以来强制执行的简单化语法规则,即 super(..)
或 this(..)
必须是构造函数体中的第一条语句。在新模型中,构造函数体包含两个不同的阶段:前言 是构造函数调用之前的代码,后记 是构造函数调用之后的代码。
举例来说,考虑以下类层次结构:
class Object {
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
--> Object 构造函数体
--> A 构造函数体
--> B 构造函数体
--> C 构造函数体
D 构造函数体
这就是为什么 Java 语言当前的安全对象初始化方法被描述为自上而下的:从层次结构的顶部开始执行构造函数体,即从 Object
类开始,逐一下移到子类。
当构造函数体同时具有前言和后记时,我们可以概括类声明如下:
class Object {
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 后记
}
}
因此,在评估 new D()
时相应构造函数体的执行可以可视化为:
D 前言
--> C 前言
--> B 前言
--> A 前言
--> Object 构造函数体
--> A 后记
--> B 后记
--> C 后记
D 后记
这种新方法不是自上而下地运行构造函数体,而是首先自下而上地运行前言,然后自上而下地运行后记。
这是一个 JEP 12 预览语言功能,默认情况下禁用
要在 JDK 24 中尝试下面的例子,您必须启用预览功能:
使用
javac --release 24 --enable-preview Main.java
编译程序,并使用java --enable-preview Main
运行它;或者,当使用 源代码启动器 时,使用
java --enable-preview Main.java
运行程序;或者,当使用
jshell
时,使用jshell --enable-preview
启动它。
语法
我们将修订 构造函数体 的语法规则,允许在显式构造函数调用之前出现语句,即从:
ConstructorBody:
{ [ExplicitConstructorInvocation] [BlockStatements] }
变为:
ConstructorBody:
{ [BlockStatements] ExplicitConstructorInvocation [BlockStatements] }
{ [BlockStatements] }
省略一些细节,显式的构造函数调用要么是 super(..)
要么是 this(..)
。
出现在显式构造函数调用之前的语句构成了构造函数体的 前言。
出现在显式构造函数调用之后的语句构成了构造函数体的 后记。
如果构造函数体内没有显式构造函数调用,则前言为空,构造函数体内的所有语句构成后记。
如果 return
语句不包含表达式,则允许其出现在构造函数体的后记中。也就是说,允许 return;
但不允许 return e;
。return
语句出现在构造函数体的前言中将导致编译时错误。
在构造函数体的前言或后记中抛出异常是允许的。在前言中抛出异常在快速失败场景中是典型的。
早期构造上下文
目前,在 Java 语言中,出现在显式构造函数调用的参数列表中的代码被称为处于 静态上下文。这意味着对显式构造函数调用的参数被视为就像在 static
方法中的代码一样;换句话说,就好像没有可用的实例。然而,静态上下文的技术限制比实际需要的更为严格,它们阻止了有用且安全的代码作为构造函数参数出现。
我们引入了 早期构造上下文 的概念,而非修订静态上下文的概念,该概念既涵盖了显式构造函数调用的参数列表,也包括了在构造函数体中它之前出现的任何语句,即前言部分。早期构造上下文中的代码不得使用正在构造的实例,除非用于初始化那些没有自己初始化器的字段。
这意味着在早期构造上下文中,任何显式或隐式的使用 this
引用当前实例,或访问当前实例的字段或调用其方法都是不允许的:
class A {
int i;
A() {
System.out.print(this); // 错误 - 引用了当前实例
var x = this.i; // 错误 - 显式引用了当前实例的字段
this.hashCode(); // 错误 - 显式引用了当前实例的方法
var x = i; // 错误 - 隐式引用了当前实例的字段
hashCode(); // 错误 - 隐式引用了当前实例的方法
super();
}
}
同样地,任何由 super
限定的字段访问、方法调用或方法引用在早期构造上下文中也是不允许的:
class B {
int i;
void m() { ... }
}
class C extends B {
C() {
var x = super.i; // 错误
super.m(); // 错误
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();
}
}
在早期构造上下文中对字段的早期赋值
在早期构造上下文中访问当前实例的字段是不允许的,但是在实例仍在构造过程中时给当前实例的字段赋值又如何呢?
允许这样的赋值将有助于子类中的构造函数防御超类中的构造函数看到子类中未初始化的字段。当超类中的构造函数调用一个被子类覆盖的方法时就会发生这种情况。虽然 Java 语言 允许构造函数调用可覆盖的方法,但这被认为是不好的实践:《Effective Java(第三版)》的第 19 项建议“构造函数不得调用可覆盖的方法”。看看下面的类层次结构,了解为什么这被认为是不好的做法:
class Super {
Super() { overriddenMethod(); }
void overriddenMethod() { System.out.println("hello"); }
}
class Sub extends Super {
final int x;
Sub(int x) {
/* super(); */ // 隐式调用
this.x = x;
}
@Override
void overriddenMethod() { System.out.println(x); }
}
new Sub(42)
打印出什么?你可能期望它打印 42
,但实际上它打印的是 0
。这是因为 Super
构造函数在 Sub
构造函数体中的字段赋值之前被隐式调用了。然后 Super
构造函数调用 overriddenMethod
,导致 Sub
中的该方法运行,此时 Sub
构造函数体还未有机会将 42
赋值给字段。结果,在 Sub
中的方法看到的是字段的默认值,即 0
。
这种模式是许多错误和 bug 的来源。虽然认为这是不良编程习惯,但它并不少见,并且为子类带来了难题——尤其是在修改超类不可行的情况下。
我们通过允许 Sub
构造函数在显式调用 Super
构造函数之前初始化 Sub
中的字段来解决这个难题。可以如下重写示例,其中仅更改了 Sub
类:
class Super {
Super() { overriddenMethod(); }
void overriddenMethod() { System.out.println("hello"); }
}
class Sub extends Super {
final int x;
Sub(int x) {
this.x = x; // 初始化字段
super(); // 然后显式调用 Super 构造函数
}
@Override
void overriddenMethod() { System.out.println(x); }
}
现在,new Sub(42)
将打印 42
,因为在调用 overriddenMethod
之前,Sub
中的字段已经被赋值为 42
。
在构造函数体中,只要字段声明没有初始化器,简单赋值 给同一类中声明的字段在早期构造上下文中是允许的。这意味着构造函数体可以在早期构造上下文中初始化自身类的字段,但不能初始化超类的字段。再次强调,这确保了安全的对象初始化。
如前所述,在显式构造函数调用之后,即在尾声部分,构造函数体才能读取当前实例的任何字段——无论是与构造函数相同的类中声明的,还是在超类中声明的。
记录
记录类的构造函数 已经受到比普通类构造函数更多的限制。特别是,
标准记录构造函数不得包含任何显式的构造函数调用,
非标准记录构造函数必须包含替代构造函数调用(
this(..)
),而不是超类构造函数调用(super(..)
)。
这些限制仍然存在。否则,记录构造函数将受益于上述更改,主要是因为非标准记录构造函数将能够在替代构造函数调用之前包含语句。
枚举
枚举类的构造函数 可以包含替代构造函数调用,但不能包含超类构造函数调用。枚举类将从上述更改中受益,主要是因为它们的构造函数将在替代构造函数调用之前能够包含语句。
测试
我们将使用现有单元测试对编译器更改进行测试,除了那些验证已更改行为的测试之外不做其他更改,同时增加适当的新正向和反向测试案例。
我们将使用之前的和新的编译器版本编译所有 JDK 类,并验证生成的字节码是否相同。
不需要进行特定平台的测试。
风险和假设
我们上面提出的更改是源代码和行为兼容的。它们严格扩展了合法 Java 程序的集合,同时保留了所有现有 Java 程序的意义。
尽管这些更改本身较为温和,但它们代表了构造函数在安全对象初始化中的参与方式的一个重大改变。它们放宽了长期以来的要求,即如果存在构造函数调用,则它必须总是作为构造函数体中的第一条语句出现。这一要求深深嵌入到了代码分析器、样式检查器、语法高亮器、开发环境以及 Java 生态系统中的其他工具中。与任何语言变更一样,在工具更新期间可能会有一段阵痛期。
依赖关系
Java 语言中的灵活构造函数体取决于 JVM 能够验证和执行出现在构造函数调用之前的任意代码的能力,只要该代码不引用正在构造的实例即可。幸运的是,JVM 已经支持更灵活地处理构造函数体:
构造函数体内可以包含多个构造函数调用,前提是任何代码路径上只有一个调用;
在构造函数调用之前可以出现任意代码,只要该代码不引用正在构造的实例,除非是进行字段赋值;并且
显式的构造函数调用不得出现在
try
块内,即字节码异常范围之内。
JVM 的规则仍然确保了安全的对象初始化:
超类初始化总是恰好发生一次,无论是直接通过超类构造函数调用还是间接通过替代构造函数调用完成的;并且
直到超类初始化完成前,未初始化的实例除了字段赋值(字段赋值不影响结果)外是不可访问的。
因此,本提案不包括对 Java 虚拟机规范的任何更改,仅涉及 Java 语言规范的修订。
现存的 JVM 允许灵活的构造函数体与更为严格的 Java 语言之间的不匹配是一个历史遗留问题。最初,JVM 更加严格,但这导致了诸如内部类和捕获的自由变量等新语言特性编译器生成字段初始化的问题。为了适应编译器生成的代码,我们在许多年前就放宽了 JVM 规范,但我们从未修订 Java 语言规范以利用这种新的灵活性。