C++中虚函数、虚继承内存模型

1,607 阅读18分钟
原文链接: zhuanlan.zhihu.com
C++中虚函数、虚继承的内存模型是一个经典问题,其实现依赖于编译器,但其主要原理大体相同。本文以问题导向的方式,详细介绍了g++中虚函数和虚继承的内存模型及其原理。

1 多态类型

在C++中,多态类型是指声明或者继承了至少一个虚函数的类型,反之则为非多态类型。

对于非多态类型的变量,其类型信息都可以在编译时确定。例如:

struct A
{
    void foo() {}
};

...

A a;

std::cout << typeid(a).name();  // 可以在编译时确定a的类型为A
a.foo();     // 可以在编译时确定A::foo在内存中的地址
sizeof(a);   // 尽管A为空,但由于需要在内存中确定a的地址,因此A类型对象所占空间为1个字节

而对于多态类型,一些信息必须延迟到运行时才可以确定,例如它的实际类型、所调用的虚函数的地址等。下面的这个例子中,类型B继承了声明有虚函数的类型A,因此AB都是多态类型。

struct A
{
    virtual void foo() {} // 声明虚函数
};

struct B : public A
{
    // 隐式继承了虚函数
};

...

B b{};
A& a_rb = b; // 将b绑定到A的左值引用a_rb上

typeid(decltype(a_rb)).name(); // decltype产生的是编译时即可确定的声明类型,因此为A
typeid(a_rb).name();  // 由于a_rb是多态类型的glvalue,typeid在运行时计算,因此为B

a_rb.foo();  // 这里调用的是B中的foo,其函数地址是运行时确定的
sizeof(b);   // 这里的sizeof是编译器决定的,通常为8 (64位)

2 虚函数内存模型

我们可以用基类型A的引用或者指针持有实际类型为派生类B的对象,这意味着,编译时我们无法通过其声明类型来确定其实际类型,也就无法确定应该调用哪个具体的虚函数。考虑到程序中的每个函数都在内存中有着唯一的地址,我们可以将具体函数的地址作为成员变量,存放在对象之中,这样就可以在运行时,通过访问这个成员变量,获取到实际类型虚函数的地址。

2.1 单继承内存模型

现代的C++编译器都采用了表格驱动的对象模型。具体来说,对于每一个多态类型,其所有的虚函数的地址都以一个表格的方式存放在一起,每个函数的偏移量在基类型和导出类型中均相同,这使得虚函数相对于表格首地址的偏移量在可以在编译时确定。虚函数表格的首地址储存在每一个对象之中,称为虚(表)指针(vptr)或者虚函数指针(vfptr),这个虚指针始终位于对象的起始地址。使用多态类型的引用或指针调用虚函数时,首先通过虚指针和偏移量计算出虚函数的地址,然后进行调用。

例如,有如下所示的类型AB

struct A
{
    int ax; // 成员变量
    virtual void f0() {}
    virtual void f1() {}
};

struct B : public A
{
    int bx; // 成员变量
    void f0() override {}; // 重写f0
};

它们的对象模型和虚表模型如下所示:

struct A
 object                                            A VTable (不完整)
     0 - vptr_A -------------------------------->  +--------------+
     8 - int ax                                    |    A::f0()   |
sizeof(A): 16    align: 8                          +--------------+
                                                   |    A::f1()   |
                                                   +--------------+

struct B
 object                                         
     0 - struct A                                  B VTable (不完整)
     0 -   vptr_A ------------------------------>  +--------------+
     8 -   int ax                                  |    B::f0()   |
    12 - int bx                                    +--------------+
sizeof(A): 16    align: 8                          |    A::f1()   |
                                                   +--------------+

注意到,由于B重写了方法f0(),因此它的虚表在同样的位置,将A::f0()覆盖为B::f0()。当发生f0()函数调用时,对于实际类型为A的对象,其VTable偏移量为offset0的位置为A::f0(), 对于实际类型为B的对象,对应位置为B::f0(),这样就实现了运行时虚函数函数地址的正确选择。

