C++对象的内存布局
1、多态与虚表
多态简单来说就是基类指针或引用具有多种形态---当它指向一个派生类对象时,就会调用子类的成员方法,而非基类方法;更形象的来说,多态即不同对象对于同一种操作或者行为展现出来的不同表达方式;
在C++中多态可分为静态多态和动态多态,两者的核心区别在于多态的绑定时机(编译期还是运行期)。静态多态是指在编译阶段就确定了调用哪个函数,主要分为函数重载和模板。动态多态是指在运行阶段才能确定调用哪个函数,需要通过虚函数表和继承来实现;
静态多态:函数重载即在同一作用域下,定义了多个函数名相同但参数列表不同(参数的个数、类型、顺序)的函数(需要注意的是函数重载与返回值无关),编译器会根据实参的具体类型匹配对应的函数;通过模板可以进行泛型编程,编译器会根据传入参数的具体类型实例化出不同的函数或类,实现(相同接口,不同类型)的多态;
动态多态:动态多态的实现需要三个条件:
a) 基类中声明虚函数(virtual关键字);
b) 派生类中进行虚函数的重写;
c) 通过基类指针或者引用调用虚函数;
核心原理:编译器会为含有虚函数的类自动生成一个虚函数表(vtable)和指向虚函数表指针(vptr),(vptr指向vtable,也就是vptr中存放的数据就是vtable的地址)。vptr一般都放在对象内存布局的第一个位置上,这是为了保证在多层继承或多重继承的情况下能以最高效率取到vtable。vtable中会按照虚函数的声明顺序存放虚函数的地址(虚函数的函数指针),在程序运行的过程中会通过vptr找到对应的vtable调用实际类型的虚函数;
为了验证上述对象的存储模型,下面本文将一一进行验证。
class Base
{
public:Base(int num) :_baseNum(num) {}virtual void SetNum(void){ cout << "调用了虚函数Base::SetNum()" << endl;}virtual void Print(void){ cout << "调用了虚函数Base::Print()" << endl;}virtual ~Base() {}
private:int _baseNum;
};
现在可以利用调试功能看一下vs上的内存布局:
展开vfptr,可以看到虚函数表中存在三个虚函数:
现在利用代码来验证:
using Func = void(*)(void); /* same as: typedef void(*Func)(void) */void testBase(Base& p)
{int* vptrAddress = (int*)(&p);cout << "vptr地址:" << vptrAddress << endl;printf("vfptr地址(vptr): %08x\n", *vptrAddress);//验证虚表printf("虚函数表第一个函数的地址vtable[0]: %08x\n", *(int*)*vptrAddress);Func IsSetI = (Func)*(int*)*vptrAddress;IsSetI();printf("虚函数表第二个函数的地址vtable[1]: %08x\n", *((int*)*vptrAddress + 1));Func IsPrintf = (Func) (*((int*)*vptrAddress + 1));IsPrintf();
}int main()
{Base base(1000);testBase(base);return 0;
}
说明:&p 是引用 p
所指向的 Base
对象的起始地址,也就是上图中的0xFFFAAC。在上面我们说过编译器在对象最开始的位置加上虚函数表指针vptr。
(int*)(&p):vptr的地址,&p为base对象的地址,我们需要取出前4字节(Win32指针大小)的内容所以强转为(int*),关于为什么要强转为int*的问题,解释如下:int*可以在解引用之后控制+1跳过的字节数使其保持在4字节,达到在Win32环境下一个指针的大小,后续就可以直接使用指针直接进行访问;
*(int*)(&p):就是上图中的0X00ee9b34,就是vptr的内容,也就是虚函数表的地址。但是在内存中0X00ee9b34是以int的形式存在的,如果我们需要访问虚函数表的内容就需要把vptr的内容强转为(int*)在进行解引用, 即*(int*)*(int*)(&p)就是虚函数表中的第一个虚函数的函数指针;第二个虚函数的地址就是vptr的内容+4字节再解引用即*((int*)*(int*)(&p) + 1);
2、多继承、菱形继承与菱形虚拟继承
多继承:假设存在派生类继承自两个独立的基类(基类1和基类2),那么这个派生类就会有两个虚函数表指针,分别指向基类1和基类2的虚函数表。当派生类中有自己独立的虚函数时,派生类新增的虚函数往往会被添加到第一个基类的虚函数表中。如果派生类中重写了基类1或者基类2的虚函数,那么重写之后的虚函数指针会替换对应基类虚函数表中的原函数指针,以保证多态的正确性(当派生类对象的引用或者指针赋值给基类1或者基类2的引用或指针都能正确的进行多态的调用)。内存布局中的成员变量的顺序为:按继承顺序排序(先基类1中的成员变量,再基类2中的成员变量);
class Base
{
public:Base(int i) :baseI(i) {};virtual ~Base() {}int getI() { return baseI; }static void countI() {};virtual void print(void) { cout << "Base::print()"; }private:int baseI;static int baseS;
};
class Base_2
{
public:Base_2(int i) :base2I(i) {};virtual ~Base_2() {}int getI() { return base2I; }static void countI() {};virtual void print(void) { cout << "Base_2::print()"; }private:int base2I;static int base2S;
};class Drive_multyBase :public Base, public Base_2
{
public:Drive_multyBase(int d) :Base(1000), Base_2(2000), Drive_multyBaseI(d) {};virtual void print(void) { cout << "Drive_multyBase::print"; }virtual void Drive_print() { cout << "Drive_multyBase::Drive_print"; }private:int Drive_multyBaseI;
};
内存布局:
菱形继承:菱形继承是一种典型的多继承场景,因继承关系图为菱形而得名。它描述的是同一个派生类同时继承自两个子类,而这两个子类又共同继承自同一个基类。
从上面的继承关系,可以发现菱形继承存在数据冗余和二义性的问题,当Assistant对象需要访问_name成员属性时存在访问二义性问题,且Assistant对象内部存在两份_name成员属性产生数据冗余问题。为了解决菱形继承产生的问题,C++的设计者们提出了菱形虚拟继承克服存在的难题。
菱形虚拟继承:核心为通过特殊的继承方式,确保菱形结构中最顶层的基类在最终的派生类中只存在一份份实列,从而消除数据冗余和访问二义性问题。
为什么Student对象和Teacher对象需要找到公共的_name这个成员属性呢?原因在于当派生类Assistant对象赋值给Student或者Teacher对象时会发生对象切片,就需要找到Student或者Teacher对象对应的_name数据。