虚函数表里有什么?(三)——普通多继承下的虚函数表

导言

本篇探究普通多继承(没有虚继承)下的虚函数表。示例代码如下:

复制代码
#include <iostream>

struct X { 
    virtual ~X() {}
    virtual void zoo() { std::cout << "X::zoo()\n"; }
    int x_data = 100;
};
struct A : public X {
    A() { this->funA(); }
    virtual void funA() { std::cout << "A::funA()\n"; }
    virtual ~A() {}
    int a_data = 200;
};
struct B : public X {
    B() { this->funB(); }
    virtual void funB() { std::cout << "B::funB()\n"; }
    virtual ~B() {}
    int b_data = 300;
};
struct C : public A, public B {
    virtual void foo() {}
    virtual void funA() override { std::cout << "C::funA()\n"; }
    virtual void funB() override { std::cout << "C::funB()\n"; }
    virtual ~C() {};
    int c_data = 400;
};

int main(int argc, char *argv[])
{
    C *p = new C;
    p->foo();
    delete p;
    return 0;
}
复制代码

构造过程

我们首先跟踪C类实例的构造过程,看看构造过程中都发生了什么。读者可以使用 g++ -g -O2 -fno-inline main.cpp -o main 命令编译上述代码,在 C *p = new C; 一句处打断点,然后单步执行汇编。这里我们给出Compiler Explorer中的汇编代码,因为它没有name mangling,更加易懂。

step1:分配内存,调用C::C()

复制代码
main:
        pushq   %r14
        movl    $40, %edi # 分配40字节的内存
        pushq   %rbx
        subq    $8, %rsp
        call    operator new(unsigned long) # 调用new操作符分配内存
        movq    %rax, %rdi # %rax中保存了new返回的地址,把该地址存入%rdi,作为调用C类构造函数的第一个参数(即this指针)
        movq    %rax, %rbx # 保存到%rbx是为了析构时用
        call    C::C() [complete object constructor] # 调用C类的对象构造函数
复制代码

step2:构造基类子对象

复制代码
C::C() [base object constructor]:
        pushq   %rbx
        movq    %rdi, %rbx
        call    A::A() [base object constructor]
        leaq    16(%rbx), %rdi # 调整this指针,为调用B::B()做准备call    B::B() [base object constructor]
        movq    $vtable for C+16, (%rbx) # 覆盖A类的虚表指针
        movq    $vtable for C+80, 16(%rbx) # 覆盖B类的虚表指针
        movl    $400, 32(%rbx) # 初始化c_data
        popq    %rbx
        ret
X::X() [base object constructor]:
        movq    $vtable for X+16, (%rdi) # 将X类的虚表指针放到内存的前8个字节(this指针指向的位置)
        movl    $100, 8(%rdi) # 在接下来的内存中存入X::x_dataret
A::A() [base object constructor]:
        pushq   %rbx
        movq    %rdi, %rbx # 保存首地址(this指针)
        call    X::X() [base object constructor]
        movq    $vtable for A+16, (%rbx) # 将A类的虚表指针放到内存的前8个字节(this指针指向的位置),这覆盖了之前X的虚表指针
        movq    %rbx, %rdi # 将this放入%rbi,
        movl    $200, 12(%rbx) # 在接下来的内存中存入A::a_datacall    A::funA()
        popq    %rbx
        ret
复制代码

 

首先构造基类A,而A自己也有基类,所以先构造基类X,过程详见注释。构造完成后,内存布局如下:

基类X构造完成后,流程再次回到A类的构造函数中。我们看到, A::A() 将A类的虚表指针写入到了内存的前8个字节当中了,覆盖了之前A类的虚表指针!其实,这么做是合理的。假设我们在 X::X() 中调用虚函数,该使用那个版本的虚函数呢?自己的?还是派生类重写的?假设我们在 X::X() 中调用 typeid 操作符,该返回什么类型呢?X类型?A类型?C类型?在 X::X() 内部,它并不知道构造的是独立的X对象,还是某个完整对象的基类子对象(正如本例),而且,即便是基类子对象,调用派生类的虚函数也是有危险的。因为,此次时刻,正在构造基类子对象,派生类的部分还没有构造,派生类独有的数据成员自然也没有初始化,这个时候调用派生类的虚函数,如果该函数读写了派生类独有的数据成员,结果将是未定义的。因此,在 X::X() 中,只能调用X类自己版本的虚函数, typeid 也只能返回X类型(完整对象还没有构造完)。所以,在构造X类时,应当使用X类自己的虚函数表。针对这一点,Itanium C++ ABI也有相关表述。

During the construction of a class object, the object assumes the type of each of its proper base classes, as each base class subobject is constructed. RTTI queries in the base class constructor will return the type of the base class, and virtual calls will resolve to member functions of the base class rather than the complete class. 

当流程再次回到 A::A() ,A的基类子对象X已经构造完了,因此,它可以使用使用自己的数据和函数,也可以使用基类子对象X的,所以,此时可以把虚表指针指向A类自己的虚函数表了。但还不能用派生类的虚函数,所以,当 A::A() 里调用 funA 时,生成的汇编代码直接是 call A::funA() ,相当于静态绑定。

