继承体系下虚函数表的初始化

有时会被问到这样一个问题,构造函数和析构函数里面可以调用虚函数吗?或许我们知道最好不要那样做,但是为什么呢?写个例子测试一下就知道这并没什么问题,程序也许可能按预期正常执行,但是语法上通过的东西,未必真的就make sense。《深度探索C++对象模型》一书中分析了对象的创建过程,当然也包括虚函数表的部分。首先看原文,关于编译器对构造函数的扩充:


    1.记录在member initialization list中的data members初始化操作会被放进constructor的函数本身,并以members的声明顺序为顺序.
    2.如果有一个member并没有出现在member initialization list中,但它有一个default constructor,那么该default constructor必须被调用.
    3.在那之前,如果 class object有 virtual table pointers,它们必须被设定初值,指向适当的 virtual tables.
    4.在那之前,所有上一层的base class constructors必须被调用,以base class 的声明顺序为顺序(与member initialization list中的顺序没关联):

        如果base class 被列于member initialization list中,那么任何明确指定的参数都应该传递过去.
        如果base class 没有被列于member initialization list中,而它有default constructor(或default memberwise copy constructor),那么就调用它.
        如果base class 是多重继承下的第二或后继的base class,那么 this 指针必须有所调整.
    5.在那之前,所有 virtual base class constructor必须被调用,从左到右,从最深到最浅:
        如果 class 被列与member initialization list中,那么如果有任何明确指定的参数,都应该传递过去.若没有列于list中,而 class 有一个default constructor,也应该调用它.
        此外,class 中的每一个 virtual base class subobject的偏移量(offset)必须在执行期可内存取.
        如果 class object是最底层(most-derived)的 class,其constructors可能被调用;某些用以支持这个行为的机制必须被放进来.


简言之,在一个类的对象被构造时,首先所有virtual base classes以及base class的constructors会被调用,接着才会初始化虚函数表,最后执行构造函数初始化列表和函数体。可见,以下这段代码:

Derived::Derived() : Base(virtual_func1()), _member(virtual_func2())

virtual_func1的调用会引发问题,因为那个时候还在构造基类,派生类的虚函数表还未初始化,所以一定不能这么做。而virtual_func2的调用是成功的,此时虚函数表已经设置完成,看似没问题,但是语义上却有点别扭。假设Derived还有个派生类BigClass,其对象需要初始化,那么也是由 Base->Derived->BigClass 一步步构造过来的,可是在执行 _member = virtual_func2() 这一步时,BigClass还没有构建,实际上调用的是Derive::virtual_func2(),也就是它自己的版本,虚函数未能实现多态,这种表达方式不太好。另一方面,倘若virtual_func2()内部还用到了其他data member,此时它们恐怕还未被正确初始化,程序也就埋下了祸根。所以说,最好不要在构造函数里调用虚函数。

还要提到虚拟继承,例如在一个菱形继承中,最顶上的那个是共享类,按照C++构造函数的运行方式,该共享类对应的子对象会被初始化多次,虚表也被多次设置,于是编译器通常会对构造函数进行优化,将其一分为二,一个针对完整的object,一个针对各个subobject,前者会调用虚拟基类的构造函数并设定所有的虚函数表,后者不会调用虚基类的构造函数,也不一定需要设置虚表,这就提高了效率。即使不是虚拟继承,编译器也可能用这种方式实现构造函数。总之,虚函数表必须被设置的情况也就存在于以下两者之一:

    1.当一个完整的对象被构造起来时.如果声明一个类的对象,该类的constructor必须设定其vptr.

    2.当一个subobject constructor调用了一个 virtual function时(不论是直接调用或间接调用).


可见,在一个类的构造函数中调用虚函数,如果这个类不是位于继承关系的最下层(比如位于菱形继承的中间层),那么还需要额外的设置虚表,这也算是降低了效率。

析构函数的运行方式和构造函数刚好相反,基于类似的理由,最好不要在析构函数里调用虚函数。实现析构函数的策略也类似于构造函数的一分为二,如下:

    1.一个complete object实体,总是设定好vptr(s),并调用virtual base class destructors.

    2.一个base class object实体,除非在destructor调用一个virtual function,否则它绝对不会调用virtual base class destructors并设定vptr.


以上两条是《深度探索C++对象模型》中的原话,当时看了不太理解,后来查了英文版的电子档,第二条的原文是“A base class subobject instance that never invokes the virtual base class destructors and sets the vptr(s) only if a virtual function may be invoked from within the body of the destructor”,可以说这句话存在自然语言的二义性,我个人的理解应该翻译为“一个base class object实体绝不会调用virtual base class destructor,并且在没有调用virtual function的情况下也不会设定vptr”。

最后说一下对象的拷贝赋值函数,按理说它完成和拷贝构造函数类似的功能,有以下代码:

class A {};
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {};

那么实现D的拷贝赋值函数,我们很可能这样写:

D & D::operator=(const D & d)
{
    this->A::operator=(d);
    this->B::operator=(d);
    this->C::operator=(d);
    ...
}

而B和C的拷贝赋值函数里面,通常也会调用A的版本,这就导致了A的拷贝赋值函数被多次调用。上面提到过构造函数一分为二的处理方法,也是因为其本身比较特殊,编译器会默默的完成很多事情(包括自动调用基类的构造函数),而拷贝赋值函数则是个普通的成员函数,派生类的拷贝赋值函数也不会自动去调用基类的版本,因此无法采用类似的策略避免虚拟基类的拷贝赋值函数被重复调用。书中提到了一些方法,但并不能彻底解决这个问题,可以看作是语言本身的弱点,推荐在最底层类(例如D)的拷贝赋值函数中,将 this->A::operator=(d); 这一行放在最后以保证语义正确,此外作者的忠告是不要在虚基类中声明数据成员。

发现凡是涉及到虚拟继承,必然是对象模型变复杂,执行效率变低,编译器的解决方式也是各式各样,这中间还会产生不少坑。虚拟继承和多继承,能不用最好别用,即使用了也别弄太复杂。

©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页