Java构造时成员初始化的陷阱

Java构造时成员初始化的陷阱

让我们先来看两个类:Base和Derived类。注意其中的whenAmISet成员变量,和方法preProcess()

public class Base
{
    Base() {
        preProcess();
    }

    void preProcess() {}
}
public class Derived extends Base
{
    public String whenAmISet = "set when declared";

    @Override void preProcess()
    {
        whenAmISet = "set in preProcess()";
    }
}

如果我们构造一个子类实例,那么,whenAmISet 的值会是什么呢?

public class Main
{
    public static void main(String[] args)
    {
        Derived d = new Derived();
        System.out.println( d.whenAmISet );
    }
}

再续继往下阅读之前,请先给自己一些时间想一下上面的这段程序的输出是什么?是的,这看起来的确相当简单,甚至不需要编译和运行上面的代码,我们也应该知道其答案,那么,你觉得你知道答案吗?你确定你的答案正确吗?

很多人都会觉得那段程序的输出应该是“set in preProcess()”,这是因为当子类Derived 的构造函数被调用时,其会隐晦地调用其基类Base的构造函数(通过super()函数),于是基类Base的构造函数会调用preProcess() 函数,因为这个类的实例是Derived的,而且在子类Derived中对这个函数使用了override关键字,所以,实际上调用到的是:Derived.preProcess(),而这个方法设置了whenAmISet 成员变量的值为:“set in preProcess()”。

当然,上面的结论是错误的。如果你编译并运行这个程序,你会发现,程序实际输出的是“set when declared ”。怎么为这样呢?难道是基类Base 的preProcess() 方法被调用啦?也不是!你可以在基类的preProcess中输出点什么看看,你会发现程序运行时,Base.preProcess()并没有被调用到(不然这对于Java所有的应用程序将会是一个极具灾难性的Bug)。

虽然上面的结论是错误的,但推导过程是合理的,只是不完整,下面是整个运行的流程:

  1. 进入Derived 构造函数。
  2. Derived 成员变量的内存被分配。
  3. Base 构造函数被隐含调用。
  4. Base 构造函数调用preProcess()。
  5. Derived 的preProcess 设置whenAmISet 值为 “set in preProcess()”。
  6. Derived 的成员变量初始化被调用。
  7. 执行Derived 构造函数体。

等一等,这怎么可能?在第6步,Derived 成员的初始化居然在 preProcess() 调用之后?是的,正是这样,我们不能让成员变量的声明和初始化变成一个原子操作,虽然在Java中我们可以把其写在一起,让其看上去像是声明和初始化一体。但这只是假象, 我们的错误就在于我们把Java中的声明和初始化看成了一体 在C++的世界中,C++并不支持成员变量在声明的时候进行初始化,其需要你在构造函数中显式的初始化其成员变量的值,看起来很土,但其实C++用心良苦。

在面向对象的世界中,因为程序以对象的形式出现,导致了我们对程序执行的顺序雾里看花。所以, 在面向对象的世界中,程序执行的顺序相当的重要

下面是对上面各个步骤的逐条解释。

  1. 进入构造函数。
  2. 为成员变量分配内存。
  3. 除非你显式地调用super(),否则Java 会在子类的构造函数最前面偷偷地插入super() 。
  4. 调用父类构造函数。
  5. 调用preProcess,因为被子类override,所以调用的是子类的。
  6. 于是,初始化发生在了preProcess()之后。这是因为,Java需要保证父类的初始化早于子类的成员初始化,否则,在子类中使用父类的成员变量就会出现问题。
  7. 正式执行子类的构造函数(当然这是一个空函数,虽然我们没有声明)。

你可以查看《Java语言的规格说明书》中的 相关章节 来了解更多的Java创建对象时的细节。

C++的程序员应该都知道,在C++的世界中在“构造函数中调用虚函数”是不行的,Effective C++ 条款9:Never call virtual functions during construction or destruction,Scott Meyers已经解释得很详细了。

在语言设计的时候,“ 在构造函数中调用虚函数 ”是个两难的问题。

  1. 如果调用的是父类的函数的话,这个有点违反虚函数的定义。
  2. 如果调用的是子类的函数的话,这可能产生问题的:因为在构造子类对象的时候,首先调用父类的构造函数,而这时候如果去调用子类的函数,由于子类还没有构造完成,子类的成员尚未初始化,这么做显然是不安全的。

C++选择了第一种,而Java选择了第二种。

  • C++类的设计相对比较简陋,通过虚函数表来实现,缺少类的元信息。
  • 而Java类的则显得比较完整,有super指针来导航到父类。

最后,需要向大家推荐一本书,Joshua Bloch 和 Neal Gafter 写的 Java Puzzlers: Traps, Pitfalls, and Corner Cases ,中文版《 JAVA 解惑 》。

(全文完)

(转载本站文章请注明作者和出处 酷 壳 – CoolShell ,请勿用于任何商业用途)

好烂啊 有点差 凑合看看 还不错 很精彩 ( 15 人打了分,平均分: 3.87 )
Loading...