A a;
B b;
A &a_ra = a;
A &a_rb = b;
a_ra.f0(); // call (a_ra->vptr_A + offset0) --> A::f0()
a_rb.f0(); // call (a_rb->vptr_A + 0ffset0) --> B::f0()

在以上的例子中,B中虚函数都已经在A中声明过,如果类型B中出现了基类型A中没有的虚函数,新的虚函数将会被附加在虚函数表的最后,不会对与基类重合的部分造成影响。例如B中新增加了函数f2(),虚函数表变化如下:

 struct B
 object                                         
     0 - struct A                                  B VTable (不完整)
     0 -   vptr_A ------------------------------>  +--------------+
     8 -   int ax                                  |    B::f0()   |
    12 - int bx                                    +--------------+
sizeof(A): 16    align: 8                          |    A::f1()   |
                                                   +--------------+
                                                   |    B::f2()   |
                                                   +--------------+

对于多态类型,除了要在运行时确定虚函数地址外,还需要提供运行时类型信息(Run-Time Type Identification, RTTI)的支持。一个显然的解决方案是,将类型信息的地址加入到虚表之中。为了避免虚函数表长度对其位置的影响,g++将它放在虚函数表的前,所示如下:

 struct B                                          B VTable (不完整)
 object                                            +--------------+
     0 - struct A                                  |  RTTI for B  |
     0 -   vptr_A ------------------------------>  +--------------+
     8 -   int ax                                  |    B::f0()   |
    12 - int bx                                    +--------------+
sizeof(A): 16    align: 8                          |    A::f1()   |
                                                   +--------------+
                                                   |    B::f2()   |
                                                   +--------------+

现在的虚表中,不仅含有函数地址,还含有RTTI的地址,之后还会加入许多新项目。虚表中的每一项都称作一个实体(entity)。

上述的解决方案,可以很好的处理单链继承的情况。在单链继承中,每一个派生类型都包含了其基类型的数据以及虚函数,这些虚函数可以按照继承顺序,依次排列在同一张虚表之中,因此只需要一个虚指针即可。并且由于每一个派生类都包含它的直接基类,且没有第二个直接基类,因此其数据在内存中也是线性排布的,这意味着实际类型与它所有的基类型都有着相同的起始地址。例如,B继承AC继承B,它们的定义和内存模型如下所示:

struct A
{
    int ax;
    virtual void f0() {}
};

struct B : public A
{
    int bx;
    virtual void f1() {}
};

struct C : public B
{
    int cx;
    void f0() override {}
    virtual void f2() {}
};

内存模型为

                                                      C VTable(不完整)
struct C                                              +------------+
object                                                | RTTI for C |
    0 - struct B                            +-------> +------------+
    0 -   struct A                          |         |   C::f0()  |
    0 -     vptr_A -------------------------+         +------------+
    8 -     int ax                                    |   B::f1()  |
   12 -   int bx                                      +------------+
   16 - int cx                                        |   C::f2()  |
sizeof(C): 24    align: 8                             +------------+

从上图可以看出,使用一个类型AB的引用持有实际类型为C的对象,它的起始地址仍然指向C的起始地址,这意味着单链继承的情况下,动态向下转换和向上转换时,不需要对this指针的地址做出任何修改,只需要对其重新“解释”。

然而,并非所有派生类都是单链继承的,它们的起始地址和其基类的起始地址不一定始终相同。

2.2 多继承内存模型

假设类型C同时继承了两个独立的基类AB, 它们的定义关系如下:

struct A
{
    int ax;
    virtual void f0() {}
};

struct B
{
    int bx;
    virtual void f1() {}
};

struct C : public A, public B
{
    int cx;
    void f0() override {}
    void f1() override {}
};

与单链继承不同,由于AB完全独立,它们的虚函数没有顺序关系,即f0f1有着相同对虚表起始位置的偏移量,不可以顺序排布。 并且AB中的成员变量也是无关的,因此基类间也不具有包含关系。这使得ABC中必须要处于两个不相交的区域中,同时需要有两个虚指针分别对它们虚函数进行索引。 其内存布局如下所示:

                                                C Vtable (7 entities)
                                                +--------------------+
