effective-c++系列:单继承、多继承、虚函数、虚继承内存布局

虚函数内存模型

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

单继承的内存模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Base{
int x;
public:
virtual void f1(){}
virtual void f2(){}
};

class A: public Base{
public:
void f1(){
cout << "A";
}
};
int main()}{
Base b;
A a;
cout << sizeof(b) << endl;
// 16 = 虚函数指针(8) + int(4)+ 对齐(4)
cout << sizeof(a) << endl;
// 24 = Base虚函数指针(8)+ Base int(4) + padding(4)
return 0;
}

使用gdb调试

1
2
g++ main.cpp -o main -g
gdb main

内存布局为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 基类内存
(gdb) p b
$1 = {
_vptr.Base = 0x404580 <vtable for Base+16>,
x = 16
}
(gdb) p &b
$4 = (Base *) 0x61fe10
(gdb) x/16xb 0x61fe10
0x61fe10: 0x40 0x45 0x40 0x00 0x00 0x00 0x00 0x00
0x61fe18: 0x10 0x00 0x00 0x00 0x00 0x00 0x00 0x00
// 子类内存
(gdb) p a
$2 = {
<Base> = {
_vptr.Base = 0x404520 <vtable for A+16>,
x = 20
}, <No data fields>}
(gdb) p &a
$5 = (A *) 0x61fe00
(gdb) x/16xb 0x61fe00
0x61fe00: 0x20 0x45 0x40 0x00 0x00 0x00 0x00 0x00
0x61fe08: 0x14 0x00 0x00 0x00 0x00 0x00 0x00 0x00

可以看出子类中含有父类的成员变量和一个虚函数指针,虚函数表指针在前。
子类覆盖了父类的函数f1(),因此在子类的虚函数表中会将Base::f1()覆盖为A::f1().

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(gdb) p b.f1
$2 = {void (Base * const)} 0x402d80 <Base::f1()>
(gdb) p b
$3 = {_vptr.Base = 0x404540 <vtable for Base+16>, x = 16}
(gdb) x/16xb 0x404540 //Base虚函数表
//f1()函数地址
0x404540 <_ZTV4Base+16>: 0x80 0x2d 0x40 0x00 0x00 0x00 0x00 0x00
0x404548 <_ZTV4Base+24>: 0x90 0x2d 0x40 0x00 0x00 0x00 0x00 0x00

gdb) p a
$4 = {<Base> = {_vptr.Base = 0x404520 <vtable for A+16>, x = 20}, <No data fields>}
(gdb) x/16xb 0x404520 //A虚函数表
//f1()函数
0x404520 <_ZTV1A+16>: 0x20 0x2d 0x40 0x00 0x00 0x00 0x00 0x00
0x404528 <_ZTV1A+24>: 0x90 0x2d 0x40 0x00 0x00 0x00 0x00 0x00

在线性继承关系中,子类只需包含一个虚函数指针和直接父类的所有成员函数即可。

多继承内存模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Base1{
int x;
public:
virtual void f0(){}
};
class Base2{
int y;
public:
virtual void f1(){}
};
class A: public Base1,
public Base2{
public:
void f1(){
cout << "A::f1";
}
void f2(){
cout << "A::f2";
}
};
sizeof(Base1);//16
sizeof(Base2);//16
sizeof(A);//32

同样使用gdb调试

1
2
3
4
5
6
7
8
9
10
(gdb) p a
$2 = {
<Base1> = {
_vptr.Base1 = 0x404560 <vtable for A+16>,
x = 20
},
<Base2> = {
_vptr.Base2 = 0x404580 <vtable for A+48>,
x = 16
}, <No data fields>}

可以看出Base1作为A的主基类,虚函数表从Base1的函数地址开始。

1
2
3
4
5
6
7
(gdb) p &a    
$3 = (A *) 0x61fe00
(gdb) x/32xb 0x61fe00
0x61fe00: 0x60 0x45 0x40 0x00 0x00 0x00 0x00 0x00
0x61fe08: 0x14 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x61fe10: 0x80 0x45 0x40 0x00 0x00 0x00 0x00 0x00
0x61fe18: 0x10 0x00 0x00 0x00 0x00 0x00 0x00 0x00

A中存在Base1的虚表指针,Base2的虚表指针。可以看到两个虚表是相连的,如果重写Base1的函数会直接在虚表中覆盖,Base2同理。
下面在内存中查看下两个虚表的布局

