1 多态类型
在C++中,多态类型是指声明或者继承了至少一个虚函数的类型,反之则为非多态类型。
对于非多态类型的变量,其类型信息都可以在编译时确定。例如:
struct C
{
/* data */
void foo() {}
};
C c; std::cout << typeid(c).name(); // 可以在编译时确定a的类型为A
c.foo(); // 可以在编译时确定C::foo在内存中的地址
sizeof(c); // 尽管C为空,但由于需要在内存中确定c的地址,因此A类型对象所占空间为1个字节
而对于多态类型,一些信息必须延迟到运行时才可以确定,例如它的实际类型、所调用的虚函数的地址等。下面的这个例子中,类型B继承了声明有虚函数的类型A,因此A和B都是多态类型。
struct A
{
/* data */
virtual void foo() {}
};
struct B: public A
{
/* data */
};
B b;
A& a_rb = b; // 将b绑定到A的左值引用a_rb上
typeid(decltype(a_rb)).name(); //编译时类型推导 c++0x 特性 output: 1A
typeid(a_rb).name(); //运行时输出类型 output: 1B
a_rb.foo(); // 这里调用的是B中的foo,其函数地址是运行时确定的 (编译时看父类 运行时看子类)
sizeof(b); // //output:8 由编译器决定 64位os 指针大小为8Byte
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() {}; // 重写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() {}
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() {}
void f1() {}
};
与单链继承不同,由于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()。
后续内容有空继续补充。