C++中虚函数、虚继承内存模型
转载
作者:Holy Chen
链接:https://zhuanlan.zhihu.com/p/41309205
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
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
,因此A
和B
都是多态类型。
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),这个虚指针始终位于对象的起始地址。使用多态类型的引用或指针调用虚函数时,首先通过虚指针和偏移量计算出虚函数的地址,然后进行调用。
例如,有如下所示的类型A
和B
:
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
继承A
,C
继承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 +------------+
从上图可以看出,使用一个类型A
或B
的引用持有实际类型为C
的对象,它的起始地址仍然指向C
的起始地址,这意味着单链继承的情况下,动态向下转换和向上转换时,不需要对this
指针的地址做出任何修改,只需要对其重新“解释”。
然而,并非所有派生类都是单链继承的,它们的起始地址和其基类的起始地址不一定始终相同。
2.2 多继承内存模型
假设类型C
同时继承了两个独立的基类A
和B
, 它们的定义关系如下:
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 {}
};
与单链继承不同,由于A
和B
完全独立,它们的虚函数没有顺序关系,即f0
和f1
有着相同对虚表起始位置的偏移量,不可以顺序排布。 并且A
和B
中的成员变量也是无关的,因此基类间也不具有包含关系。这使得A
和B
在C
中必须要处于两个不相交的区域中,同时需要有两个虚指针分别对它们虚函数进行索引。 其内存布局如下所示:
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() |
+--------------------+
在上图所示的布局中,C
将A
作为主基类,也就是将它虚函数“并入”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 构造与析构过程
在多态类型的构造和析构过程中,所调用的虚函数并不是最终的实际类型的对应函数,而是当前已经创建了的(或尚未析构的)类型的对应函数。这句话比较绕口,我们通过一个例子来说明。如下所示的两个类型A
和B
, 它们在构造和析构时都会调用对应的虚函数:
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
中的成员,类型B
和C
分别虚继承A
。类型D
继承了B
和C
。依据其继承方式的不同,D
中的B
、C
的偏移量可以在编译时确定,而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中的菱形继承的例子进行解释。四个类型A
,B
,C
和D
的继承关系如下所示:
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
,虚基类A
对B
的首地址的偏移量为16
;若实际类型为D
,则其中A
对B
首地址的偏移量为32
。这明显与B
自身的虚表冲突。如果构建D::B
时还采用的是B
自身的虚表,会由于偏移量的不同导致错误。
这一问题的解决方法其实很粗暴,那就是在对象构造、析构阶段,会用到多少种虚表,会用到多少种虚指针就生成多少种虚指针。在构造或析构时,“按需分配”。
例如,这里的类型D
是类型B
和C
的子类,而B
和C
虚继承了类型A
。 这种继承关系会导致D
内部含有的B
(称作B-in-D
)、C
(称作C-in-D
)的虚表与B
、C
的虚表不同。 因此,这需要生成两张新的虚表,即B-in-D
和C-in-D
的虚表。
由于B-in-D
也是B
类型的一种布局,B
的一个虚表对应两个虚指针,分别是vptr_B
和vptr_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_C
和vptr_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
传递正确的虚表