JEP 482: Flexible Constructor Bodies (Second Preview) | 灵活的构造函数体(第二次预览)
摘要
在 Java 编程语言的构造函数中,允许在显式构造函数调用(即 super(..)
或 this(..)
)之前出现语句。这些语句不能引用正在构造的实例,但它们可以初始化其字段。在调用另一个构造函数之前初始化字段可以使一个类在方法被重写时更加可靠。这是一个 预览语言特性。
历史
这个特性最初由 JEP 447 以不同的标题提出,并在 JDK 22 中作为预览特性交付。我们在这里提议再次预览它,有一个重大变化:
- 允许构造函数体在显式调用构造函数之前 初始化同一类中的字段。这使得子类中的构造函数能够确保超类中的构造函数永远不会执行看到子类中字段的 默认值(例如,
0
、false
或null
)的代码。当由于重写,超类构造函数调用子类中的使用该字段的方法时,可能会发生这种情况。
目标
给予开发人员更大的自由度来表达构造函数的行为,使得目前必须分解到辅助静态方法、辅助中间构造函数或构造函数参数中的逻辑能够更自然地放置。
保留现有的保证,即在类实例化期间构造函数按从上到下的顺序运行,确保子类构造函数中的代码不能干扰超类的实例化。
动机
一个类的构造函数负责创建该类的有效实例。例如,假设 Person
类的实例有一个 age
字段,其值必须始终小于 130。一个接受与年龄相关的参数(例如,出生日期)的构造函数必须验证它,并将其写入 age
字段,从而确保一个有效的实例,否则抛出异常。
此外,一个类的构造函数负责在存在子类化的情况下确保有效性。例如,假设 Employee
是 Person
的子类。每个 Employee
构造函数将隐式或显式地调用一个 Person
构造函数。共同作用下,构造函数必须确保一个有效的实例:Employee
构造函数负责 Employee
类中声明的字段,而 Person
构造函数负责 Person
类中声明的字段。由于 Employee
构造函数中的代码可能引用由 Person
构造函数初始化的字段,所以后者必须先运行。
一般来说,构造函数必须从上到下运行:超类中的构造函数必须先运行,确保该类中声明的字段的有效性,然后子类中的构造函数才能运行。
为了保证构造函数从上到下运行,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
参数,但必须将其转换为一个字节数组以供超类构造函数使用:
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); } // 将参数两次传递给 Super 的构造函数
public Sub(int i) { this(new C(i)); } // 为 Super 的构造函数准备参数
}
如果我们能够在构造函数体中安排共享,就不需要辅助构造函数,代码会更易于维护:
public class Sub extends Super {
public Sub(int i) {
var x = new C(i);
super(x, x);
}
}
摘要
在所有这些例子中,我们想要编写的构造函数体包含在显式构造函数调用之前不使用正在构造的实例的语句。不幸的是,构造函数体被编译器拒绝——即使它们都是安全的。
如果 Java 语言能够以更灵活的规则保证从上到下的构造,那么构造函数体将更容易编写和维护。构造函数体可以更自然地进行参数验证、参数准备和参数共享,而无需调用笨拙的辅助方法或构造函数。是时候超越自 Java 1.0 以来强制执行的简单语法要求,即 super(..)
或 this(..)
必须是构造函数体中的第一条语句了。
描述
我们修改了 构造函数体 的语法,以允许在显式构造函数调用之前出现语句,即从:
ConstructorBody:
{ [ExplicitConstructorInvocation] [BlockStatements] }
改为:
ConstructorBody:
{ [BlockStatements] ExplicitConstructorInvocation [BlockStatements] }
{ [BlockStatements] }
省略一些细节,显式构造函数调用要么是 super(..)
要么是 this(..)
。
在显式构造函数调用之前出现的语句构成构造函数体的 前言。
在显式构造函数调用之后出现的语句构成构造函数体的 尾声。
构造函数体中的显式构造函数调用可以省略。在这种情况下,前言为空,构造函数体中的所有语句构成尾声。
如果 return
语句不包含表达式,则在构造函数体的尾声中允许出现。也就是说,return;
是允许的,但 return e;
是不允许的。在构造函数体的前言中出现 return
语句是编译时错误。
在构造函数体的前言或尾声中抛出异常是允许的。在快速失败的场景中,在前言中抛出异常是常见的。
这是一个 预览语言特性,默认情况下是禁用的
要在 JDK 23 中尝试下面的例子,你必须启用预览特性:
使用
javac --release 23 --enable-preview Main.java
编译程序,并使用java --enable-preview Main
运行它;或者,当使用 源代码启动器 时,使用
java --enable-preview Main.java
运行程序;或者,当使用
jshell
时,使用jshell --enable-preview
启动它。
早期构造上下文
在 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
。这是因为在 Sub
构造函数体中的字段赋值之前,Super
构造函数被隐式调用。然后 Super
构造函数调用 overriddenMethod
,导致 Sub
中的那个方法在 Sub
构造函数体有机会将 42
赋值给字段之前运行。结果,Sub
中的方法看到了字段的默认值,即 0
。
这种模式是许多错误的根源。虽然这被认为是不好的编程实践,但它并不罕见,并且它给子类带来了难题——特别是当修改超类不是一个选择时。
我们通过允许 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 语言规范以利用这种新的灵活性。