JEP 447:在 super(...)
之前的语句(预览)
总结
在 Java 编程语言的构造函数中,允许在显式构造函数调用之前出现未引用正在创建的实例的语句。这是一个预览语言功能。
目标
-
赋予开发者更大的自由来表达构造函数的行为,使得当前必须分解到辅助静态方法、辅助中间构造函数或构造函数参数中的逻辑能够更自然地放置。
-
保留现有的保证,即在类实例化期间构造函数按照自上而下的顺序执行,确保子类构造函数中的代码不会干扰超类的实例化。
-
不需要对 Java 虚拟机进行任何更改。此 Java 语言功能仅依赖 JVM 当前对出现在构造函数中显式构造函数调用之前代码的验证和执行能力。
动机
当一个类扩展另一个类时,子类会从超类继承功能,并且可以通过声明自己的字段和方法来添加功能。子类中声明的字段的初始值可能依赖于超类中声明的字段的初始值,因此在初始化子类的字段之前,先初始化超类的字段是至关重要的。例如,如果类 B
扩展了类 A
,那么首先必须初始化看不见的类 Object
的字段,然后是类 A
的字段,最后是类 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(..)
或 this(..)
必须是第一条语句”、“不能使用 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();
}
}
在更复杂的情况下,非法访问不需要包含 this
或 super
关键字:
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();
}
}
}
在 Inner
构造函数的预构造上下文中允许出现调用 hello()
,因为它引用的是 Inner
的外围实例(在此情况下,其类型为 Outer
),而不是正在构造的 Inner
实例(JLS §8.8.1)。
在前面的例子中,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
的子类,而 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
语句。如果在构造函数主体的开头部分出现 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 的限制更多,但这导致了在初始化编译器为新语言特性(如内部类和捕获的自由变量)生成的字段时出现问题。结果是,规范被放宽以适应编译器生成的代码,但这种新的灵活性从未返回到语言层面。