1
2
3
4
5
6
7
8
9
(gdb) x/64xb 0x404550
0x404550 <_ZTV1A>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x404558 <_ZTV1A+8>: 0xc0 0x44 0x40 0x00 0x00 0x00 0x00 0x00 // RTTI:运行时类型信息(Run-Time Type Identification, RTTI)
0x404560 <_ZTV1A+16>: 0xa0 0x2d 0x40 0x00 0x00 0x00 0x00 0x00 // Base1::f0()
0x404568 <_ZTV1A+24>: 0xf0 0x2c 0x40 0x00 0x00 0x00 0x00 0x00 // A::f1()
0x404570 <_ZTV1A+32>: 0xf0 0xff 0xff 0xff 0xff 0xff 0xff 0xff //offeset
0x404578 <_ZTV1A+40>: 0xc0 0x44 0x40 0x00 0x00 0x00 0x00 0x00 // RTTI
0x404580 <_ZTV1A+48>: 0x00 0x2e 0x40 0x00 0x00 0x00 0x00 0x00 //chunk A::f1()
0x404588 <_ZTV1A+56>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00

A将Base1作为主基类,也就是将它虚函数“并入”Base1的虚函数表之中,并将Base1的虚指针作为A的内存起始地址。

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

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

而实体Thunk又是什么呢?如果不考虑这个Thunk,这里应该存放函数A::f1()的地址。然而,从内存分配可以看出,Thunk A::f1()和A::f1()的地址并不一样。

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

简单来说,offerset的作用是编译器绑定对象,如果是Base1指针,就加上Base1对应的偏移量,如果是Base2,就加上Base2的偏移量。
而thunk的作用类似于重定位,当用户申请Base2指针指向A对象,并且调用f1()函数,在内存中首先是从0x404580开始找,找到thunk后,里面是一个代码段,首先将this指针向上指,然后再调用this->f1()

虚继承的内存模型

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

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

虚继承最常用的场景是解决菱形继承的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class A{
int ax;
virtual void f0() {}
virtual void f1() {}
};

class B : virtual public A{
int bx;
void f0() override {}
};

class C : virtual public A{
int cx;
void f0() override {}
};
class D : public B, public C{
int dx;
void f0() override {}
};

cout << sizeof(A) << endl; // 16 虚指针A + int + pad
cout << sizeof(B) << endl; // 32 A + 虚指针B + int + pad
cout << sizeof(C) << endl; // 32 A + 虚指针C + int + pad
cout << sizeof(D) << endl; // 48 A + 虚指针D + int + pad

A的内存布局和虚表没有太多变化

1
2
3
4
5
6
7
8
(gdb) p a
$6 = {
_vptr.A = 0x4056c0 <vtable for A+16>,
x = 16
}
(gdb) x/16xb 0x4056c0
0x4056c0 <_ZTV1A+16>: 0x70 0x2d 0x40 0x00 0x00 0x00 0x00 0x00 // A::f0()
0x4056c8 <_ZTV1A+24>: 0x80 0x2d 0x40 0x00 0x00 0x00 0x00 0x00 // A::f1()

B/C的内存布局

1
2
3
4
5
6
7
8
9
$7 = {
<A> = {
_vptr.A = 0x405710 <vtable for B+64>,
x = 20
},
members of B:
_vptr.B = 0x4056e8 <vtable for B+24>,
y = 4199705
}

B/C的虚表内存为

1
2
3
4
5
6
7
8
9
10
11
(gdb) x/72xb 0x4056d0
0x4056d0 <_ZTV1B>: 0x10 0x00 0x00 0x00 0x00 0x00 0x00 0x00 // vbase_offset
0x4056d8 <_ZTV1B+8>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 //offset_to_top
0x4056e0 <_ZTV1B+16>: 0x70 0x55 0x40 0x00 0x00 0x00 0x00 0x00 // RTTI of B
0x4056e8 <_ZTV1B+24>: 0xd0 0x2d 0x40 0x00 0x00 0x00 0x00 0x00 // B.f0()
0x4056f0 <_ZTV1B+32>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 //vcall_offset
0x4056f8 <_ZTV1B+40>: 0xf0 0xff 0xff 0xff 0xff 0xff 0xff 0xff //vcall_offset
0x405700 <_ZTV1B+48>: 0xf0 0xff 0xff 0xff 0xff 0xff 0xff 0xff //offset_to_top
0x405708 <_ZTV1B+56>: 0x70 0x55 0x40 0x00 0x00 0x00 0x00 0x00 //RTTI for B
0x405710 <_ZTV1B+64>: 0xb0 0x2f 0x40 0x00 0x00 0x00 0x00 0x00 //Thunk B::f0()
0x405718 <_ZTV1B+72>: 0x80 0x2d 0x40 0x00 0x00 0x00 0x00 0x00 //A::f1()