struct C                                        | offset_to_top (0)  |
object                                          +--------------------+
    0 - struct A (primary base)                 |     RTTI for C     |
    0 -   vptr_A -----------------------------> +--------------------+       
    8 -   int ax                                |       C::f0()      |
   16 - struct B                                +--------------------+
   16 -   vptr_B ----------------------+        |       C::f1()      |
   24 -   int bx                       |        +--------------------+
   28 - int cx                         |        | offset_to_top (-16)|
sizeof(C): 32    align: 8              |        +--------------------+
                                       |        |     RTTI for C     |
                                       +------> +--------------------+
                                                |    Thunk C::f1()   |
                                                +--------------------+

在上图所示的布局中,CA作为主基类,也就是将它虚函数“并入”A的虚函数表之中,并将A的虚指针作为C的内存起始地址。

而类型B的虚指针vptr_B并不能直接指向虚表中的第4个实体,这是因为vptr_B所指向的虚表区域,在格式上必须也是一个完整的虚表。因此,需要为vptr_B创建对应的虚表放在虚表A的部分之后 。

在上图中,出现了两个“新”的实体,一个是offset_to_top,另一个是Thunk

在多继承中,由于不同的基类起点可能处于不同的位置,因此当需要将它们转化为实际类型时,this指针的偏移量也不相同。由于实际类型在编译时是未知的,这要求偏移量必须能够在运行时获取。实体offset_to_top表示的就是实际类型起始地址到当前这个形式类型起始地址的偏移量。在向上动态转换到实际类型时,让this指针加上这个偏移量即可得到实际类型的地址。需要注意的是,由于一个类型即可以被单继承,也可以被多继承,因此即使只有单继承,实体offset_to_top也会存在于每一个多态类型之中。

而实体Thunk又是什么呢?如果不考虑这个Thunk,这里应该存放函数C::f1()的地址。然而,dump虚表可以看到,Thunk C::f1()C::f1()的地址并不一样。

为了弄清楚Thunk是什么,我们首先要注意到,如果一个类型B 的引用持有了实际类型为C的变量,这个引用的起始地址在C+16处。当它调用由类型C重写的函数f1()时,如果直接使用this指针调用C::f1()会由于this指针的地址多出16字节的偏移量导致错误。 因此在调用之前,this指针必须要被调整至正确的位置 。这里的Thunk起到的就是这个作用:首先将this 指针调整到正确的位置,即减少16字节偏移量,然后再去调用函数C::f1()

2.3 构造与析构过程

在多态类型的构造和析构过程中,所调用的虚函数并不是最终的实际类型的对应函数,而是当前已经创建了的(或尚未析构的)类型的对应函数。这句话比较绕口,我们通过一个例子来说明。如下所示的两个类型AB, 它们在构造和析构时都会调用对应的虚函数:

struct A
{
    virtual void f0() { std::cout << "A\n"; }

    A() { this->f0(); }

    virtual ~A() { this->f0(); }
};

struct B : public A
{
    virtual void f0() { std::cout << "B\n"; }

    B() { this->f0(); }

    ~B() override { this->f0(); }
};

int main()
{
    B b;
    return 0;
} // 输出:ABBA

运行上述程序,可以得到输出“ABBA”,表明程序依次调用了A::A()B::B()B::~B()A::~A()。直观上理解,在构造A时,B中的数据还没有创建,因此B重写的虚函数当然不可使用,因此应该调用A中的版本;反过来,析构的时候,由于B先析构,在B析构之后,B中的函数当然也不可用,因此也应该调用A中的版本。

在程序运行中,这一过程是通过动态的修改对象的虚指针实现的。

