在 Java 中,为什么从构造函数中调用方法被认为是不好的做法?如果该方法计算量很大,是否特别糟糕?
3 回答
首先,通常在构造函数中调用方法没有问题。这些问题特别涉及调用构造函数类的可覆盖方法以及将对象的this
引用传递给其他对象的方法(包括构造函数)的特定情况。
避免可覆盖方法和“泄漏this
”的原因可能很复杂,但它们基本上都与防止使用未完全初始化的对象有关。
避免调用可覆盖的方法
避免在构造函数中调用可覆盖方法的原因是 Java 语言规范 (JLS) §12.5中定义的实例创建过程的结果。
除其他事项外,§12.5 的过程确保在实例化派生类[1]时,其基类的初始化(即,将其成员设置为其初始值并执行其构造函数)发生在其自身初始化之前。这旨在通过两个关键原则允许一致的类初始化:
- 每个类的初始化可以集中在只初始化它明确声明自己的成员,在知道从基类继承的所有其他成员都已经初始化的情况下是安全的。
- 每个类的初始化都可以安全地使用其基类的成员作为其自身成员初始化的输入,因为可以保证它们在类的初始化发生时已正确初始化。
然而,有一个问题:Java 允许在构造函数中进行动态调度[2]。这意味着,如果作为派生类实例化的一部分执行的基类构造函数调用派生类中存在的方法,则会在该派生类的上下文中调用该方法。
所有这一切的直接后果是,在实例化派生类时,会在派生类初始化之前调用基类构造函数。如果该构造函数调用了被派生类覆盖的方法,则调用的是派生类方法(而不是基类方法),即使派生类尚未初始化也是如此。如果该方法使用派生类的任何成员,显然这是一个问题,因为它们还没有被初始化。
显然,问题是基类构造函数调用了可以被派生类覆盖的方法的结果。为了防止这个问题,构造函数应该只调用它们自己类的最终、静态或私有方法,因为这些方法不能被派生类覆盖。final 类的构造函数可以调用它们的任何方法,因为(根据定义)它们不能派生自它们。
JLS 的示例 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
类中方法的覆盖版本。 - 类的
printThree()
方法Test
打印成员变量的当前值three
,这是默认值0
(因为Test
实例尚未初始化)。 printThree()
方法和构造Super()
函数各自退出,并且Test
实例被初始化(three
此时设置为3
)。- 该
main()
方法再次调用printThree()
,这一次打印了预期值3
(因为Test
现在已经初始化了实例)。
如上所述,§12.5 规定 (2) 必须在 (5) 之前发生,以确保在Super
is 之前初始化Test
。但是,动态分派意味着(3)中的方法调用是在未初始化Test
类的上下文中运行的,从而导致意外行为。
避免泄漏this
对this
从构造函数传递到另一个对象的限制更容易解释。
基本上,一个对象在其构造函数完成执行之前不能被认为是完全初始化的(因为它的目的是完成对象的初始化)。因此,如果构造函数将对象传递this
给另一个对象,那么即使它尚未完全初始化(因为它的构造函数仍在运行),该对象也会引用该对象。如果另一个对象随后尝试访问未初始化的成员或调用依赖于它被完全初始化的原始对象的方法,则可能会导致意外行为。
有关这如何导致意外行为的示例,请参阅本文。
[1] 从技术上讲,Java 中
Object
的每个类都是派生类——我在这里只使用术语“派生类”和“基类”来概述所讨论的特定类之间的关系。[2] JLS(据我所知)没有说明为什么会出现这种情况。另一种选择——在构造函数中不允许动态调度——会使整个问题变得毫无意义,这可能正是 C++ 不允许它的原因。
构造函数只应该调用私有、静态或最终的方法。这有助于摆脱覆盖可能出现的问题。
此外,构造函数不应该启动线程。在构造函数(或静态初始化程序)中启动线程有两个问题:
- 在非最终类中,它增加了子类问题的危险
- 它为允许 this 引用逃离构造函数打开了大门
在构造函数(或静态初始化程序)中创建线程对象没有任何问题——只是不要从那里开始。
在构造函数中调用实例方法是危险的,因为对象尚未完全初始化(这主要适用于可以被覆盖的方法)。众所周知,构造函数中的复杂处理会对测试能力产生负面影响。
做的时候要小心,用可覆盖的方法来做是不好的做法。