Java 25 的构造函数变灵活了

Java 构造函数有一条规则,从 1.0 开始就没变过:super()this() 必须是构造方法体的第一条语句。不是建议,是强制。不遵守编译器直接报错。

这个限制存在了近 30 年,大家早就习惯了,顶多抽个 static 辅助方法绕过去,很少去想它到底有没有道理。实际上 JVM 那边早就支持在 super() 前写代码了——当年为了适配内部类的编译器生成代码,JVM 规范被放宽过,但 Java 语言规范一直没跟着改。直到 JEP 513 在 Java 25 里正式定稿,这个缺口才终于补上。

别扭在哪

先说最常见的一种情况。子类想先校验参数,再传给父类构造函数。

class Employee extends Person {
    Employee(..., int age) {
        super(..., age);        // 必须第一行
        if (age < 18 || age > 67)
            throw new IllegalArgumentException(...);
    }
}

super() 了才校验,如果 age 不合法,父类构造函数已经白跑了一遍。

以往的标准解法是抽一个 static 辅助方法,把校验逻辑内联到 super() 参数里:

class Employee extends Person {
    private static int verifyAge(int value) {
        if (value < 18 || value > 67)
            throw new IllegalArgumentException(...);
        return value;
    }

    Employee(..., int age) {
        super(..., verifyAge(age));
    }
}

一个简单的边界检查要多包一层方法,读代码时还需跳转查看。

更隐蔽的问题:子类字段还没初始化就被访问了

Java 构造函数是从父类往下跑的:父类构造函数执行的时候,子类的字段还是默认值。如果父类构造函数间接访问了子类字段 —— 比如调了一个被子类覆盖的方法 —— 那看到的就是 null 或者 0

class Person {
    int age;

    void show() {
        System.out.println("Age: " + this.age);
    }

    Person(..., int age) {
        this.age = age;
        show();      // 这里调用的是子类的 show
    }
}

class Employee extends Person {
    String officeID;

    @Override
    void show() {
        System.out.println("Age: " + this.age);
        System.out.println("Office: " + this.officeID);
    }

    Employee(..., int age, String officeID) {
        super(..., age);
        this.officeID = officeID;
    }
}

new Employee(42, "CAM-FORA") 的实际输出是:

Age: 42
Office: null

officeIDnull,因为 Person 构造函数调用 show() 时,Employee 构造函数尚未执行到 this.officeID = officeID。即使声明为 final,字段也会被观察到两次 —— 第一次是默认值,第二次才是真正的赋值。Effective Java 第 19 条对此的建议是 “构造函数不能调用可覆盖的方法”。

Java 25 的解法

JEP 513 做了什么?就是把那条 “super() 必须第一行” 的规则给删了。仅此而已。效果是你可以这样写:

class Employee extends Person {
    String officeID;

    Employee(..., int age, String officeID) {
        // 先校验
        if (age < 18 || age > 67)
            throw new IllegalArgumentException(...);
        
        // 先初始化子类字段
        this.officeID = officeID;
        
        // 再调用父类构造函数
        super(..., age);
    }
}

new Employee(42, "CAM-FORA") 现在打印:

Age: 42
Office: CAM-FORA

参数校验在 super() 之前完成,子类字段在父类构造函数执行前就已处于有效状态。

Prologue 和 Epilogue

构造函数体现在分成两个阶段:

super() 之前的代码叫 prologue(序言),之后的叫 epilogue(尾声)。执行顺序变成了:

D 的 prologue
  → C 的 prologue
    → B 的 prologue
      → A 的 prologue
        → Object 构造函数体
      → A 的 epilogue
    → B 的 epilogue
  → C 的 epilogue
→ D 的 epilogue

Prologue 从最底层的子类往父类方向执行(自底向上),把各层字段初始化好,父类构造函数执行时所有字段均已就绪。Epilogue 再按传统方式从父类到子类(自顶向下)执行,此时可安全访问实例的任意状态。

不是完全没有限制

Prologue 里虽然能写代码,但不能乱来。这块代码处于 early construction context(早期构造上下文),核心限制就一条:不能用 this

具体来说:

  • 不能读任何实例字段,也不能调任何实例方法 —— 不管显式 this.xxx 还是隐式 xxx
  • 可以给字段赋值,但只能是那些声明时没有初始化器的字段(比如 int i; 可以,String s = "hello"; 不行)
  • 不能用 return
  • 不能用 super 访问父类的字段或方法

对象尚未构造完成时,读取到的值不可靠,因此禁止读取而只允许写入刚赋值的字段。

嵌套类有一个例外:内部类在 prologue 里可以访问外部类的实例和成员,因为外部类实例在此前已创建完成。

JVM 其实早就准备好了一切

JVM 规范在很多年前就已被放宽 —— 当时是为了让编译器能生成内部类相关的一些特殊代码。构造函数体的验证规则从一开始的 “super() 必须是第一条指令” 改成了 “允许前面有任意指令,只要不访问未初始化的实例就行”。

但那次改 JVM 的时候,没人顺手把 Java 语言规范也改了。于是产生了这样一个局面:JVM 能跑灵活的构造函数体,javac 却不让写。编译器自己生成的那些特殊代码用到了这个灵活性,但用户写不出来。

直到 JDK 25,这个缺口才终于补上。

从 Preview 到定稿

JEP 513 没有引入新关键字或语法糖,只是撤掉了一条历史限制。该特性在 JDK 22、23、24 上连续 preview 了三版,期间几乎没有改动,最终在 JDK 25 定稿。

Project Valhalla 的 value class(JEP 401)将在此基础之上进一步演进 —— 当 value class 的构造函数没有显式 super() 调用时,隐式调用会被放置在构造函数体末尾而非开头,即整个构造函数体都是 prologue。这意味着 value class 的初始化模型与普通类有根本性不同。


本文内容来源于 JEP 513: Flexible Constructor Bodies

comments powered by Disqus