Java构造时成员初始化的陷阱 》的相关评论

  1. 怎么说呢。。。。看过head first java的人,应该很容易理解楼主的讲解。

    成员变量初始化被调用在构造函数之后

  2. C++中,情况就大不一样了。基类的构造函数调用的preProcess()是基类的,而不是被重写过的preProcess(),构造基类时,vtable还没有被正确的书写。

  3. ivan :

    C++中,情况就大不一样了。基类的构造函数调用的preProcess()是基类的,而不是被重写过的preProcess(),构造基类时,vtable还没有被正确的书写。

    Effective C++ 条款9:Never call virtual functions during construction or destruction,Scott Meyers已经解释得很详细了!

    在语言设计的时候,“ 在构造函数中调用虚函数 ”是个两难的问题。
    一、如果调用的是父类的函数的话,这个有点违反虚函数的定义。
    二、如果调用的是子类的函数的话,这可能产生问题的:因为在构造子类对象的时候,首先调用父类的构造函数,而这时候如果去调用子类的函数,由于子类还没有构造完成,子类的成员尚未初始化,这么做显然是不安全的。
    c++选择了第一种,而Java选择了第二种。
    c++类的设计相对比较简陋,通过虚函数表来实现,缺少类的元信息。
    而Java类的则显得比较完整,有super指针来导航到父类

  4. 呵呵,刚学JAVA的时候,老师说构造函数不会被继承,当时没有想太多当定率记下来了,一直没有深究。直到学多了几门语言之后才慢慢的去想这些。
    网站刚建不久吧,等静下来我自己也弄一个。想用DJANGO 可惜很多空间提供商都不支持。

  5. 以前闲暇的时候翻了翻《JAVA解惑》,觉得挺有意思。当时还一度选了几个做笔试新人用,现在回想起来觉得这事儿办的有点邪恶了。

  6. xiaots :
    扯淡…需要考虑的那么复杂吗?

    首先我要说的是结果输出的的确是set when declared
    但是原因楼主说的不对。java的初始化顺序是这样的:
    1、不通过new的,顺序是静态变量,静态块,静态方法。
    2、通过new的,顺序是成员变量,构造函数。
    下面是示例代码的过程:
    1、new Derived ()时,首先是初始化成员变量 whenAmISet = “set when declared”
    2、执行Derived构造函数,没有显示构造函数执行默认的无参构造函数,(孩子不能比父亲早出生,java的规定)递归调用父类构造函数Base(),执行完父类构造函数里preProcess()后退出构造,整个new过程结束
    3、d.whenAmISet显示whenAmISet 的内存值:set when declared
    本人的观点来源于think in java 第三版。

    关注coolshell时间不长,本人道行尚浅,有不足处望指正。

  7. @name
    我错了,楼主是对的。
    我们的错误就在于我们把Java中的声明和初始化看成了一体。

    源地址的例子比较好
    class Super {
    Super() { printThree(); }
    void printThree() { System.out.println(“three”); }
    }
    class Test extends Super {
    int three = (int)Math.PI; // That is, 3
    public static void main(String[] args) {
    Test t = new Test();
    t.printThree();
    }
    void printThree() { System.out.println(three); }
    }

  8. 成员变量在构造函数中进行赋值,为啥要那么复杂?子类的成员变量在子类的构造函数中完成不就行了

  9. Should the below explaination is better than this blog?
    ===================================================================

    a local variable declaration is a statement that appears within a Java method; the variable initialization is performed when the statement is executed. Field declarations, however, are not part of any method, so they cannot be executed as statements are.
    Instead, the Java compiler generates instance-field initialization code automatically and puts it in the constructor or constructors for the class. The initialization code is inserted into a constructor in the order in which it appears in the source code, which means that a field initializer can use the initial values of any fields declared before it. Consider the following code excerpt, which shows a constructor and two instance fields of a hypothetical class:

    public class TestClass {
      public int len = 10;
      public int[] table = new int[len];
    
      public TestClass() { 
        for(int i = 0; i < len; i++) table[i] = i;
      }
    
      // The rest of the class is omitted... 
    }

    In this case, the code generated for the constructor is actually equivalent to the following:

    public TestClass() { 
      len = 10;
      table = new int[len];
      for(int i = 0; i < len; i++) table[i] = i;
    }
  10. @name
    我认为你的说法是正确的,的确是先初始化变量,再执行构造器。
    针对LZ的代码,因为不存在static变量,所以比较容易说清。
    1、执行Derived的构造器
    2、但此时需要先执行Base的构造器,而在理论上需要初始化Base内的成员变量,但Base不包含则不需讨论。
    3、之后需要在执行Derived的构造器之前初始化Derived的成员变量
    这么解释我觉得是正确。同样来自Think in Java 中文版P145 7.9

  11. 谢谢, 我有点迷惑的是 调用子类prexxx方法是因为子类构造中调用父类构造的super方法有隐藏this的原因吗?

  12. 您好, Java构造时成员初始化的陷阱这篇文章中有个地方不理解,请百忙之中帮忙解答一下,谢谢。
    5.调用preProcess,因为被子类override,所以调用的是子类的。
    这块没有理解,为什么是调用子类的,这时子类实例还没有创建,子类的实例方法不是要等子类实例创建后才能调用访问吗?

  13. 看子类的指令,给实例属性赋值是在super之后,而子类常量池中的字符串的值就是声明的值:set when declared

    public com.wc.common.contruct.test1.SubClass();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
    stack=2, locals=1, args_size=1
    0: aload_0
    1: invokespecial #10 // Method com/wc/common/contruct/test1/BaseClass.””:()V
    4: aload_0
    5: ldc #12 // String set when declared
    7: putfield #14 // Field whenAmISet:Ljava/lang/String;
    10: return
    LineNumberTable:
    line 3: 0
    line 4: 4
    line 3: 10
    LocalVariableTable:
    Start Length Slot Name Signature
    0 11 0 this Lcom/wc/common/contruct/test1/SubClass;

  14. 打了断点看了看,首先执行base中process,然后base中process调的是子类的process,这个时候属性初始值为null,然后设置为了”in process”,父类构造函数调用完成,子类构造函数调super()执行完接下来初始化全局属性whenAmISet = “set when declared”;接下来再执行子类构造函数super()以外的,可以解释这个现象

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注