根据C++中继承类的构造顺序,首先基类A被构造。在构造A时, 对象自身的虚指针指向A的虚表。由于A的虚表中,f0()的位置保存着A::f0()的地址,因此A::f0()被调用。在A的构造结束后,B的构造启动,此时虚指针被修改为指向B的虚表。析构过程与此相反。

3 虚继承的内存模型

上述的模型中,对于派生类对象,它的基类相对于它的偏移量总是确定的,因此动态向下转换并不需要依赖额外的运行时信息。

而虚继承破坏了这一条件。它表示虚基类相对于派生类的偏移量可以依实际类型不同而不同,且仅有一份拷贝,这使得虚基类的偏移量在运行时才可以确定。因此,我们需要对继承了虚基类的类型的虚表进行扩充,使其包含关于虚基类偏移量的信息。

3.1 菱形继承的内存模型

下面展示了一个经典的菱形虚继承关系,为了避免重复包含A中的成员,类型BC分别虚继承A。类型D继承了BC。依据其继承方式的不同,D中的BC的偏移量可以在编译时确定,而A的偏移量在运行时确定。

struct A
{
    int ax;
    virtual void f0() {}
    virtual void bar() {}
};

struct B : virtual public A           /****************************/
{                                     /*                          */
    int bx;                           /*             A            */
    void f0() override {}             /*           v/ \v          */
};                                    /*           /   \          */ 
                                      /*          B     C         */
struct C : virtual public A           /*           \   /          */
{                                     /*            \ /           */
    int cx;                           /*             D            */ 
    void f0() override {}             /*                          */
};                                    /****************************/

struct D : public B, public C
{
    int dx;
    void f0() override {}
};

首先对类型A的内存模型进行分析。由于虚继承影响的是子类,不会对父类造成影响,因此A的内存布局和虚表都没有改变。

                                                   A VTable
                                                   +------------------+
                                                   | offset_to_top(0) |
struct A                                           +------------------+
 object                                            |    RTTI for A    |
     0 - vptr_A -------------------------------->  +------------------+
     8 - int ax                                    |      A::f0()     |
sizeof(A): 16    align: 8                          +------------------+
                                                   |      A::bar()    |
                                                   +------------------+

类型B类和类型C没有本质的区别,因此只分析类型B。下图为类型B的内存模型:

                                          B VTable
                                          +---------------------+
                                          |   vbase_offset(16)  |
                                          +---------------------+
                                          |   offset_to_top(0)  |
struct B                                  +---------------------+
object                                    |      RTTI for B     |
    0 - vptr_B -------------------------> +---------------------+
    8 - int bx                            |       B::f0()       |
   16 - struct A                          +---------------------+
   16 -   vptr_A --------------+          |   vcall_offset(0)   |x--------+
   24 -   int ax               |          +---------------------+         |
                               |          |   vcall_offset(-16) |o----+   |
                               |          +---------------------+     |   |
                               |          |  offset_to_top(-16) |     |   |
                               |          +---------------------+     |   |
                               |          |      RTTI for B     |     |   |
                               +--------> +---------------------+     |   |
                                          |     Thunk B::f0()   |o----+   |
                                          +---------------------+         |
                                          |       A::bar()      |x--------+
                                          +---------------------+

对于形式类型为B的引用,在编译时,无法确定它的基类A它在内存中的偏移量。 因此,需要在虚表中额外再提供一个实体,表明运行时它的基类所在的位置,这个实体称为vbase_offset,位于offset_to_top上方。

除此之外,如果在B中调用A声明且B没有重写的函数,由于A的偏移量无法在编译时确定,而这些函数的调用由必须在A的偏移量确定之后进行, 因此这些函数的调用相当于使用A的引用调用。也因此,当使用虚基类A的引用调用重载函数时 ,每一个函数对this指针的偏移量调整都可能不同,它们被记录在镜像位置的vcall_offset中。例如,调用A::bar()时,this指针指向的是vptr_A,正是函数所属的类A的位置,因此不需要调整,即vcall_offset(0);而B::f0()是由类型B实现的, 因此需要将this指针向前调整16字节。

