写了 20 多年 Java ,也写过 JVM ,我来尝试解释一下。
首先 Java 这个行为和 C#是一样的:在子类尚未完成初始化时,父类的构造函数就已经能调用在子类中重载的函数。这意味着不注意的话很容易跑出 NPE 和别的毛病来。这个问题在 stackoverflow 上也经常有人问。
要理解这个行为,可以看一下 JVM spec 里对 invokevirtual 这个字节码的解释:
https://docs.oracle.com/javase/specs/jvms/se9/html/jvms-6.html#jvms-6.5.invokevirtual注:invokevirtual 是 JVM bytecode 调用在 class 中定义的 virtual 函数时所使用的指令。
简单的说,调用虚方法时,查找的是当前实例(this)的类的方法表,也就是你的 Child 的方法表。
这里 Java 和 C++的区别是,C++的虚函数表(vtable)的建立,在逻辑上是动态的,相当于每一层实例构造完以后,更新一次 vtable 。当然实际上 C++编译器不会这么没效率,就把构造函数里调用的函数当作非虚函数在编译期直接 resolve 完事。
而 Java 的类的方法表就那么一张,每个类在加载验证 link 完成以后,方法表就在那里不动了。而基类构造函数的调用是在初始化实例时动态发生的,调虚方法时查的表也是 Child 的表,自然会调用到 Child 中重载的函数,即使此时 Child 的数据成员并未初始化。
这样做在逻辑上确实有难以理解的地方:Child 整个实例都还没处于一个合法的状态,其方法就被调用了。
但是,C++这种做法也有其局限性:确实有场景是需要基类能在构造函数里调用子类重载的虚函数,只要子类的实现不依赖子类的数据成员即可。打个比方:
class Bike {
Bike () {
frontWheel = makeWheel();
rearWheel = makeWheel();
}
Wheel makeWheel();
}
class TitaniumBike {
Wheel makeWheel() {
return new TitaniumAllyWheel();
};
}
这样子类就可以正确产生一辆拥有钛合金狗眼(划掉)轮子的自行车。这里不讨论此种设计模式的优劣,只是举个例子。我本人反正是不会这么写。