还有一点需要注意,正常应该是先初始化数据成员(这里是 A::a_data ),再执行构造函数的函数体(这里是 this->funA(); )的,但上面的汇编代码,是反过来的,这是我们编译时开了 -O2 优化的缘故。

 A::A() 执行完成后,对象布局如下。

至此,和构造一个独立的A对象没有区别。细心的读者可能发现了,在构造函数后面,有的标着complete object constructor,而有的标着base object constructor,在本例中,两者其实是同一个函数的别名。等到我们讲到虚拟继承时,再来看它们之间的区别。

构造完A类后,接着将 this 指针加上16字节,作为新的 this 指针,调用 B::B() ,开始构造B类子对象。这个过程和A类子对象的构造过程如出一辙,我们略过不表。完成后,对象内存布局如下。

step3:构造派生类部分

流程再次回到 C::C() ,因为所有基类子对象都已构造完毕,进入到构造完整对象阶段,可以放心得让虚表指针指向C类的虚函数表了。

复制代码
C::C() [base object constructor]:
        pushq   %rbx
        movq    %rdi, %rbx
        call    A::A() [base object constructor]
        leaq    16(%rbx), %rdi
        call    B::B() [base object constructor]
     => movq    $vtable for C+16, (%rbx) # 覆盖A类的虚表指针
        movq    $vtable for C+80, 16(%rbx) # 覆盖B类的虚表指针
        movl    $400, 32(%rbx) # 初始化c_data
        popq    %rbx
        ret
复制代码

最终的内存布局如下:

内存布局规律

可以看到,在对象和vtable的内存布局上:

  • 按声明顺序依次存放各个基类子对象的虚表指针和非static数据成员,最后存放派生类自己的非static数据成员。
  • 完整对象和第一个基类子对象(也称为primary base class)共用一个虚表指针,其余基类子对象各有一个虚表指针。
  • 基类子对象的虚表指针指向的是完整对象的vtable,而不是它们自己的vtable。以基类子对象B为例,虚表指针指向了vtable for C + 80的位置,而不是vtable for B + 16。这是实现RTTI的关键。从 0x555555557cd0 到 0x555555557cff ,是B作为C的基类子对象时对应的虚函数表。其中,top_offset是-16,因为从基类子对象B的首地址到完整对象C的首地址,正好差了16字节。typeinfo指针也指向了C类的typeinfo对象,而不是B类自己的,这是因为现在的完整对象是C类型而不是B类型。这样,B类的指针或引用指向C类实例时,通过 typeid 获取到的就是类型C(即实际对象的类型)了,在 dynamic_cast 中,也能通过基类子对象的指针获取到完整对象的类型。
  • 完整对象C的vtable的内存布局如下:首先是第一个直接基类A的虚函数表,其中typeinfo信息是C类的,并且,如果C类重写了A类某些虚函数,对应条目要换成C类版本的(比如这里的 C::funA() )。接着是C类自己的虚函数(比如这里的 C::foo() ),再接下来是C类重写的其它基类的虚函数,按声明顺序排列(比如这里的 C::funB() )。剩下的,就是其余各个基类的虚函数表,包括了top_offset、typeinfo(都指向C类typeinfo信息)、没有被override的虚函数、被override的虚函数(实际上是non-virtual thunk to的形式,后面详细介绍)等信息。

non-virtual thunk to function

在C类的vtable中,出现了之前没有介绍过的条目 non-virtual thunk to C::~C() 和 non-virtual thunk to C::funB() 。这是什么呢?别急,先考虑下面的问题。

C *pc = new C;
B *pb = pc;

请问, pb 和 pc 相等吗?不相等!这里进行了隐式类型转换, pb 指向了完整对象中的基类子对象,以上图为例的话,就是 pc = 0x55555556aeb0 , pb = 0x55555556aec0 , pb 比 pc 多16字节。现在,假设要执行 pb->funcB(); ,根据多态性,应该执行 C::funcB() 才对,但现在 this 指针指向的却不是完整对象C,而是基类子对象B,不配套了。因此,需要在调用 C::funcB() 前调整 this 指针的值,non-virtual thunk函数就是做这个事情的,因为是非虚继承,因此是non-virtual。让我们来看看 non-virtual thunk to C::funcB() 做了什么?

non-virtual thunk to C::funB():
        subq    $16, %rdi # this减去16,由指向基类子对象B改为指向完整对象C
        jmp     .LTHUNK0

其中 .LTHUNK0 是 C::funcB() 的别名,可见,所谓的thunk函数,就是调整指针后再执行真正要执行的函数。

__vmi_class_type_info

ltanium C++ ABI规定,表示typeinfo的类一共有3个:

  •  __class_type_info :用于没有基类的多态类,比如本文中的类X。
  •  __si_class_type_info :如果一个类只有一个基类,并且该基类是公共的(public继承)、非虚的(不是虚继承),那么,这个类的typeinfo信息就用 __si_class_type_info 表示,比如本文中的类A和类B。
  •  __vmi_class_type_info :用于除上述两种情况外的所有其它情况,比如多继承、虚拟继承等。

