跳到主要内容

JEP 447:super(...) 之前的语句(预览)

概括

在 Java 编程语言的构造函数中,允许不引用正在创建的实例的语句出现在显式构造函数调用之前。这是预览语言功能

目标

  • 为开发人员提供了更大的自由来表达构造函数的行为,从而可以更自然地放置目前必须纳入辅助静态方法、辅助中间构造函数或构造函数参数中的逻辑。

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

  • 不需要对 Java 虚拟机进行任何更改。此 Java 语言功能仅依赖于 JVM 验证和执行构造函数中显式构造函数调用之前出现的代码的当前能力。

动机

当一个类扩展另一个类时,子类从超类继承功能,并且可以通过声明自己的字段和方法来添加功能。子类中声明的字段的初始值可以取决于超类中声明的字段的初始值,因此首先初始化超类的字段,然后再初始化子类的字段是至关重要的。例如,如果 classB扩展 class A,则必须首先初始化未见过的类的字段Object,然后是 class 的字段A,然后是 class 的字段B

按此顺序初始化字段意味着构造函数必须从上到下运行:超类中的构造函数必须在子类中的构造函数运行之前完成对该类中声明的字段的初始化。这就是对象的整体状态的初始化方式。

确保类的字段在初始化之前不会被访问也很重要。防止访问未初始化的字段意味着必须限制构造函数:在超类中的构造函数完成之前,构造函数的主体不得访问在其自己的类或任何超类中声明的字段。

为了保证构造函数从上到下运行,Java 语言要求在构造函数主体中,对另一个构造函数的任何显式调用都必须出现在第一条语句中;如果没有给出显式构造函数调用,则由编译器注入。

为了保证构造函数不会访问未初始化的字段,Java 语言要求如果给出显式构造函数调用,则其任何参数都不能this以任何方式访问当前对象 。

这些要求保证了自上而下的行为和初始化前禁止访问,但它们很严厉,因为它们使得普通方法中使用的一些习惯用法很难甚至不可能在构造函数中使用。下面的例子说明了这些问题。

示例:验证超类构造函数参数

有时我们需要验证传递给超类构造函数的参数。我们可以在事后验证论点,但这意味着可能会做不必要的工作:

public class PositiveBigInteger extends BigInteger {

public PositiveBigInteger(long value) {
super(value); // Potentially unnecessary work
if (value <= 0)
throw new IllegalArgumentException("non-positive value");
}

}

最好通过在调用超类构造函数之前验证其参数来声明一个快速失败的构造函数。今天我们只能使用辅助方法在线执行此操作static

public class PositiveBigInteger extends BigInteger {

public PositiveBigInteger(long value) {
super(verifyPositive(value));
}

private static long verifyPositive(long value) {
if (value <= 0)
throw new IllegalArgumentException("non-positive value");
return value;
}

}

如果我们可以将验证逻辑直接包含在构造函数中,则此代码将更具可读性。我们想写的是:

public class PositiveBigInteger extends BigInteger {

public PositiveBigInteger(long value) {
if (value <= 0)
throw new IllegalArgumentException("non-positive value");
super(value);
}

}

示例:准备超类构造函数参数

有时我们必须执行重要的计算,以便为超类构造函数准备参数,再次求助于辅助方法:

public class Sub extends Super {

public Sub(Certificate certificate) {
super(prepareByteArray(certificate));
}

// Auxiliary method
private static byte[] prepareByteArray(Certificate certificate) {
var publicKey = certificate.getPublicKey();
if (publicKey == null)
throw new IllegalArgumentException("null certificate");
return switch (publicKey) {
case RSAKey rsaKey -> ...
case DSAPublicKey dsaKey -> ...
...
default -> ...
};
}

}

超类构造函数接受一个byte数组参数,但子类构造函数接受一个Certificate参数。为了满足超类构造函数调用必须是子类构造函数中的第一个语句的限制,我们声明辅助方法prepareByteArray来为该调用准备参数。

如果我们可以将参数准备代码直接嵌入构造函数中,则该代码将更具可读性。我们想写的是:

