Java中基类构造函数调用重写方法时,派生类对象的状态如何?

12
请参考以下 Java 代码:
class Base{
     Base(){
         System.out.println("Base Constructor");
         method();
     }
     void method(){}    
}

class Derived extends Base{
    int var = 2;
    Derived(){
         System.out.println("Derived Constructor");  
    }

     @Override
     void method(){
        System.out.println("var = "+var);
     }
 }

class Test2{
    public static void main(String[] args) {
        Derived b = new Derived();
    }
}

看到的输出是:

Base Constructor
var = 0
Derived Constructor

我认为出现var = 0是因为Derived对象只是被初始化了一半;类似于Jon Skeet在这里说的

我的问题是:

如果还没有创建Derived类对象,为什么会调用覆盖的方法?

在什么时候将var赋值为0?

是否存在某些情况下希望这种行为发生的用例?


1
另请参见:https://dev59.com/80_Sa4cB1Zd3GeqP-DCk - Zaki
@Zaki:感谢你提供的链接。非常感激... - Abhi
4个回答

12
  • 在Java中,Derived对象已经被创建了,只是构造函数还没有运行。在对象创建之后(即所有构造函数运行之前),对象的类型不会发生变化。

  • var在创建对象时被赋予默认值0,此时构造函数尚未运行。基本上,类型引用被设置了,表示对象的其余内存被擦除为零(可以理解为垃圾回收之前已经被擦除为零)。

  • 这种行为至少保持了一致性,但有时也会令人烦恼。就一致性而言,假设你有一个只读子类和一个可变的基类。基类可能有一个isMutable()属性,其实际上默认为true - 但子类将其覆盖为始终返回false。在子类构造函数运行之前,对象是可变的,但在运行之后却是不可变的,这是很奇怪的。另一方面,如果在类的构造函数运行之前就运行了该类的代码,那么这种情况肯定是非常奇怪的 :(

一些指南:

  • 尽量避免在构造函数中进行过多的工作。一个避免这种情况的方法是在静态方法中完成工作,然后将静态方法的最后一部分设置为构造函数调用,该构造函数只是简单地设置字段。当然,在执行此过程时,您不会获得多态性的好处 - 但在构造函数调用中执行此操作也是危险的。

  • 尽量避免在构造函数中调用非final方法 - 这很可能会引起混乱。如果您确实必须调用某些方法,请非常清楚地记录下来,以便覆盖它们的任何人都知道它们将在初始化完成之前被调用。

  • 如果您必须在构造过程中调用方法,则通常不应在之后再调用它。如果是这种情况,请记录下来并尝试在名称中指示。

  • 尽量避免过度使用继承 - 只有当你的子类需要从Object以外的超类继承时,这才会成为一个问题 :) 为继承进行设计是棘手的。


  • 在Swing中,通常在构造函数[体]运行之前,在派生类中运行create方法是很常见的。Swing绝对是奇怪的。 - Tom Hawtin - tackline
    @Tom,这意味着create方法永远不可能依赖于派生类的对象状态。 - rsp
    @rsp 是的。它通常取决于派生类的类型。它实际上可以依赖于从“外部this”引入的状态,或通过像ThreadLocal这样的黑客方式引入。 - Tom Hawtin - tackline
    感谢你清晰明了的解释! - Abhi

    5
    如果尚未创建Derived类对象,为什么会调用被覆盖的方法?
    Derived类构造函数隐式地调用Base类构造函数作为第一条语句。Base类构造函数调用method(),该方法调用Derived类中被覆盖实现的方法,因为正在创建对象的是Derived类。此时,Derived类中的method()将var视为0。
    在何时将var分配值0?
    在调用Derived类的构造函数之前,var被分配int类型的默认值即0。在隐式超类构造函数调用完成之后,并且在Derived类的构造函数语句开始执行之前,var被赋值为2。
    是否存在任何使用这种行为的用例?
    通常,在非final类的构造函数/初始化器中使用非final非private方法是个坏主意。你的代码已经证明了原因。如果正在创建的对象是子类实例,则这些方法可能会产生意料之外的结果。

    3
    请注意,这与C++不同,在对象构造过程中类型会发生变化,因此从基类构造函数调用虚方法不会调用派生类的覆盖方法。在销毁期间也会发生相反的情况。因此,对于转向Java的C++程序员来说,这可能是一个小陷阱。

    是的,在C++中类型可以改变而在Java中则不行,了解这一点总是很好的。 - Abhi

    2
    需要注意Java语言规范中的一些属性才能解释这种行为:
    • 超类构造函数在子类构造函数之前始终被隐式/显式地调用。
    • 从构造函数调用的方法与任何其他方法调用相同;如果方法不是final,则调用是虚拟调用,这意味着要调用的方法实现是与对象的运行时类型相关联的方法实现。
    • 在构造函数执行之前,所有数据成员都会自动初始化为默认值(数字原语为0,对象为null,布尔值为false)。

    事件序列如下:

    1. 创建子类的实例
    2. 所有数据成员都使用默认值进行初始化
    3. 立即调用正在调用的构造函数来委托控制给相关的超类构造函数。
    4. 超级构造函数初始化其自己的某些/全部数据成员,然后调用一个虚拟方法。
    5. 该方法被子类重写,因此会调用子类实现。
    6. 该方法尝试使用子类的数据成员,假设它们已经初始化,但事实并非如此 - 调用堆栈尚未返回到子类的构造函数。
    简而言之,当超类的构造函数调用非final方法时,我们有可能陷入这个陷阱,因此不建议这样做。 请注意,如果您坚持使用此模式,则没有优雅的解决方案。以下是两个复杂且创新的解决方案,都需要线程同步(!):

    http://www.javaspecialists.eu/archive/Issue086.html

    http://www.javaspecialists.eu/archive/Issue086b.html


    这并不是真正的原因,因为在C++中构造顺序是相同的。实际上我看不出它怎么可能有任何不同。然而,在C++中却不存在这个问题。真正的原因是对象在整个构造过程中都是目标类型,而这在C++中并不是这种情况。 - user207421
    @EJP: 我并不是在与C++进行比较。我只是在说这种行为的两个事实导致了这种情况:1)super的ctor总是首先被调用;2)从ctor调用方法与任何调用都没有区别,因此它是一个虚拟调用,除非该方法是final(private/声明为final)。请参阅我提供的链接,以获取有关此设计问题的更多详细信息。 - Eyal Schneider
    我同意这两个事实,现在你已经都陈述清楚了;-) - user207421

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接