对于类型D,它的虚表更为复杂,但虚表中的实体我们都已熟悉。 以下为D的内存模型:

                                          D VTable
                                          +---------------------+
                                          |   vbase_offset(32)  |
                                          +---------------------+
struct D                                  |   offset_to_top(0)  |
object                                    +---------------------+
    0 - struct B (primary base)           |      RTTI for D     |
    0 -   vptr_B  ----------------------> +---------------------+
    8 -   int bx                          |       D::f0()       |
   16 - struct C                          +---------------------+
   16 -   vptr_C  ------------------+     |   vbase_offset(16)  |
   24 -   int cx                    |     +---------------------+
   28 - int dx                      |     |  offset_to_top(-16) |
   32 - struct A (virtual base)     |     +---------------------+
   32 -   vptr_A --------------+    |     |      RTTI for D     |
   40 -   int ax               |    +---> +---------------------+
sizeof(D): 48    align: 8      |          |       D::f0()       |
                               |          +---------------------+
                               |          |   vcall_offset(0)   |x--------+
                               |          +---------------------+         |
                               |          |   vcall_offset(-32) |o----+   |
                               |          +---------------------+     |   |
                               |          |  offset_to_top(-32) |     |   |
                               |          +---------------------+     |   |
                               |          |      RTTI for D     |     |   |
                               +--------> +---------------------+     |   |
                                          |     Thunk D::f0()   |o----+   |
                                          +---------------------+         |
                                          |       A::bar()      |x--------+
                                          +---------------------+     

3.2 构造与析构过程

与非虚继承相似,通过虚继承产生的派生类在构造和析构时,所调用的虚函数只是当前阶段的的虚表中对应的函数。一个问题也就由此产生,由于在虚基类的不同的派生类中,虚基类相对于该类型的偏移量是可以不同的,如果直接使用2.3中的方法,直接用继承虚基类的类型自身的虚表作为构建该类时使用的虚表,会由于偏移量的不同,导致无法正确获取虚基类中的对象。

这个描述比较抽象拗口,我们通过3.1中的菱形继承的例子进行解释。四个类型ABCD的继承关系如下所示:

struct A
{
    int ax;
    virtual void f0() {}
    virtual void bar() {}
};

struct B : virtual public A           /****************************/
{                                     /*                          */
    int bx;                           /*             A            */
    void f0() override {}             /*           v/ \v          */
};                                    /*           /   \          */
                                      /*          B     C         */
struct C : virtual public A           /*           \   /          */
{                                     /*            \ /           */
    int cx;                           /*             D            */
    virtual void f1() {}              /*                          */
};                                    /****************************/


struct D : public B, public C
{
    int dx;
    void f0() override {}
};

观察实际类型为B和实际类型为D对象的内存布局可以发现,如果实际类型为B,虚基类AB的首地址的偏移量为16;若实际类型为D,则其中AB首地址的偏移量为32。这明显与B自身的虚表冲突。如果构建D::B时还采用的是B自身的虚表,会由于偏移量的不同导致错误。

这一问题的解决方法其实很粗暴,那就是在对象构造、析构阶段,会用到多少种虚表,会用到多少种虚指针就生成多少种虚指针。在构造或析构时,“按需分配”。

例如,这里的类型D是类型BC的子类,而BC虚继承了类型A。 这种继承关系会导致D内部含有的B(称作B-in-D)、C(称作C-in-D)的虚表与BC的虚表不同。 因此,这需要生成两张新的虚表,即B-in-DC-in-D的虚表。

由于B-in-D也是B类型的一种布局,B的一个虚表对应两个虚指针,分别是vptr_Bvptr_A,因此它也有两个着两个虚指针。在构造或析构D::B时,其对象的内存布局和虚表布局如图所示:

                                          B-in-D VTable
                                          +---------------------+
                                          |   vbase_offset(32)  |
                                          +---------------------+