可以表示为下面这个结构

B的内存布局
对于形式类型为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的内存布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(gdb) p d
$8 = {
<B> = {
<A> = {
_vptr.A = 0x4057d0 <vtable for D+96>,
x = 0
},
members of B:
_vptr.B = 0x405788 <vtable for D+24>,
y = 0
},
<C> = {
members of C:
_vptr.C = 0x4057a8 <vtable for D+56>,
z = -605902202
},
members of D:
d = 32763
}

虚表内存为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(gdb) x/176xb 0x405770
0x405770 <_ZTV1D>: 0x20 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x405778 <_ZTV1D+8>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x405780 <_ZTV1D+16>: 0xd0 0x55 0x40 0x00 0x00 0x00 0x00 0x00
0x405788 <_ZTV1D+24>: 0x10 0x2f 0x40 0x00 0x00 0x00 0x00 0x00
0x405790 <_ZTV1D+32>: 0x10 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x405798 <_ZTV1D+40>: 0xf0 0xff 0xff 0xff 0xff 0xff 0xff 0xff
0x4057a0 <_ZTV1D+48>: 0xd0 0x55 0x40 0x00 0x00 0x00 0x00 0x00
0x4057a8 <_ZTV1D+56>: 0xa0 0x2f 0x40 0x00 0x00 0x00 0x00 0x00
0x4057b0 <_ZTV1D+64>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x4057b8 <_ZTV1D+72>: 0xe0 0xff 0xff 0xff 0xff 0xff 0xff 0xff
0x4057c0 <_ZTV1D+80>: 0xe0 0xff 0xff 0xff 0xff 0xff 0xff 0xff
0x4057c8 <_ZTV1D+88>: 0xd0 0x55 0x40 0x00 0x00 0x00 0x00 0x00
0x4057d0 <_ZTV1D+96>: 0xd0 0x2f 0x40 0x00 0x00 0x00 0x00 0x00
0x4057d8 <_ZTV1D+104>: 0x80 0x2d 0x40 0x00 0x00 0x00 0x00 0x00
//下面为调试信息
0x4057e0 <_ZTV1D+112>: 0x47 0x43 0x43 0x3a 0x20 0x28 0x78 0x38
0x4057e8 <_ZTV1D+120>: 0x36 0x5f 0x36 0x34 0x2d 0x70 0x6f 0x73
0x4057f0 <_ZTV1D+128>: 0x69 0x78 0x2d 0x73 0x65 0x68 0x2d 0x72
0x4057f8 <_ZTV1D+136>: 0x65 0x76 0x30 0x2c 0x20 0x42 0x75 0x69
0x405800 <_ZTV1D+144>: 0x6c 0x74 0x20 0x62 0x79 0x20 0x4d 0x69
0x405808 <_ZTV1D+152>: 0x6e 0x47 0x57 0x2d 0x57 0x36 0x34 0x20
0x405810 <_ZTV1D+160>: 0x70 0x72 0x6f 0x6a 0x65 0x63 0x74 0x29
0x405818 <_ZTV1D+168>: 0x20 0x38 0x2e 0x31 0x2e 0x30 0x00 0x00

可以表示为下面这个结构

D的虚表结构

与非虚继承相似,通过虚继承产生的派生类在构造和析构时,所调用的虚函数只是当前阶段的的虚表中对应的函数。一个问题也就由此产生,由于在虚基类的不同的派生类中,虚基类相对于该类型的偏移量是可以不同的,如果直接使用2.3中的方法,直接用继承虚基类的类型自身的虚表作为构建该类时使用的虚表,会由于偏移量的不同,导致无法正确获取虚基类中的对象。
这个描述比较抽象拗口,我们通过3.1中的菱形继承的例子进行解释。四个类型A,B,C和D的继承关系如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class A{
int ax;
virtual void f0() {}
virtual void f1() {}
};

class B : virtual public A{
int bx;
void f0() override {}
};

class C : virtual public A{
int cx;
void f0() override {}
};
class 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时,其对象的内存布局和虚表布局如图所示:

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

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