JEP 447: Statements before super(...) (Preview) | super(...) 之前声明语句(预览)
摘要
在 Java 编程语言的构造函数中,允许不引用正在创建的实例的语句出现在显式构造函数调用之前。这是一个 预览语言特性。
目标
给予开发人员更大的自由度来表达构造函数的行为,使得目前必须分解到辅助静态方法、辅助中间构造函数或构造函数参数中的逻辑能够更自然地放置。
保留现有的保证,即在类实例化期间构造函数以自上而下的顺序运行,确保子类构造函数中的代码不能干扰超类的实例化。
不需要对 Java 虚拟机进行任何更改。这个 Java 语言特性仅依赖于 JVM 当前验证和执行构造函数中显式构造函数调用之前的代码的能力。
动机
当一个类扩展另一个类时,子类从超类继承功能,并可以通过声明自己的字段和方法来添加功能。子类中声明的字段的初始值可能依赖于超类中声明的字段的初始值,因此在子类的字段之前初始化超类的字段是至关重要的。例如,如果类 B
扩展类 A
,那么首先必须初始化不可见的类 Object
的字段,然后是类 A
的字段,最后是类 B
的字段。
以这种顺序初始化字段意味着构造函数必须自上而下运行:超类中的构造函数必须在子类的构造函数运行之前完成对该类中声明的字段的初始化。这就是对象的整体状态被初始化的方式。
同样重要的是要确保在字段被初始化之前不能访问它们。防止访问未初始化的字段意味着构造函数必须受到约束:构造函数的主体在超类的构造函数完成之前不能访问其自身类或任何超类中声明的字段。
为了保证构造函数自上而下运行,Java 语言要求在构造函数体中,任何对另一个构造函数的显式调用必须作为第一条语句出现;如果没有给出显式的构造函数调用,那么编译器会注入一个。
为了保证构造函数不会访问未初始化的字段,Java 语言要求如果给出了显式的构造函数调用,那么它的任何参数都不能以任何方式访问当前对象 this
。
这些要求保证了自上而下的行为和在初始化之前不能访问,但它们过于严格,因为它们使得在普通方法中使用的一些惯用法在构造函数中难以甚至不可能使用。以下示例说明了这些问题。
示例:验证超类构造函数参数
有时我们需要验证传递给超类构造函数的参数。我们可以在事后验证参数,但这意味着可能会做不必要的工作:
public class PositiveBigInteger extends BigInteger {
public PositiveBigInteger(long value) {
super(value); // 可能是不必要的工作
if (value <= 0)
throw new IllegalArgumentException("非正数值");
}
}
2
3
4
5
6
7
8
9
通过在调用超类构造函数之前验证其参数来声明一个快速失败的构造函数会更好。目前我们只能使用辅助静态方法内联地做到这一点:
public class PositiveBigInteger extends BigInteger {
public PositiveBigInteger(long value) {
super(verifyPositive(value));
}
private static long verifyPositive(long value) {
if (value <= 0)
throw new IllegalArgumentException("非正数值");
return value;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
如果我们能直接在构造函数中包含验证逻辑,这段代码会更易读。我们想要写的是:
public class PositiveBigInteger extends BigInteger {
public PositiveBigInteger(long value) {
if (value <= 0)
throw new IllegalArgumentException("非正数值");
super(value);
}
}
2
3
4
5
6
7
8
9
示例:准备超类构造函数参数
有时我们必须执行非平凡的计算来为超类构造函数准备参数,再次求助于辅助方法:
public class Sub extends Super {
public Sub(Certificate certificate) {
super(prepareByteArray(certificate));
}
// 辅助方法
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 ->...
};
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
超类构造函数接受一个字节数组参数,但子类构造函数接受一个 Certificate
参数。为了满足超类构造函数调用必须是子类构造函数中的第一条语句的限制,我们声明辅助方法 prepareByteArray
来为该调用准备参数。
如果我们能直接在构造函数中嵌入参数准备代码,这段代码会更易读。我们想要写的是:
public Sub(Certificate certificate) {
var publicKey = certificate.getPublicKey();
if (publicKey == null)
throw new IllegalArgumentException("证书为空");
final byte[] byteArray = switch (publicKey) {
case RSAKey rsaKey ->...
case DSAPublicKey dsaKey ->...
...
default ->...
};
super(byteArray);
}
2
3
4
5
6
7
8
9
10
11
12
示例:共享超类构造函数参数
有时我们需要计算一个值并在超类构造函数调用的参数之间共享它。构造函数调用必须首先出现的要求意味着实现这种共享的唯一方法是通过一个中间辅助构造函数:
public class Super {
public Super(F f1, F f2) {
...
}
}
public class Sub extends Super {
// 辅助构造函数
private Sub(int i, F f) {
super(f, f); // 这里共享 f
... i...
}
public Sub(int i) {
this(i, new F());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
在公共的 Sub
构造函数中,我们想要创建一个类 F
的新实例,并将对该实例的两个引用传递给超类构造函数。我们通过声明一个辅助私有构造函数来实现这一点。
我们想要写的代码直接在构造函数中进行复制,从而消除了对辅助构造函数的需求:
public Sub(int i) {
var f = new F();
super(f, f);
... i...
}
2
3
4
5
摘要
在所有这些例子中,我们想要编写的构造函数代码在显式构造函数调用之前包含语句,但在超类构造函数完成之前不会通过 this
访问任何字段。如今,这些构造函数被编译器拒绝,即使它们都是安全的:它们在自上而下运行构造函数方面进行协作,并且在访问未初始化的字段之前不会进行访问。
如果 Java 语言能够以更灵活的规则保证自上而下的构造和在初始化之前不进行访问,那么代码将更容易编写和维护。构造函数可以更自然地进行参数验证、参数准备和参数共享,而无需通过笨拙的辅助方法或构造函数来完成这些工作。我们需要超越自 Java 1.0 以来强制执行的简单语法要求,即“super(..)
或 this(..)
必须是第一条语句”、“不使用 this
”等等。
描述
我们修改构造函数体的语法(JLS §8.8.7)如下:
ConstructorBody:
{ [BlockStatements] }
{ [BlockStatements] ExplicitConstructorInvocation [BlockStatements] }
2
3
出现在显式构造函数调用之前的块语句构成构造函数体的 前言。没有显式构造函数调用的构造函数体中的语句以及显式构造函数调用之后的语句构成 结语。
预构造上下文
至于语义,Java 语言规范将构造函数体中显式构造函数调用的参数列表中的代码分类为处于 静态上下文(JLS §8.1.3)。这意味着这种构造函数调用的参数被视为好像它们在一个 static
方法中;换句话说,就好像没有实例可用。然而,静态上下文的技术限制比必要的更强,它们阻止了有用且安全的代码作为构造函数参数出现。
我们不是修改静态上下文的概念,而是定义一个新的、严格更弱的 预构造上下文 概念,以涵盖显式构造函数调用的参数以及在其之前出现的任何语句。在预构造上下文中,规则与普通实例方法类似,除了代码不能访问正在构造的实例。
确定什么算作访问正在构造的实例结果出人意料地棘手。让我们考虑一些例子。
从一个简单的例子开始,在预构造上下文中不允许任何未限定的 this
表达式:
class A {
int i;
A() {
this.i++; // 错误
this.hashCode(); // 错误
System.out.print(this); // 错误
super();
}
}
2
3
4
5
6
7
8
9
10
11
12
出于类似的原因,在预构造上下文中不允许任何由 super
限定的字段访问、方法调用或方法引用:
class D {
int i;
}
class E extends D {
E() {
super.i++; // 错误
super();
}
}
2
3
4
5
6
7
8
9
10
11
12
在更棘手的情况下,非法访问不需要包含 this
或 super
关键字:
class A {
int i;
A() {
i++; // 错误
hashCode(); // 错误
super();
}
}
2
3
4
5
6
7
8
9
10
11
更令人困惑的是,有时涉及 this
的表达式不是指当前实例,而是指内部类的封闭实例:
class B {
int b;
class C {
int c;
C() {
B.this.b++; // 允许 - 封闭实例
C.this.c++; // 错误 - 同一实例
super();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
未限定的方法调用也因内部类的语义而变得复杂:
class Outer {
void hello() {
System.out.println("Hello");
}
class Inner {
Inner() {
hello(); // 允许 - 封闭实例方法
super();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
出现在 Inner
构造函数的预构造上下文中的调用 hello()
是允许的,因为它指的是 Inner
的封闭实例(在这种情况下,类型为 Outer
),而不是正在构造的 Inner
实例(JLS §8.8.1)。
在前面的例子中,封闭的 Outer
实例已经构造完成,因此可以访问,而 Inner
实例正在构造中,因此不可访问。相反的情况也是可能的:
class Outer {
class Inner {
}
Outer() {
new Inner(); // 错误 - 'this'是封闭实例
super();
}
}
2
3
4
5
6
7
8
9
10
11
表达式 new Inner()
是非法的,因为它需要为 Inner
构造函数提供一个 Outer
的封闭实例,但将要提供的 Outer
实例仍在构造中,因此不可访问。
类似地,在预构造上下文中,声明匿名类的类实例创建表达式不能将新创建的对象作为隐式封闭实例:
class X {
class S {
}
X() {
var tmp = new S() { }; // 错误
super();
}
}
2
3
4
5
6
7
8
9
10
11
这里声明的匿名类是 S
的子类,S
是 X
的内部类。这意味着匿名类也将有一个 X
的封闭实例,因此类实例创建表达式将新创建的对象作为隐式封闭实例。同样,由于这发生在预构造上下文中,因此会导致编译时错误。如果类 S
被声明为 static
,或者如果它是一个接口而不是一个类,那么它将没有封闭实例,并且不会有编译时错误。
相比之下,这个例子是允许的:
class O {
class S {
}
class U {
U() {
var tmp = new S() { }; // 允许
super();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这里类实例创建表达式的封闭实例不是新创建的 U
对象,而是词法上封闭的 O
实例。
如果 return
语句不包含表达式(即 return;
是允许的,但 return e;
不允许),则可以在构造函数体的结语中使用。如果 return
语句出现在构造函数体的前言中,则是编译时错误。
在构造函数体的前言中抛出异常是允许的。实际上,在快速失败的场景中这将是典型的。
与静态上下文不同,预构造上下文中的代码可以引用正在构造的实例的类型,只要它不访问实例本身:
class A<T> extends B {
A() {
super(this); // 错误 - 引用'this'
}
A(List<?> list) {
super((T)list.get(0)); // 允许 - 引用'T'但不引用'this'
}
}
2
3
4
5
6
7
8
9
10
11
记录(Records)
记录类构造函数已经比普通构造函数受到更多限制(JLS §8.10.4)。具体来说:
- 标准记录构造函数不能包含任何显式构造函数调用,并且
- 非标准记录构造函数必须调用替代构造函数(一个
this(...)
调用),并且不能调用超类构造函数(一个super(...)
调用)。
这些限制仍然存在,但除此之外,记录构造函数将受益于上述更改,主要是因为非标准记录构造函数将能够在显式替代构造函数调用之前包含语句。
枚举(Enums)
目前,枚举类构造函数可以包含显式替代构造函数调用,但不能包含超类构造函数调用。枚举类将受益于上述更改,主要是因为它们的构造函数将能够在显式替代构造函数调用之前包含语句。
测试
我们将使用现有的单元测试来测试编译器的更改,除了那些验证更改行为的测试之外,其他测试都保持不变,并且在适当的时候添加新的正面和负面测试用例。
我们将使用以前和新的编译器版本编译所有 JDK 类,并验证生成的字节码是否相同。
不需要特定于平台的测试。
风险和假设
我们上面提出的更改是源代码和行为兼容的。它们严格地扩展了合法 Java 程序的集合,同时保留了所有现有 Java 程序的含义。
这些更改虽然本身适度,但代表了长期以来的要求的重大变化,即如果存在构造函数调用,它必须始终作为构造函数体中的第一条语句出现。这个要求深深地嵌入在 Java 生态系统中的代码分析器、风格检查器、语法高亮器、开发环境和其他工具中。与任何语言更改一样,在工具更新时可能会有一段痛苦的时期。
依赖项
这个 Java 语言特性依赖于 JVM 验证和执行构造函数中构造函数调用之前的任意代码的能力,只要该代码不引用正在构造的实例。幸运的是,JVM 已经支持对构造函数体进行更灵活的处理:
- 在构造函数中可以出现多个构造函数调用,只要在任何代码路径上恰好有一个调用;
- 任意代码可以在构造函数调用之前出现,只要该代码不引用正在构造的实例,除非是给字段赋值;并且
- 显式构造函数调用不能出现在
try
块中,即在字节码异常范围内。
这些更宽松的规则仍然确保自上而下的初始化:
- 超类初始化总是恰好发生一次,要么直接通过超类构造函数调用,要么间接通过替代构造函数调用;并且
- 在超类初始化完成之前,除了不影响结果的字段赋值之外,未初始化的实例是不可访问的。
换句话说,我们不需要对 Java 虚拟机规范进行任何更改。
JVM 和语言之间当前的不匹配是一个历史遗留问题。最初,JVM 更加严格,但这导致了编译器为新语言特性(如内部类和捕获的自由变量)生成的字段初始化问题。结果,规范被放宽以适应编译器生成的代码,但这种新的灵活性从未回到语言中。