当前位置:C++技术网 > 资讯 > 类的对象构造顺序以及可能会引发的问题的深入分析

类的对象构造顺序以及可能会引发的问题的深入分析

更新时间:2015-06-26 21:23:46浏览次数:1+次

    在实际的开发中,将一个类作为另一个类的成员变量,这种用法,实在是太普遍了。也正因为如此,这里隐藏着巨大的危机。说不定在无意之中就中招了,而你却对此一无所知。这是对象构造顺序的一个问题,希望通过本文的讲解可以让你有个深刻的认识,从根本上了解这个问题的来源,并且防患于未然。
    这篇文章,就讲解知识点来说,并没什么好说的。大把的教材上都讲了。我就不讲述这些基础的概念了。我从项目的实际开发的使用中来讲解这个问题。
    在项目中,使用了一个向导程序,和VS的创建项目的向导一样,向导类作为框架类,类中包含了每个向导页的类。这样就形成了以上的类的成员对象的用法。而在构造函数中,需要处理成员对象的初始化,基类的初始化和类本身的初始化。而对于类来说,都是在构造函数中完成的,而这几个类中顺序如何,你是否清楚?还有,this指针你是否清楚?如果都不清楚,在这里很可能会翻船,所以要好好看看。如果非常清楚我这里要说什么,就不必费劲看了。如果想热心检查我的说法是否正确,很欢迎指正,一起学习交流。
    首先说说初始化顺序。基类、类本身、成员对象这三者,顺序如何,我下面仔细的讨论一下。
    先举个实例的代码,便于讲解。代码如下:

class B
{
private:
    Int i ;
public:
    B();
    ~B();
}
B::B()
{
    i = 3;
}


class A:public Base
{
public:
    A();
    ~A();
    B m_b;   
}
A::A()
{
    ...
}
void main()
{
    A a;
}

    以上是基本的类声明。A继承于Base,A类中包含了成员对象m_b。
    以这个例子,来说一下初始化的顺序,以及为什么要这样的顺序初始化。
    在执行完A a;时,a先分配好了内存,然后调用a的构造函数即A()。此时不会有任何问题。初始化的顺序是这样的:构造一个派生类对象时,首先要构造其基类。为什么呢?你可以想想,派生类继承于基类,内存模型也是在基类的基础之上的。还会继承基类的成员变量。基类的部分,在派生类只是拿来即可。派生类中,无法对基类的成员进行初始化。因为在派生类出来之前,基类就已经造好了。我们可以这么来理解。Base类的初始化必然是它自己来完成,否则就不叫初始化了。如果基类不初始化好,派生类也无法从它继承。没有爸妈,怎么有孩子呢?对吧。不管怎么样,按照先初始化基类再初始化派生类是非常自然地。
    那么A类的成员对象B初始化如何理解呢?
    只有在继承关系中,才有父子关系,即派生关系。而B对象作为A对象的一个成员,完全是客串。A类中,完全可以没有B,可以由C来代替。那么也就是说,B既然是来客串的,必然是已经生成好了的。如果没有出生,怎么来客串呢?在A的构造函数中,代表A正在出生。这就意味着,B已经出生好了。假如B的出生在A的构造函数之后完成,那就和上面的道理相违背。因为没出生的对象不能来客串。所以就说,B必然在A的构造函数之前进行初始化,即调用B的构造函数,让B先出生。这个和Base基类先于A出生有一定的区别。没有Base表示A没有了爸妈,那就不可能有A,而没有了B,A照样能出生,只是少了一个客串的前辈朋友罢了。作为前辈的朋友B来说,B是先于A出生的了。就这些区别。
    因为B的构造是在A的出现才出现的,Base则是A出现的前提,所以Base是最重要的,因此最先出场,然后是B客串的出来迎接,最后就是A自己构造出来。不管是Base、B还是A,只有在构造中才叫初始化,也只有在自己的构造函数中才叫初始化。
    说了这么多,形象的说明了三个类的构造顺序的原因。顺序不能乱,否则时光倒流了。那么问题来了,B要在A之前构造,但是B又只有在A出现时才能构造,又不能在构造函数中初始化B,如何解决呢?结果聪明的C++设计人员发明了成员初始化列表。这也是成员初始化列表的由来。成员初始化列表是介于基类Base和类A之间的一步,在基类构造完毕后,就构造成员初始化列表的对象,然后才是类对象本身。这个设计解决了构造顺序的矛盾,也就形成了一个语法规则。当然,如果要提前初始化普通的变量,也可以放在这里哦。
    A a;这个一句代码,其实执行了太多的语句,我也是醉了。哈哈哈。而在调用构造函数时,还有一个问题,那才是我们今天要讨论的核心所在。但是上面的是必须掌握的,否则下面的内容是无法理解的。这也是为什么我花这么大的理解讲述这个构造顺序的原因。
    在现在的代码中,默认的B构造函数只是对成员变量i进行初始化,仅此而已。A的构造函数,并没有成员初始化列表。是不是就真的没有成员初始化列表的存在了呢?不是的。因为B提供了无参的构造函数,默认的情况,初始化列表就是调用这个默认的构造函数,也就没有必要再写到成员初始化列表中了。一切都在默默的执行。
    现在来到真正的问题引发之处。修改一下B的声明,适应真正的开发需求。代码如下:

