跳到主要内容

JEP 492: 灵活的构造函数体(第三次预览)

QWen Max 中英对照 JEP 492: Flexible Constructor Bodies (Third Preview)

概述

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

历史

此功能最初在 JDK 22 中通过 JEP 447 以不同的标题进行了预览。它在 JDK 23 中通过 JEP 482 再次进行了预览。我们在此提议第三次预览该功能,没有重大更改。

目标

  • 重新构想构造函数在对象初始化过程中的作用,使开发人员能够更自然地放置目前必须纳入辅助静态方法、辅助中间构造函数或构造函数参数中的逻辑。

  • 在构造函数体中引入两个不同的阶段:序言 包含在调用超类构造函数之前执行的代码,而 结语 则在超类构造函数被调用之后执行。

  • 保持现有的保证,即子类构造函数中的代码不能干扰超类实例化。

动机

类的构造函数负责创建该类的有效实例。例如,假设 Person 类的实例有一个 age 字段,其值永远不能为负。接受与年龄相关的参数(例如,出生日期)的构造函数必须验证该参数,并将其写入 age 字段,从而确保一个有效实例,或者抛出一个异常。

类的构造函数还负责确保在存在子类化的情况下保持有效性。例如,假设 EmployeePerson 的子类。每个 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
}
}

目前,通过 new D() 创建类 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 构造函数中,早期构造上下文中的代码不能使用 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 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 允许灵活的构造函数主体,而 Java 语言则更为严格)是一个历史遗留问题。最初,JVM 更为严格,但这导致了新语言特性(如内部类和捕获的自由变量)的编译器生成字段初始化的问题。为了适应编译器生成的代码,我们在多年前放宽了 JVM 规范,但我们从未修订 Java 语言规范以利用这种新的灵活性。