首先,在构造函数中调用方法通常没有问题。问题特别在于调用构造函数类的可覆盖方法以及将对象的 this 引用传递给其他对象的方法(包括构造函数)的特殊情况。
避免覆盖方法和“泄漏this”的原因可能很复杂,但它们基本上都与防止使用未完全初始化的对象有关。
避免调用可覆盖的方法
避免在构造函数中调用可覆盖方法的原因是 Java 语言规范 (JLS) 的 §12.5 中定义的实例创建过程的结果。
除其他事项外,§12.5 的过程确保在实例化派生类[1] 时,其基类的初始化(即,将其成员设置为其初始值并执行其构造函数) 发生在它自己的初始化之前。这旨在通过两个关键原则允许一致地初始化类:
- 每个类的初始化可以专注于仅初始化它明确声明的成员,确保从基类继承的所有其他成员都已初始化。
- 每个类的初始化都可以安全地使用其基类的成员作为其自身成员初始化的输入,因为可以保证它们在类初始化时已正确初始化。
但是,有一个问题:Java 允许在构造函数中进行动态调度[2]。这意味着,如果作为派生类实例化的一部分执行的基类构造函数调用派生类中存在的方法,则会在该派生类的上下文中调用它。
所有这一切的直接后果是,在实例化派生类时,会在派生类初始化之前调用基类构造函数。如果该构造函数调用了被派生类覆盖的方法,则调用的是派生类方法(不是基类方法),即使派生类尚未初始化。如果该方法使用派生类的任何成员,显然这是一个问题,因为它们还没有被初始化。
显然,问题是基类构造函数调用了可以被派生类覆盖的方法的结果。为了防止这个问题,构造函数应该只调用它们自己类的最终、静态或私有方法,因为这些方法不能被派生类覆盖。 final 类的构造函数可以调用它们的任何方法,因为(根据定义)它们不能派生自。
JLS 的Example 12.5-2 很好地证明了这个问题:
class Super {
Super() { printThree(); }
void printThree() { System.out.println("three"); }
}
class Test extends Super {
int three = (int)Math.PI; // That is, 3
void printThree() { System.out.println(three); }
public static void main(String[] args) {
Test t = new Test();
t.printThree();
}
}
这个程序打印0 然后3。本例中的事件顺序如下:
-
new Test() 在main() 方法中被调用。
- 由于
Test 没有显式构造函数,因此调用其超类的默认构造函数(即Super())。
-
Super() 构造函数调用printThree()。这被分派到 Test 类中方法的覆盖版本。
-
Test 类的printThree() 方法打印three 成员变量的当前值,即默认值0(因为Test 实例尚未初始化)。
-
printThree() 方法和Super() 构造函数分别退出,Test 实例被初始化(此时three 被设置为3)。
-
main() 方法再次调用printThree(),这一次打印3 的预期值(因为Test 实例现已初始化)。
如上所述,§12.5 规定 (2) 必须在 (5) 之前发生,以确保 Super 在 Test 之前初始化。但是,动态分派意味着(3)中的方法调用是在未初始化的Test 类的上下文中运行的,从而导致意外行为。
避免泄漏this
将this 从构造函数传递到另一个对象的限制更容易解释。
基本上,一个对象在其构造函数完成执行之前不能被认为是完全初始化的(因为它的目的是完成对象的初始化)。因此,如果构造函数将对象的this 传递给另一个对象,那么即使它尚未完全初始化(因为它的构造函数仍在运行),该对象也会引用该对象。如果另一个对象随后尝试访问未初始化的成员或调用依赖于它被完全初始化的原始对象的方法,则可能会导致意外行为。
有关这如何导致意外行为的示例,请参阅this article。
[1] 从技术上讲,Java 中除
Object 之外的每个类都是派生类——我在这里仅使用术语“派生类”和“基类”来概述相关特定类之间的关系。
[2] JLS(据我所知)没有说明为什么会出现这种情况。另一种选择——在构造函数中不允许动态分派——会使整个问题变得毫无意义,这可能正是 C++ 不允许它的原因。