class B
{
private:
    A* m_pA ;// - 这里不能用A对象哦,只能用指针,否则就出现循环包含了
public:
    B(A* pA);
    ~B();
}
B::B(A* pA)
{
    m_pA = pA;
}


    在这种情况下,就需要在A的构造函数的初始化列表中写明初始化B,且传入参数。为了简化问题,基类就使用默认的构造,因此不用在成员初始化列表中写出来。如下:

A():m_b(this)
{
    ...
}

    因此此时如果提供B的默认构造函数,且不在成员初始化列表中初始化,那就只有在A的构造函数中对m_b这个成员指针m_pA赋值了,这就不是B的初始化了。当然,这两个效果一样。但是,如果不提供默认构造函数,只有一个带参数的构造函数,B也确实只要一个带参数的构造函数,那就必须在A的初始化列表中指明,并将this指针传给B。这样也可以完成B的构造。
    这里就隐藏了一个巨大的问题了。B类要在A类的构造函数的成员初始化列表中传入A的指针。在由上面可以知道,在成员初始化列表中,A尚未初始化,而此时就将A类指针this传给B去初始化B对象,是不是有隐患呢?你看出来了没有?
    我们先来看看this指针。This指针出现在哪里,指向的就是那个对象。就这句话,你就应该知道了,this出现在A类的构造函数中,或者初始化列表中,指向的都是A类对象。这样应该不难理解。而不管是出现在构造函数中还是成员初始化列表中,都可以看出,此时,A并没有初始化好。而指针指向的是A对象的内存起始地址,因为在构造A对象即初始化A对象之前,在A的基类初始化之前,A的内存已经分配了,所以此时,this指针是可以正确指向的,只是A对象中的成员没有初始化罢了。所以,此时this指向的A对象,虽然对象已经创建好了,但是成员都没有开始初始化。
    那么在初始化列表中,将A的this指针传给B,B在构造函数中,如果只是将此指针存储起来,不通过这个指针使用A的成员变量等,是不会出现问题的。在B中存起来留在A对象初始化完毕使用,是没有问题的。因为指针指向的是A对象的内存起始地址,所以之后使用,也是可以正常的调用A的。这就是指针的好处。
    然而,因为B的构造函数先于A构造函数之前执行。假如在B的构造函数中,使用了A对象的指针操作了A的变量,此时就会出问题。下面来举例说明。
    假如A的类中增加一个成员变量bool m_bIsRight。B的构造函数中得到指向A的指针m_pA后,通过m_pA来获取m_bIsRight,并对m_bIsRight进行判断,以进一步进行操作。如果为true,则继续初始化B,如果为false,则终止程序。代码如下:

B::B(A* pA)
{
    m_pA = pA;
    If(m_pA)
    {
        // - 继续初始化B
    }else
    {
        // - 终止程序运行
        exit(-1);
    }
}

    但是因为A还没有初始化完,因此,m_bIsRight是垃圾值,内存中是哪样的值,无法确定。如果是非零值,那B就可以继续初始化,而如果正好是0,那么B就无法初始化,程序也就退出了。这就是没有理解初始化顺序引起的问题。一般此时只是设置一个值,到A构造函数时又初始化一次,结果数值被覆盖,这也导致了数据错误。这样在后期很难调试出来。因为数据错误通常很隐蔽,遇见这个问题,就比较麻烦了。而问题的根本,就是这个构造顺序没有理解明白,使用了未初始化完毕的A对象中的变量。
    我们的项目中,使用了这样方式,但是只是把指针存起来,因此不存在这个问题。我相信,看到这里,你理解后,对于这种问题,是不会再犯了。如果一定要在B中使用A的变量,那么就给B提供一个无参的默认构造函数,在初始化列表不用写什么,先将B初始化好,然后在A构造函数中给B的相应的变量赋值。理解这个初始化顺序后,你就可以很好的安排了。只有在A构造函数结束后,才代表A初始化完毕。
    深刻理解这个构造顺序,才会做到游刃有余。如果文中有不对之处,请指正。