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

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

1

2

3

4

5

6

7

8

public class Base

{

    Base() {

        preProcess();

    }

 

    void preProcess() {}

}

1

2

3

4

5

6

7

8

9

public class Derived extends Base

{

    public String whenAmISet = "set when declared";

 

    @Override void preProcess()

    {

        whenAmISet = "set in preProcess()";

    }

}

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

 

1

2

3

4

5

6

7

8

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指针来导航到父类。

 

再看一个例子
     java类中声明的立即赋值的成员变量,真的立即赋值了吗?

  我告诉你答案。并没有!

  下面我们来执行一个例子,验证一下?

  首先定义一个抽象的父类,父类的构造函数中调用子类实现的方法。

  public abstract  class Parent {
    public Parent(){
        System.out.println("在父类的构造函数中调动子类的实例化了initVariables方法,输出的值是");
        initVariables();
    }
    protected abstract void initVariables();
}


再定义一个子类。

public class Son extends Parent{
    private int number=6;
    public Son(){
        super();
        System.out.println("在子类的构造函数中,在super下面执行initVariables方法,输出的值是");
        initVariables();
    }
    @Override
    protected void initVariables() {
        System.out.println("number is "+number);
    }
}


好,下面就来实例化一个子类对象看看输出吧!

public class Main {
    public static void main(String[] args) {
        Son son = new Son();
    }
}


输出的内容是:

在父类的构造函数中调动子类的实例化了initVariables方法,输出的值是
number is 0
在子类的构造函数中,在super下面执行initVariables方法,输出的值是
number is 6

   为什么在父类构造函数中调用子类的initVariables方法输出number的值是0呢?我明明在声明number的时候就是赋值为6了的啊(privateintnumber=6;)。但是在子类的构造函数中调用initVariables方法确实输出正常呢?实在是百思不得其解。

  没有其他办法了?看来只能调试,一步一步看程序是怎么进行的。

  1.定义Son对象,首先进入Son构造函数

   2.调用了super,所以进入父类构造函数

   3.父类调用了子类的initVariables方法,此时number为0

  4.父类构造函数执行完了,按理说应该是回到子类执行super下面的initVariables方法的啊,但是程序是执行声明和赋值的成员变量的那里privateintnumber=6;当执行完成员变量赋值后才回到super后面的initVariables方法。这时候输出就是number=6了。

   看到这里大家应该懂了。对于类中的成员变量,一般的数据类型,声明成员变量之后就有一个默认值,比如private int number;其实这个number就是等于0了的。java虚拟机会默认给他一个值,对于其他的对象就是默认值为null.

  为什么要说这个问题呢?因为之前我在做一些程序的时候,就是在父类构造函数中调用子类实现的抽象方法,而在这个方法中又使用到了子类的成员变量(这些变量是在声明的时候就赋值了的),然后执行程序,发现怎么都不是我想要的东西。原来是这些变量其实在父类构造函数中调用的方法时还没有真正的赋值。


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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值