public Sub(Certificate certificate) {
var publicKey = certificate.getPublicKey();
if (publicKey == null)
throw new IllegalArgumentException("null certificate");
final byte[] byteArray = switch (publicKey) {
case RSAKey rsaKey -> ...
case DSAPublicKey dsaKey -> ...
...
default -> ...
};
super(byteArray);
}

示例:共享超类构造函数参数

有时我们需要计算一个值并在超类构造函数调用的参数之间共享它。构造函数调用首先出现的要求意味着实现这种共享的唯一方法是通过中间辅助构造函数:

public class Super {

public Super(F f1, F f2) {
...
}

}

public class Sub extends Super {

// Auxiliary constructor
private Sub(int i, F f) {
super(f, f); // f is shared here
... i ...
}

public Sub(int i) {
this(i, new F());
}

}

在公共Sub构造函数中,我们想要创建一个类的新实例F,并将对该实例的两个引用传递给超类构造函数。我们通过声明一个辅助私有构造函数来做到这一点。

我们想要编写的代码直接在构造函数中进行复制,从而不需要辅助构造函数:

public Sub(int i) {
var f = new F();
super(f, f);
... i ...
}

概括

在所有这些示例中,我们要编写的构造函数代码包含显式构造函数调用之前的语句,但在this超类构造函数完成之前不会访问任何字段。如今,这些构造函数被编译器拒绝,尽管它们都是安全的:它们自上而下地合作运行构造函数,并且它们不访问未初始化的字段。

如果Java语言能够以更灵活的规则保证自上而下的构造和初始化前禁止访问,那么代码将更容易编写和维护。构造函数可以更自然地进行参数验证、参数准备和参数共享,而无需通过笨拙的辅助方法或构造函数来完成这些工作。我们需要超越自 Java 1.0 以来强制执行的简单化语法要求,即“ super(..)orthis(..)必须是第一个语句”、“不使用this”等等。

描述

我们将构造函数体的语法(JLS §8.8.7)修改为:

ConstructorBody:
{ [BlockStatements] }
{ [BlockStatements] ExplicitConstructorInvocation [BlockStatements] }

显式构造函数调用之前出现的块语句构成了构造函数主体的_序言_。构造函数体中没有显式构造函数调用的语句以及显式构造函数调用之后的语句构成了_尾声_。

施工前环境

至于语义,Java 语言规范将构造函数体中显式构造函数调用的参数列表中出现的代码分类为静态上下文_(_ JLS §8.1.3)。这意味着此类构造函数调用的参数将被视为位于static方法中;换句话说,就好像没有可用的实例一样。然而,静态上下文的技术限制比必要的要强,它们会阻止有用且安全的代码作为构造函数参数出现。

我们没有修改静态上下文的概念,而是定义了一个新的、严格较弱的_预构造上下文_概念,以涵盖显式构造函数调用的参数以及在其之前发生的任何语句。在预构造上下文中,规则与普通实例方法类似,只是代码不能访问正在构造的实例。

事实证明,确定什么有资格访问正在构建的实例是非常棘手的。让我们考虑一些例子。

从一个简单的例子开始,this在预构造上下文中不允许使用任何不合格的表达式:

class A {

int i;

A() {
this.i++; // Error
this.hashCode(); // Error
System.out.print(this); // Error
super();
}

}

super出于类似的原因,在预构造上下文中不允许任何由以下条件限定的字段访问、方法调用或方法引用:

class D {
int i;
}

class E extends D {

E() {
super.i++; // Error
super();
}

}

在更棘手的情况下,非法访问不需要包含thisorsuper关键字:

class A {

int i;

A() {
i++; // Error
hashCode(); // Error
super();
}

}

更令人困惑的是,有时涉及的表达式this并不引用当前实例,而是引用内部类的封闭实例:

class B {

int b;

class C {

int c;

C() {
B.this.b++; // Allowed - enclosing instance
C.this.c++; // Error - same instance
super();
}

}

}

内部类的语义也使非限定方法调用变得复杂:

class Outer {

void hello() {
System.out.println("Hello");
}

class Inner {

Inner() {
hello(); // Allowed - enclosing instance method
super();
}

}

}