我们在之前的文章中已经详细介绍过前两个类,现在重点介绍 __vmi_class_type_info ,它的定义如下(仅保留数据成员,完整定义请参考源文件):

复制代码
  // Type information for a class with multiple and/or virtual bases.
  class __vmi_class_type_info : public __class_type_info
  {
  public:
    unsigned int         __flags;  // Details about the class hierarchy.
    unsigned int         __base_count;  // Number of direct bases.

    // The array of bases uses the trailing array struct hack so this
    // class is not constructable with a normal constructor. It is
    // internally generated by the compiler.
    __base_class_type_info     __base_info[1];  // Array of bases.

    // Implementation defined types.
    enum __flags_masks
      {
    __non_diamond_repeat_mask = 0x1, // Distinct instance of repeated base. 非菱形继承,有重复的基类
    __diamond_shaped_mask = 0x2, // Diamond shaped multiple inheritance.
    __flags_unknown_mask = 0x10
      }; 
  };
复制代码

让我们依次解释各个成员的含义,推荐读者对照着前面的图示来理解。

__flags & __base_count

 __flags 表示继承的类型,是 enum __flags_masks 中各个值按位或的结果。对文本中的例子而言,因为是非菱形继承,有重复的基类,因此是 __non_diamond_repeat_mask ,即1。

 __base_count 表示直接基类的个数,对文本中的例子而言,C有两个直接基类A和B,因此 __base_count 是2。

__base_info

 __base_info 是一个数组,保存了各个直接基类的信息,一共有几个基类,数组中就有几个元素。正如注释所说,这里使用了称为trailing array struct hack的编程技巧,简单说就是可变长度的数组,感兴趣的读者请自行学习。让我们来看看  __base_class_type_info 里有什么。

复制代码
class __base_class_type_info
  {
  public:
    const __class_type_info*     __base_type;  // Base class type.
#ifdef _GLIBCXX_LLP64
    long long            __offset_flags;  // Offset and info.
#else
    long             __offset_flags;  // Offset and info.
#endif

    enum __offset_flags_masks
      {
    __virtual_mask = 0x1,
    __public_mask = 0x2,
    __hwm_bit = 2,
    __offset_shift = 8          // Bits to shift offset.
      };

    bool __is_virtual_p() const { return __offset_flags & __virtual_mask; }
    bool __is_public_p() const { return __offset_flags & __public_mask; }
    ptrdiff_t __offset() const { return static_cast<ptrdiff_t>(__offset_flags) >> __offset_shift; }
  };
复制代码

 

其中, __class_type_info 指向了基类的typeinfo信息,而 __offset_flags 同时包含了标志位和offset信息。 __hwm_bit = 2 (hwm是High Water Mark的意思)表明了最低的2 bit用来储存继承关系,即 __virtual_mask = 0x1 和 __public_mask = 0x2 。 __offset_shift = 8 表示从第8 bit开始,储存的是基类子对象在完整对象中的偏移量。而第2 bit到第7 bit,目前暂未使用,留待未来扩展。以B类子对象为例,它的 __offset_flags 字段是0x1002,最低2 bit是 10 ,表明是public继承,不是virtual继承。 0x1002 >> 8 = 0x10  ,表明子对象到完整对象的偏移量是16,正好和top_offset对得上。

总结

  • 在完整对象的构造/析构过程中,其虚表指针是随着构造/析构阶段不断变化的,正在构造/析构哪个对象,该对象就相当于“完整对象”,虚表指针就指向该对象的vtable。因为C++标准规定,在对象的构造/析构期间,对象的动态类型被认为是正在构造/析构的那个类,而不是最终派生出的完整类型。
  • 在对象的构造/析构阶段,如果在构造函数/析构函数中直接或者间接调用虚函数,就相当于静态调用,即只能调用当前正在构造/析构的那个类自己或者其基类的虚函数,不能调用其派生类的虚函数。例如,在本文例子中,当构造到子对象A时,只能调用A类或者其基类X类中的虚函数,不能调用C类中的虚函数。(参考资料
  • 多重继承下的对象和vtable遵循特定的内存布局,详见上文
  • 当派生类指针赋值给基类指针时,会发生隐式转换,基类指针的值是基类子对象的首地址。当用基类指针调用派生类虚函数时,需要调整 this 指针,这一步由 non-virtual thunk to xxx 函数或者 virtual thunk to xxx 函数完成。
  • 多重继承或虚拟继承下,类型的typeinfo信息由 __vmi_class_type_info 定义,该类记录了类的继承方式、基类的类型、基类子对象到完整对象的偏移量等RTTI需要的信息。

由于在下才疏学浅,能力有限,错误疏漏之处在所难免,恳请广大读者批评指正,您的批评是在下前进的不竭动力。

原创作者: zpcdbky 转载于: https://www.cnblogs.com/zpcdbky/p/18811970
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值