跳到主要内容

JEP 482:灵活的构造函数主体(第二次预览)

QWen Max 中英对照 JEP 482: Flexible Constructor Bodies (Second Preview)

总结

在 Java 编程语言的构造函数中,允许在显式构造函数调用(即 super(..)this(..))之前出现语句。这些语句不能引用正在构造的实例,但可以初始化其字段。在调用另一个构造函数之前初始化字段,当方法被重写时,可以使类更加可靠。这是一个预览语言功能

历史

此功能最初由 JEP 447 以不同的标题提出,并在 JDK 22 中作为预览功能交付。我们在此建议对其进行第二次预览,其中包含一项重大更改:

  • 允许构造函数主体在显式调用构造函数之前初始化同一类中的字段。这使得子类中的构造函数能够确保父类中的构造函数永远不会执行看到子类中字段默认值的代码(例如,0falsenull)。当由于重写,父类构造函数调用了使用该字段的子类方法时,就会发生这种情况。

目标

  • 赋予开发者更大的自由来表达构造函数的行为,使得当前必须分解到辅助静态方法、辅助中间构造函数或构造函数参数中的逻辑能够更自然地放置。

  • 保留现有的在类实例化期间构造函数自上而下运行的保证,确保子类构造函数中的代码不会干扰超类的实例化。

动机

类的构造函数负责创建该类的有效实例。例如,假设 Person 类的实例具有一个 age 字段,其值必须始终小于 130。接受与年龄相关参数(例如出生日期)的构造函数必须对其进行验证,并将其写入 age 字段以确保有效实例,否则抛出异常。

此外,类的构造函数还负责在存在子类的情况下确保有效性。例如,假设 EmployeePerson 的子类。每个 Employee 构造函数都会隐式或显式地调用 Person 构造函数。这些构造函数共同协作,必须确保生成一个有效的实例:Employee 构造函数负责 Employee 类中声明的字段,而 Person 构造函数负责 Person 类中声明的字段。由于 Employee 构造函数中的代码可能会引用由 Person 构造函数初始化的字段,因此后者必须首先运行。

因此,总的来说,构造函数必须自上而下运行:超类中的构造函数必须先运行,以确保该类中声明的字段的有效性,然后子类中的构造函数才能运行。

为了保证构造函数从上到下执行,Java 语言要求在构造函数体中,第一条语句必须是显式调用另一个构造函数explicit invocation of another constructor),即 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 参数,但必须将其转换为超类构造函数所需的字节数组:

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(..) 必须是构造函数主体中的第一条语句。

描述

我们修改了构造函数体的语法,允许在显式构造函数调用之前出现语句,即从:

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); // 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 构造函数中,早期构造上下文中的代码不能使用 new Inner() 实例化 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 构造函数在显式调用 Super 构造函数之前初始化 Sub 中的字段来解决这个难题。可以将该示例重写如下,其中仅更改了 Sub 类:

class Sub extends Super {
final int y = 1; // Initialize y in Sub
Sub() {
super(); // Explicitly invoke the Super constructor
}
}
java
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,因为在调用 overriddenMethod 之前,Sub 中的字段已被赋值为 42

在构造函数主体中,如果字段声明中没有初始化器,则在同一类中声明的字段在早期构造上下文中允许进行简单赋值。这意味着构造函数主体可以在早期构造上下文中初始化该类自己的字段,但不能初始化超类的字段。

如前所述,在显式构造函数调用之前,构造函数主体无法读取当前实例的任何字段 —— 无论这些字段是在与构造函数相同的类中声明的,还是在超类中声明的 —— 即在尾声(epilogue)部分之前。

记录

记录类的构造函数 已经比普通类的构造函数受到更多的限制。特别是,

  • 规范的记录构造函数不得包含任何显式的构造函数调用,并且

  • 非规范的记录构造函数必须包含另一个构造函数调用(this(..)),而不能是超类构造函数调用(super(..))。

这些限制依然存在。否则,记录构造函数将受益于上述更改,主要是因为非规范的记录构造函数将能够在调用替代构造函数之前包含语句。

枚举

枚举类的构造函数 可以包含替代构造函数调用,但不能包含超类构造函数调用。枚举类将从上述变更中受益,主要原因是它们的构造函数将能够在替代构造函数调用之前包含语句。

测试

  • 我们将使用现有的单元测试来测试编译器的更改,除了那些验证已更改行为的测试外,其余测试保持不变,并根据需要增加新的正面和负面测试用例。

  • 我们将使用旧版本和新版本的编译器编译所有的 JDK 类,并验证生成的字节码是否相同。

  • 不需要进行特定平台的测试。

风险与假设

我们上面建议的更改是源代码和行为兼容的。它们严格扩展了合法 Java 程序的集合,同时保留了所有现有 Java 程序的意义。

这些变化本身虽然不大,但却代表着对长期以来的要求——即构造函数调用(如果存在)必须始终作为构造函数主体中的第一条语句出现——的重大改变。这一要求深深植根于代码分析器、风格检查器、语法高亮器、开发环境以及 Java 生态系统中的其他工具中。与任何语言变更一样,工具更新时可能会有一段痛苦的适应期。

依赖

Java 语言中灵活的构造函数主体依赖于 JVM 的能力,即验证和执行构造函数中出现在构造函数调用之前的任意代码,只要这些代码不引用正在构造的实例即可。幸运的是,JVM 已经支持对构造函数主体进行更灵活的处理:

  • 构造函数体中可以出现多次构造函数调用,但前提是任何代码路径上必须恰好有一次调用;

  • 在构造函数调用之前可以出现任意代码,但这些代码不能引用正在构建的实例,除非是为了赋值字段;以及

  • 显式的构造函数调用不得出现在 try 块内,即不得出现在字节码异常范围内。

JVM 的规则仍然确保自上而下的初始化:

  • 超类初始化总是只发生一次,要么通过超类构造函数调用直接进行,要么通过另一个构造函数调用间接进行;并且

  • 在超类初始化完成之前,未初始化的实例是不可访问的,除了字段赋值之外,但这不会影响结果。

因此,我们不需要对《Java 虚拟机规范》进行任何更改,只需要对《Java 语言规范》进行更改即可。

JVM 允许灵活的构造函数主体,而传统的 Java 语言则更加严格,这两者之间的不匹配是一种历史产物。最初,JVM 也有较多限制,但这导致了为内部类和捕获的自由变量等新语言特性初始化编译器生成字段时出现问题。为了适应编译器生成的代码,我们多年前放宽了 JVM 规范,但从未修订 Java 语言规范以利用这种新的灵活性。