允许hello()出现在构造函数的预构造上下文中的调用,因为它引用的是封闭实例(在本例中,其类型为),而不是正在构造的实例( JLS §8.8.1) 。Inner``Inner``Outer``Inner

在前面的示例中,Outer封闭实例已构造完毕,因此可以访问,而Inner实例正在构造中,因此不可访问。相反的情况也是可能的:

class Outer {

class Inner {
}

Outer() {
new Inner(); // Error - 'this' is enclosing instance
super();
}

}

该表达式new Inner()是非法的,因为它需要为Inner构造函数提供一个封闭的实例Outer,但所提供的实例Outer仍在构造中,因此无法访问。

类似地,在预构造上下文中,声明匿名类的类实例创建表达式不能将新创建的对象作为隐式封闭实例:

class X {

class S {
}

X() {
var tmp = new S() { }; // Error
super();
}

}

这里声明的匿名类是 的子类S,它是 的内部类X。这意味着匿名类也将具有 的封闭实例X,因此类实例创建表达式会将新创建的对象作为隐式封闭实例。同样,由于这发生在预构造上下文中,因此会导致编译时错误。如果该类S被声明static,或者它是一个接口而不是一个类,那么它将没有封闭实例,并且不会出现编译时错误。

相比之下,这个例子是允许的:

class O {

class S {
}

class U {

U() {
var tmp = new S() { }; // Allowed
super();
}

}

}

这里,类实例创建表达式的封闭实例不是新创建的U对象,而是词法封闭O实例。

return如果语句不包含表达式(即return;允许,但return e;不允许),则可以在构造函数主体的尾声中使用该语句。如果return语句出现在构造函数主体的序言中,则会出现编译时错误。

允许在构造函数主体的序言中抛出异常。事实上,这在快速失败场景中很常见。

与静态上下文不同,预构造上下文中的代码可以引用正在构造的实例的类型,只要它不访问实例本身:

class A<T> extends B {

A() {
super(this); // Error - refers to 'this'
}

A(List<?> list) {
super((T)list.get(0)); // Allowed - refers to 'T' but not 'this'
}

}

记录

记录类构造函数已经比普通构造函数受到更多限制(JLS §8.10.4)。尤其:

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

  • 非规范记录构造函数必须调用替代构造函数(this(...)调用),并且不能调用超类构造函数(super(...)调用)。

这些限制仍然存在,但记录构造函数将从上述更改中受益,主要是因为非规范记录构造函数将能够在显式替代构造函数调用之前包含语句。

枚举

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

测试

我们将使用现有的单元测试来测试编译器的更改,除了那些验证更改行为的测试之外,保持不变,并酌情添加新的正面和负面测试用例。

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

不需要特定于平台的测试。

风险和假设

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

这些更改虽然本身并不多,但代表了对构造函数调用(如果存在)必须始终作为构造函数主体中的第一个语句这一长期要求的重大更改。这一要求深深嵌入到 Java 生态系统中的代码分析器、样式检查器、语法突出显示器、开发环境和其他工具中。与任何语言的变化一样,随着工具的更新,可能会经历一段痛苦的时期。

依赖关系

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

  • 多个构造函数调用可能出现在任何代码路径上提供的构造函数中,只有一次调用;

  • 任意代码可以出现在构造函数调用之前,只要该代码除了分配字段之外不引用正在构造的实例;和

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

这些更宽松的规则仍然确保自上而下的初始化:

  • 超类初始化总是只发生一次,要么直接通过超类构造函数调用,要么间接通过备用构造函数调用;和

  • 在超类初始化完成之前,未初始化的实例是禁止的,但字段分配除外,这不会影响结果。

换句话说,我们不需要对 Java 虚拟机规范进行任何更改。

当前 JVM 和语言之间的不匹配是历史造成的。最初,JVM 的限制更为严格,但这导致了新语言功能(例如内部类和捕获的自由变量)的编译器生成字段的初始化问题。因此,规范被放宽以适应编译器生成的代码,但这种新的灵活性从未回到该语言中。