struct D (Constructing/Deconstructing B)  |   offset_to_top(0)  |
object                                    +---------------------+
    0 - struct B (primary base)           |      RTTI for B     |
    0 -   vptr_B -----------------------> +---------------------+
    8 -   int bx                          |       B::f0()       |
   16 - struct C                          +---------------------+
   16 -   vptr_C                          |   vcall_offset(0)   |x--------+
   24 -   int cx                          +---------------------+         |
   28 - int dx                            |   vcall_offset(-32) |o----+   |
   32 - struct A (virtual base)           +---------------------+     |   |
   32 -   vptr_A --------------+          |  offset_to_top(-32) |     |   |
   40 -   int ax               |          +---------------------+     |   |
sizeof(D): 48    align: 8      |          |      RTTI for B     |     |   |
                               +--------> +---------------------+     |   |
                                          |     Thunk B::f0()   |o----+   |
                                          +---------------------+         |
                                          |       A::bar()      |x--------+
                                          +---------------------+

同样的,在C-in-D中也会有两个虚指针,分别是vptr_Cvptr_A。此外,在最终的D中还有三个虚指针,总计7个不同的虚指针,它们指向3张虚表的7个不同位置。因此编译器为类型D总共生成了3个不同的虚表,和7个不同的虚指针。将这7个虚指针合并到一个表中,这个表就是虚表的表(Virtual Table Table, VTT)。显然,只有当一个类的父类是继承了虚基类的类型时,编译器才会为它创建VTT。

在构造和析构过程中,子类的构造函数或析构函数向基类传递一个合适的、指向VTT某个部分指针,使得父类的构造函数或析构函数获取到正确的虚表。

4 扩展

百闻不如一见,百看不如一练。C++的运行时多态的内存模型是一个相对较复杂的问题,只是看一两遍很难理解。最好的理解方法是自己dump出内存中对象的内存模型,和类型的虚表的结构。

使用Clang++编译器,可以通过下面的命令导出main.cpp中类型的内存模型和虚表模型。

clang++ -cc1 -emit-llvm -fdump-record-layouts -fdump-vtable-layouts  main.cpp

需要注意,类型至少定义了一个变量,否则会被编译器优化掉。例如,有继承关系A<-B<-C,需要 至少定义一个C类型的对象。

使用g++导出继承结构的指令如下:

g++ -fdump-class-hierarchy -c main.cpp

由于g++的dump出的名称是其内部表示,因此还需要使用c++filt导出具有一定可读性的文档。

cat [g++导出的文档] | c++filt -n > [具有一定可读性的输出文档]

此外,还可以通过gdb跟踪内存、寄存器的变化,观察虚函数、Thunk的寻址过程,以及this指针的变化。

对于g++,它采用了安腾ABI(Application Binary Interface),如果想要更深入的了解其内存布局,可以参考安腾ABI文档。Itanium C++ ABI

对于vc++,内存的布局稍有不同,它将虚基类的偏移量单独用一个额外的指针进行索引,因此对于虚继承的类,除了指向虚函数表的vfptr外,还会在它的后面紧随有一个指向虚基类偏移量表的指针vbptr。 除此之外,vc++将空子类的虚指针,或者或者具有与基类相同虚函数接口的派生类的虚指针与虚基类的虚指针进行合并,这意味着有的时候,对象的首个地址存放的可能是vbptr而非vfptr

5 总结

  • 虚函数地址通过虚指针索引的虚函数表在运行时确定;
  • 虚表中不仅储存了虚函数的地址,还储存了类型RTTI的地址、距实际类型首地址偏移量等信息;
  • 虚函数的调用可能涉及到this指针的变更,需要Thunk等方式实现;
  • 对于虚基类的派生类,虚基类的偏移量由实际类型决定,因此在运行时才可以确定虚基类的地址;
  • 在多态类型的构造和析构过程中,通过修改虚指针使其指向不同的虚表,可以实现在不同的阶段调用不同的虚函数;
  • 对于虚继承的情况,由于同一类型虚表的虚表在不同具体类型中可以不同,在构造和析构时,需要通过VTT传递正确的虚表。