JEP 492:灵活的构造函数主体(第三次预览)
概括
在 Java 编程语言的构造函数中,允许语句出现在显式构造函数调用之前,即super(..)
或this(..)
。这些语句不能引用正在构造的实例,但可以初始化其字段。在调用另一个构造函数之前初始化字段可以使类在方法被重写时更加可靠。这是预览语言功能。
历史
该功能首次在 JDK 22 中通过JEP 447进行了预览,但标题不同。它第二次在 JDK 23 中通过JEP 482进行了预览。我们在此建议对其进行第三次预览,但不会进行重大更改。
目标
-
重新想象构造函数在对象初始化过程中的作用,使开发人员能够更自然地将他们当前必须考虑的逻辑放入辅助静态方法、辅助中间构造函数或构造函数参数中。
-
在构造函数主体中引入两个不同的阶段:_序言_包含在调用超类构造函数之前执行的代码,而_结语_在调用超类构造函数之后执行。
-
保留现有的保证,即子类构造函数中的代码不会干扰超类的实例。
动机
类的构造函数负责创建该类的有效实例。例如,假设类的实例Person
有一个age
字段,该字段的值绝不能为负数。采用与年龄相关的参数(例如出生日期)的构造函数必须验证该参数,并将其写入该age
字段,从而确保实例有效,否则将引发异常。
此外,类的构造函数还负责确保在存在子类的情况下的有效性。例如,假设Employee
是的子类Person
。每个Employee
构造函数都会以隐式或显式的方式调用Person
构造函数。两个构造函数必须协同工作以确保实例有效:Employee
构造函数负责Employee
类中声明的字段,而Person
构造函数负责类中声明的字段Person
。由于构造函数中的代码Employee
可以引用类中声明的字段,因此在允许构造函数访问这些字段Person
之前,确保正确初始化这些字段非常重要。Employee
Java 编程语言使用一种简单的解决方案来提供这种保证,即要求构造函数必须从上到下运行:超类中的构造函数必须先运行,以确保在超类中声明的字段的有效性,然后子类中的构造函数才能运行。在上例中,Person
构造函数必须在构造函数之前完整运行Employee
,以保证构造Employee
函数始终看到有效的age
字段。
为了保证构造函数从上向下运行,Java 语言要求构造函数体中的第一个语句是另一个构造函数的显式调用,即super(..)
或this(..)
。如果构造函数体中没有出现显式的构造函数调用,则编译器会将其插入super()
为构造函数体中的第一个语句。
该语言进一步要求,对于任何显式的构造函数调用,其任何参数都不能以任何方式使用正在构造的实例。
这两个要求保证了新实例构建过程中的可预测性和卫生性。然而,它们过于严格,因为它们禁止了某些常见的编程模式,如以下示例所示。
示例:验证超类构造函数参数
有时我们需要验证传递给超类构造函数的参数。我们可以在调用超类构造函数后验证该参数,但这意味着可能会做不必要的工作:
public class PositiveBigInteger extends BigInteger {
public PositiveBigInteger(long value) {
super(value); // Potentially unnecessary work
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); } // Pass the argument twice to Super's constructor
public Sub(int i) { this(new C(i)); } // Prepare the argument for Super's constructor
}
如果我们可以在构造函数主体中安排共享,从而无需辅助构造函数,则代码将更易于维护:
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 constructor body
}
}
class A extends Object {
A() {
super();
// A constructor body
}
}
class B extends A {
B() {
super();
// B constructor body
}
}
class C extends B {
C() {
super();
// C constructor body
}
}
class D extends C {
D() {
super();
// D constructor body
}
}
目前,当通过 创建类 D 的新实例时,new D()
构造函数主体的执行可以形象地表示为:
D
--> C
--> B
--> A
--> Object constructor body
--> A constructor body
--> B constructor body
--> C constructor body
D constructor body
这就是为什么 Java 语言当前的安全对象初始化方法具有自上而下的特点:构造函数主体从层次结构的顶部开始运行,从类开始Object
,然后逐个向下移动到子类。
当构造函数体同时具有序言和结语时,我们可以概括类声明:
class Object {
Object() {
// Object constructor body
}
}
class A extends Object {
A() {
// A prologue
super();
// A epilogue
}
}
class B extends A {
B() {
// B prologue
super();
// B epilogue
}
}
class C extends B {
C() {
// C prologue
super();
// C epilogue
}
}
class D extends C {
D() {
// D prologue
super();
// D epilogue
}
}
构造函数主体在评估时的相应执行new D()
可以形象地表示为:
D prologue
--> C prologue
--> B prologue
--> A prologue
--> Object constructor body
--> A epilogue
--> B epilogue
--> C epilogue
D epilogue
这种新方法不是自上而下运行构造函数主体,而是首先自下而上运行序言,然后自上而下运行结语。
这是一个预览语言功能,默认情况下禁用
要在 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); // Error - refers to the current instance
var x = this.i; // Error - explicitly refers to field of the current instance
this.hashCode(); // Error - explicitly refers to method of the current instance
var x = i; // Error - implicitly refers to field of the current instance
hashCode(); // Error - implicitly refers to method of the current instance
super();
}
}
super
类似地,在早期构造上下文中不允许使用限定的任何字段访问、方法调用或方法引用:
class B {
int i;
void m() { ... }
}
class C extends B {
C() {
var x = super.i; // Error
super.m(); // Error
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; // OK - implicitly refers to field of enclosing instance
var y = Outer.this.i; // OK - explicitly refers to field of enclosing instance
hello(); // OK - implicitly refers to method of enclosing instance
Outer.this.hello(); // OK - explicitly refers to method of enclosing instance
super();
}
}
}
相比之下,在Outer
下面显示的构造函数中,早期构造上下文中的代码无法使用 实例化类Inner
。new Inner()
此表达式实际上是this.new Inner()
,这意味着它使用 的当前实例Outer
作为对象的封闭实例。根据之前的规则,在早期构造上下文中不允许以Inner
任何显式或隐式的方式使用来引用当前实例。this
class Outer {
class Inner {}
Outer() {
var x = new Inner(); // Error - implicitly refers to the current instance of Outer
var y = this.new Inner(); // Error - explicitly refers to the current instance of 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(); */ // Implicit invocation
this.x = x;
}
@Override
void overriddenMethod() { System.out.println(x); }
}
打印什么new Sub(42)
?您可能希望它打印42
,但实际上它打印 0
。这是因为Super
构造函数在构造函数主体中的字段赋值之前被隐式调用Sub
。Super
然后构造函数调用overriddenMethod
,导致 中的方法在构造函数主体有机会赋值给字段Sub
之前运行。因此, 中的方法看到字段的默认值,即。Sub``42``Sub``0
这种模式是许多错误和漏洞的根源。虽然这被认为是不好的编程习惯,但这种情况并不少见,而且它给子类带来了难题 — — 尤其是在无法修改超类的情况下。
我们通过允许构造函数在显式调用构造函数之前Sub
初始化字段来解决这个难题。该示例可以重写如下,其中只更改了类:Sub``Super``Sub
class Super {
Super() { overriddenMethod(); }
void overriddenMethod() { System.out.println("hello"); }
}
class Sub extends Super {
final int x;
Sub(int x) {
this.x = x; // Initialize the field
super(); // Then invoke the Super constructor explicitly
}
@Override
void overriddenMethod() { System.out.println(x); }
}
现在,new Sub(42)
将打印42
,因为中的字段在被调用之前Sub
被分配。42``overriddenMethod
在构造函数主体中,允许在早期构造上下文中对同一类中声明的字段进行简单赋值,前提是该字段声明缺少初始化器。这意味着构造函数主体可以在早期构造上下文中初始化类自己的字段,但不能初始化超类的字段。同样,这确保了对象初始化的安全。
如前所述,构造函数主体不能读取当前实例的任何字段 - 无论是在与构造函数相同的类中声明,还是在超类中声明 - 直到显式构造函数调用之后,即在结尾处。
记录
记录类的构造函数已经比普通类的构造函数受到更多限制。特别是,
-
规范记录构造函数不得包含任何显式构造函数调用,并且
-
非规范记录构造函数必须包含替代构造函数调用(
this(..)
),而不是超类构造函数调用(super(..)
)。
这些限制仍然存在。否则,记录构造函数将受益于上述更改,主要是因为非规范记录构造函数将能够在替代构造函数调用之前包含语句。
枚举
枚举类的构造函数可以包含替代构造函数调用,但不能包含超类构造函数调用。枚举类将从上述更改中受益,主要是因为它们的构造函数将能够在替代构造函数调用之前包含语句。
测试
-
我们将使用现有的单元测试来测试编译器的变化,除了验证改变的行为的测试外,其他都保持不变,并根据需要添加新的正面和负面测试用例。
-
我们将使用以前版本和新版本的编译器编译所有 JDK 类,并验证生成的字节码是否相同。
-
不需要进行特定于平台的测试。
风险和假设
我们上面提出的修改是源和行为兼容的。它们严格扩展了合法 Java 程序集,同时保留了所有现有 Java 程序的含义。
这些变化虽然本身并不大,但却代表了构造函数参与安全对象初始化方式的重大变化。它们放宽了长期以来的要求,即构造函数调用(如果存在)必须始终作为构造函数主体中的第一个语句出现。此要求深深植根于 Java 生态系统中的代码分析器、样式检查器、语法高亮器、开发环境和其他工具中。与任何语言变化一样,工具更新时可能会有一段痛苦的时期。
依赖项
Java 语言中灵活的构造函数主体取决于 JVM 验证和执行构造函数中构造函数调用之前出现的任意代码的能力,只要该代码不引用正在构造的实例。幸运的是,JVM 已经支持更灵活的构造函数主体处理方式:
-
构造函数主体中可以出现多个构造函数调用,但任何代码路径上都恰好有一次调用;
-
任意代码都可以出现在构造函数调用之前,只要该代码除了分配字段外不引用正在构造的实例即可;并且
-
显式的构造函数调用可能不会出现在
try
块内,即字节码异常范围内。
JVM 的规则仍然确保安全的对象初始化:
-
超类初始化总是只发生一次,要么直接通过超类构造函数调用,要么间接通过备用构造函数调用;并且
-
在超类初始化完成之前,未初始化的实例是禁止的,除了字段分配之外,这不会影响结果。
因此,该提案不包括对 Java 虚拟机规范的任何更改,仅包括对 Java 语言规范的任何更改。
JVM 允许灵活的构造函数主体,而 Java 语言则更加严格,两者之间存在的不匹配是历史遗留问题。最初,JVM 的限制性更强,但这导致编译器生成的字段在初始化新语言功能(例如内部类和捕获的自由变量)时出现问题。为了适应编译器生成的代码,我们放宽了 JVM 规范,但从未修改过 Java 语言规范来利用这种新的灵活性。