本文主要记录一下有关C++中类的内存分配的问题,关于这一部分的内容还是挺复杂的,有可能会有描述的不准确的地方,欢迎一起讨论,共同进步!
目录
1、普通类对象的内存分配
2、含有虚函数的类对象的内存分配
3、普通派生类的内存分配
4、含有虚函数的派生类的内存分配
4.1、派生类没有重写基类的虚函数
4.2、派生类重写了基类的虚函数
4.3、派生类重写了基类的部分虚函数
5、多重继承
5.1、普通类的多重继承
5.2、含有虚函数的类的多重继承
5.2.1、派生类重写了所有基类的虚函数
5.2.2、派生类未重写基类的虚函数
5.2.3、派生类重写了部分基类的虚函数
5.2.4、例题
6、虚继承
6.1、单一虚继承
6.2、多重虚继承
6.3、虚继承中含有虚函数的情况
6.4 例题
现有如下类的定义:
class Name1{
public:
void f(){ cout << "Name1::f" << endl; }
void g(){ cout << "Name1::g" << endl; }
void h(){ cout << "Name1::h" << endl; }
void k(){
int a = 0;
printf("a \t addr: 0x%p\n", &a);
}
public:
int m1, m2;
};
该类中定义了两个成员变量,4个成员函数,则 sizeof(Name1) = 8,因为在类中,成员函数是不占用对象的内存空间的,只有成员变量会占用对象的内存空间。所以 sizeof(Name1) 只计算了两个成员变量的内存空间,在32位系统中,int型变量占用4字节,所以 sizeof(Name1) = 8(需要注意的是:如果类中没有定义这两个成员变量,则 sizeof(Name1) = 1,这是C++规定的)。在C++中类的成员成员成员函数是所有类对象所“共享的”,接下来会进行证明。
在类对象进行内存分配时,按照类的成员变量所占用的总字节数申请内存(需满足内存对齐的原则),其中内存空间是按照成员变量声明的顺序来申请的,然后对象的地址就是申请的总的内存空间首地址。我们通过以下函数打印地址进行查看(32位编译环境,所以地址长度为4字节,下同):
Name1 obj;
cout << "对象地址 -------------" << endl;
printf("obj \t addr: 0x%p\n", &obj);
cout << "\n成员变量地址 -------------" << endl;
printf("m1 \t addr: 0x%p\n", &obj.m1);
printf("m2 \t addr: 0x%p\n", &obj.m2);
打印出的结果如下:
可以看出,对象 obj 的地址和成员变量 m1 的地址是一样的,而 m2的地址 = m1的地址 + 4(int型所占字节)。
那成员函数的地址呢?由于成员函数不属于任何对象,所以只能采取如下方式进行打印:
cout << "\n成员函数地址 -------------" << endl;
printf("f() \t addr: 0x%p\n", &Name1::f); //成员函数f一定要有函数体的定义,下同
printf("g() \t addr: 0x%p\n", &Name1::g);
printf("h() \t addr: 0x%p\n", &Name1::h);
打印出的结果如下所示:
可以看出,成员函数的地址是和成员变量的地址是没有关系的,而且各成员函数的关系也没有一定的关联性。那怎么证明成员函数是所有类对象所共享的呢?通过Name1中的函数 k(),在函数k中,定义了一个局部变量 a,然后再定义两个对象,看a的地址是否相同,代码如下:
cout << "\nobj 中函数 k() 局部变量 a 的地址为 -------------" << endl;
obj.k();
cout << "\nobj2 中函数 k() 局部变量 a 的地址为 -------------" << endl;
Name1 obj2;
obj2.k();
结果如图所示:
可以看出,两个对象打印出的a 的地址是一样的。那不同的对象是怎么进行不同的操作呢?其实在C++中。在定义类的成员函数时,是隐藏了一个参数的,该参数的定义为 const Name1* this,就是我们常用的this指针,C++编译器根据不同的this指针执行不同对象的操作。
所以,普通类对象的内存分配模型如下所示:
含有虚函数的类的内存分配会比较复杂一些,主要是多了 vptr指针(即:指向虚函数表的指针),当一个类中包含了虚函数时,该类就自动包含了一个 vptr指针,这是编译器决定的,我们无法控制,而且通常我们看不到这个指针,其中虚函数表中存放了指向该类中定义的所有的虚函数的指针(即,函数指针),这样通过vptr就很容易的能够访问到该类的虚函数。
现有如下类的定义:
class Name2{
public:
void f(){ cout << "Name2::f" << endl; }
void g(){ cout << "Name2::g" << endl; }
virtual void vh(){ cout << "Name2::vh" << endl; }
virtual void vk(){ cout << "Name2::vk" << endl; }
public:
int m1 , m2;
};
此时的 szieof(Name2) = 12,就是因为类中定义了一个vptr指针,通常vptr指针存放在类对象内存空间的首地址,也即是类对象的地址。如下所示:
obj 对象的地址为 50,m1 的地址为54,就是因为首地址存放了vptr指针。
该类的成员函数,包括虚函数的地址都是没有关联的:
我们上面已经证明了 vptr指针的存在,那么我们怎么能证明 vptr 指针确实指向了虚函数表呢?我们可以通过vptr指针获取虚函数表,然后取出虚函数表中存放的函数指针,通过函数指针进行函数调用,然后看调用函数的结果是否是我们期望的就可以了。参考以下代码:
void play02(){
typedef void (*pFunc)(); //定义 void func(); 类型的函数指针类型
Name2 obj;
cout << "\n成员函数地址 -------------" << endl;
printf("vh() \t addr: 0x%p\n", &Name2::vh);
printf("vk() \t addr: 0x%p\n", &Name2::vk);
printf("虚函数表中第一个虚函数的函数指针 addr: 0x%p\n", *((int*)*(int*)&obj+0)); //
printf("虚函数表中第二个虚函数的函数指针 addr: 0x%p\n", *((int*)*(int*)&obj+1));
pFunc ph = (pFunc)*((int*)*(int*)&obj+0);
pFunc pk = (pFunc)*((int*)*(int*)&obj+1);
printf("函数指针调用结果:\n");
ph();
pk();
}
在上面获取虚函数指针的表达式 pFunc ph = (pFunc)*((int*)*(int*)&obj+0); 中,
1)、先通过 &obj 取得对象的地址(也即指向vptr指针的指针),通过(int *)转换成可操作的指针【(int *)&obj】;
2)、然后通过 * 解引用取得 vptr 指针的值,也即指向虚函数表的指针,也即虚函数表的首地址(类似于数组,int arr[10]; arr即使数组名,又表示数组指针,又表示数组的首地址,同时arr又能当做指针来用),通过 (int *) 转换成可操作的指针【(int*)*(int*)&obj】;
3)、取得虚函数表的首地址之后(实际上就是数组的首地址),就可以通过加\减操作取得数组成员了,+0获得第0个元素的指针,+1获得第1个元素的指针【(int*)*(int*)&obj + 0】;
4)、再然后通过 * 解引用就可获得数组中的元素了(函数指针)【*((int*)*(int*)&obj + 0)】;然后通过强制转换将指针转换成函数指针类型【(pFunc)*((int*)*(int*)&obj + 0)】;
获取虚函数指针还可以采取以下方式:
//首先获取obj的地址,也即内存首地址,首地址中存放的是指针(vptr),所以p 实际上就是指针的指针
void **p = (void **)&obj;
//*p取得的是vptr的值,vptr指向虚函数表,虚函数表中存放的是指针(函数指针),所以vptr实际上也是指针的指针
void **vptr = (void **)*p; //相当于 void *vptr[]; 表明是一个数组,数组中存放的是指针
printf("obj vh: %p\n", vptr[0]); //第一个虚函数的函数指针
printf("obj vk: %p\n", vptr[1]); //第二个虚函数的函数指针
pFunc ph = (pFunc)vptr[0];
pFunc pk = (pFunc)vptr[1];
printf("\n函数指针调用结果:\n");
ph();
pk();
打印结果如下所示:
由此可以看出虚函数表中存放的确实是对应的虚函数的函数指针。
细心的人可能发现了,上面打印的成员函数的地址和下面打印的虚函数表中存放的虚函数函数指针是不一致的,具体原因可参考下面一段话:“有些书上说是获取到虚函数在虚函数表中的索引。但是实际上VS编译器并不是这么做的,获取到的将会是一个用于虚函数调用的地址,这个地址上的指令会先获取虚函数表,然后再通过虚函数表获取虚函数地址相关的项。而GCC的做法又跟VS不同,通过&获取得的值都是1。总而言之,通过取地址&符号获得的不是函数地址,但是可以通过获得的值成功调用该函数。在vs中,成员函数指针不是指向函数的真实地址,而是先指向一个位置,该位置会调用Cbase::`vcall'{0}':之后才跳转到真正地址。不同的函数,Cbase::`vcall'{0}':中的数字不同。本质上还是编译时确定好每个函数的位置,通过此位置来查询虚函数表。链接:https://www.cnblogs.com/lgh1992314/p/5834839.html”;所以,为了理解方便,后面就不再考虑通过&来获取成员函数的地址了。
所以,含有虚函数的类对象的内存分配模型如下所示:
在上面这个图中,虚函数表的最后多加了一个结点,这是虚函数表的结束结点,就像字符串的结束符“\0”一样,其标志了虚函数表的结束。这个结束标志的值在不同的编译器下是不同的。在WinXP+VS2003下,这个值是NULL。而在Ubuntu 7.10 + Linux 2.6.22 + GCC 4.1.3下,这个值是如果1,表示还有下一个虚函数指针,如果值是0,表示是最后一个虚函数表已经结束。
现有如下类定义:
class Base{
public:
void f(){ cout << "Base::f" << endl; }
void g(){ cout << "Base::g" << endl; }
public:
int base_m1 , base_m2;
};
class Derived: public Base{
public:
void f1(){ cout << "Derived::f1" << endl; }
void g1(){ cout << "Derived::g1" << endl; }
public:
int derived_m1 , derived_m2;
};
打印成员变量的成员函数的地址结果如下:
对象的地址即为内存空间的首地址,先存放基类的成员变量,然后再存放自己(派生类)的成员变量,成员函数的地址依然没有什么关联。
所以,普通派生类的内存分配模型如下所示:
关于非虚函数的内存分配,没有太大的分析价值,接下来就不再考虑非虚函数的内存分配了。
现有如下类的定义:
class Base{
public:
virtual void f(){ cout << "virtual Base::f" << endl; }
virtual void g(){ cout << "virtual Base::g" << endl; }
public:
int base_m1 , base_m2;
};
class Derived: public Base{
public:
virtual void f1(){ cout << "virtual Derived::f1" << endl; }
virtual void g1(){ cout << "virtual Derived::g1" << endl; }
public:
int derived_m1 , derived_m2;
};
派生类Derived虽然继承了基类base,也继承了基类的虚函数,但是派生类并没有重写基类的虚函数,那此时的内存分配是什么样子呢?
打印成员变量的地址如下所示:
可以看到,基类的大小为12,派生类的大小为20,说明虽然派生类继承自基类,但并没有继承基类的 vptr 指针,每个类只包含一个 vptr 指针。
然后我们分别打印基类、派生类的虚函数的地址:
基类的:
派生类的:
typedef void (*pFunc)(); //定义 void func(); 类型的函数指针类型
Base base_obj;
void **p_base = (void **)&base_obj;
void **vptr_base = (void **)*p_base;
pFunc pf = (pFunc)vptr_base[0]; //虚函数 vf 的函数指针
pFunc pg = (pFunc)vptr_base[1]; //虚函数 vg 的函数指针
typedef void (*pFunc)(); //定义 void func(); 类型的函数指针类型
Derived derived_obj;
void **p_derived = (void **)&derived_obj;
void **vptr_derived = (void **)*p_derived;
pFunc pf = (pFunc)vptr_derived[0]; //继承自父类的虚函数 vf 的函数指针
pFunc pg = (pFunc)vptr_derived[1]; //继承自父类的虚函数 vg 的函数指针
pFunc pf1 = (pFunc)vptr_derived[2]; //虚函数 vf1 的函数指针
pFunc pg1 = (pFunc)vptr_derived[3]; //虚函数 vg1 的函数指针
可以很清楚的看到,在派生类的虚函数表中,优先存储的是基类的虚函数指针,然后再存储派生类的虚函数指针(按函数声明顺序),而且在派生类的虚函数表中,存储的继承自基类的虚函数指针与基类中存储的虚函数指针完全一致,这说明,如果派生类没有重写基类的虚函数,那么在进行多态操作时,如果调用派生类的虚函数(与基类同名的虚函数),是不会发生多态行为的,调用的仍然是基类的虚函数!
这种模式的内存分配模型为:
现有如下类定义:
class Base{
public:
virtual void vf(){ cout << "virtual Base::vf" << endl; }
virtual void vg(){ cout << "virtual Base::vg" << endl; }
public:
int base_m1 , base_m2;
};
class Derived: public Base{
public:
virtual void vf(){ cout << "virtual Derived::vf" << endl; }
virtual void vg(){ cout << "virtual Derived::vg" << endl; }
public:
int derived_m1 , derived_m2;
};
派生类Derived继承了基类base,也继承了基类的虚函数,并且派生类重写了基类的虚函数,那此时的内存分配是什么样子呢?
此时关于成员变量的内存分配和4.1章节的成员变量内存分配并没有什么区别,在此就不再赘述了。只分析有关虚函数部分的内存分配。
基类的:
派生类的:
通过打印的地址,可以很清楚的看到,当派生类重写了基类的虚函数时,虚函数表中存放的虚函数指针是不一样的,因为编译器给重写的虚函数重新分配了内存空间。此时,在派生类的虚函数表中,存储的继承自基类的虚函数指针与基类中存储的虚函数指针是不一致的,派生类中虚函数指针指向了自己的虚函数。这说明,如果派生类重写了基类的虚函数,那么在进行多态操作时,如果调用派生类的虚函数,那就会发生多态行为,调用的就会是派生类的虚函数!
这种模式的内存分配模型为:
以此类推,那如果派生类只重写了基类的部分虚函数呢?
代码如下所示:
class Base{
public:
virtual void vf(){ cout << "virtual Base::vf" << endl; }
virtual void vg(){ cout << "virtual Base::vg" << endl; }
public:
int base_m1 , base_m2;
};
class Derived: public Base{
public:
virtual void vf(){ cout << "virtual Derived::vf" << endl; }
virtual void vg1(){ cout << "virtual Derived::vg1" << endl; }
public:
int derived_m1 , derived_m2;
};
派生类Derived继承了基类base,也继承了基类的虚函数,但派生类只重写了基类的虚函数 vf,没有重写vg,那此时的内存分配是什么样子呢?
先打印地址看看:
基类的:
派生类的:
可以看出,因为派生类只重写了基类的 vf 虚函数,所以派生类中指向 vf 的函数指针和基类中的不一样,指向 vg 虚函数的指针是一样的。
这种模式的内存分配模型为:
现有如下代码:
class BaseA{
public:
int base_m1;
};
class BaseB{
public:
int base_m2;
};
class Derived: public BaseA, public BaseB{
public:
int derived_m3;
};
这种模式的内存分配模型为:
含有虚函数的类的多重继承,和前面提到的虚函数的单继承有很大的不同,现有如下基类定义:
class BaseA{
public:
virtual void vf(){ cout << "virtual BaseA::vf" << endl; }
public:
int base_m1;
};
class BaseB{
public:
virtual void vg(){ cout << "virtual BaseB::vg" << endl; }
public:
int base_m2;
};
有如下子类的定义:
class Derived: public BaseA, public BaseB{
public:
virtual void vf(){ cout << "virtual Derived::vf" << endl; }
virtual void vg(){ cout << "virtual Derived::vg" << endl; }
public:
int derived_m3;
};
打印成员变量地址如下图所示:
从上图中可以看到,派生类 Derived 的大小位20,其中3个int型变量的大小位12,这是为什么呢?这是因为,在派生类 Derived 中定义了两个 vptr 指针,上图中,对象 derived_obj的地址为 xxCDC,baseA_m1 的地址为 xxCE0,相差了4个字节,baseB_m2 的地址为 xxE8,相差了8个字节,这说明,在 baseA_m1 和 baseB_m2 之间还有一个vptr 指针。其中第一个 vptr 指针是为了处理继承的第一个类BaseA(派生类声明中的基类顺序),第二个 vptr 指针是为了处理继承的第二个类 BaseB(以此类推)。接下来进行证明。
打印虚函数地址如下所示:
可以看到,派生类 Derived 中因为重写了基类的虚函数,所以,虚函数表中存储的虚函数指针与基类中存储的虚函数指针是不一样的,且执行的函数体也是不一样的。
在这里需要注意的是 derived_obj vg 虚函数指针的打印的,根据内存分配,第二个vptr指针是在基类BaseA所有成员(包括第一个vptr指针)之后的,所以在,提取 第二个vptr的地址时,需要加上sizeof(BaseA)的值,如下所示:
Derived derived_obj;
void **p_vptrB = (void **)((char *)&derived_obj + sizeof(BaseA)); //需加上 sizeof(BaseA)
void **vptrB_derived = (void **)*p_vptrB;
pFunc pg = (pFunc)vptrB_derived[0];
printf("derived_obj vg 虚函数指针: : %p\n", pg);
这种模式的内存分配模型为:
现有如下子类定义:
class Derived: public BaseA, public BaseB{
public:
virtual void vf1(){ cout << "virtual Derived::vf" << endl; }
virtual void vg1(){ cout << "virtual Derived::vg" << endl; }
public:
int derived_m3;
};
打印成员变量地址如下图所示:
打印虚函数地址如下所示:
可以看到,派生类 Derived 中因为没有重写基类的虚函数,所以,虚函数表中存储的虚函数指针与基类中存储的虚函数指针是一样的,且执行的函数体也是一样的。而且因为没有重写基类的虚函数,第一个vptr指针指向的虚函数表中也保存了 Derived 类本身所定义的虚函数的指针。
这种模式的内存分配模型为:
现有如下子类定义:
class Derived: public BaseA, public BaseB{
public:
virtual void vf(){ cout << "virtual Derived::vf" << endl; } //重写了BaseA的虚函数
virtual void vg1(){ cout << "virtual Derived::vg1" << endl; }
public:
int derived_m3;
};
打印成员变量地址如下图所示:
打印虚函数地址如下所示:
这种模式的内存分配模型为:
现有如下题目:
结果应该是 pA == pC != pB,其中pB = pA(pC) + sizeof(ClassA),打印结果如下图所示:
具体原因请参考5.2章节 虚函数多重继承中的内存分配模型,这就或许能够解释当派生类指针赋值给基类后,基类指针仍能调用子类函数的原因吧。
关于虚继承的概念,可以参考这篇文章:http://c.biancheng.net/view/2280.html。从这篇文章中可以知道,虚继承就是为了解决多继承时的命名冲突和冗余数据问题,使得在派生类中只保留一份间接基类的成员。所以,在分析虚继承的内存分配时,只考虑单一虚继承是没有意义的,下面直接分析一下多重虚继承(典型的菱形继承)时的内存分配。
现有如下类的定义:
class A{ //基类
public:
int dataA;
};
class B : virtual public A{
public:
int dataB;
};
class C : virtual public A{
public:
int dataC;
};
class D: public B, public C{
public:
int dataD;
};
单一虚继承就是只考虑到类B、类C的程度,打印类 B 和C 的成员变量地址分别如下所示:
可以看到虽然B类只包含了两个成员变量 dataB和dataA,但是B类的大小却为12,类C也是一样的。这是因为在类B和C中分别定义了一个vptr指针,而且在类B中,B类的成员是在A类成员之前的,这一点和之前第3章 普通派生类的内存分配是不同的!
通过visual studio2012自带的命令行提示工具,通过cl(CL)工具进行分析(cl命令的使用可参考博客:https://blog.csdn.net/xiejingfa/article/details/48028491)可以看到B类、C类的内存分配情况(下图中的 vbptr 就是前文中一直提到的 vptr 指针,这个 vbptr指针都指向A类(父类)虚函数表):
所以,类B、类C的内存分配模型就如下所示:
只考虑单一虚继承是体现不来虚继承的优势的,下面分析一下类D的内存分配情况;打印D的成员变量地址如下图所示:
首先,D的大小为24,但是只有4个成员变量,说明内存中还有两个4字节的内容,是什么呢?在这里,这两个4字节的内存分别是两个vptr指针,一个是基类B的,一个基类C的。可以看出,在D类中,首先存放基类B(继承的第一个类)的vptr指针和成员变量,接下来存放基类C的vptr指针和成员变量,紧接着存放D(自己的)成员变量,最后是间接基类A的成员变量(只有一个)。
c1工具进行分析结果如下:
所以,类D的内存分配模型就如下所示:
现有如下类的定义:
class A1{
public:
virtual void vf(){ cout << "virtual A1::vf" << endl; }
int dataA;
};
class B1 : virtual public A1{
public:
virtual void vf(){ cout << "virtual b1::vf" << endl; }
int dataB;
};
打印成员变量如下所示:
会发现此时B1的大小为16,比6.1章节中的B类多了4字节,多出来的4字节是什么呢?是指向类B1的虚函数表的vptr指针。可以看出,在B1类中,首先存放B1类的vptr指针和成员变量,接下来存放基类A1的vptr指针和成员变量。
该模式下的内存分配模型应该如下所示:
现有如下题目:
1、
结果应该是 pA != pC == pB,其中pA = pB(pC) + sizeof(ClassB) + sizeof(ClassC),打印结果如下图所示:
ClassC 虚继承了 ClassA,正常继承了 ClassB
2、
结果应该是 pA != pC != pB,其中pA = pC + sizeof(ClassC); pB = pA + sizeof(ClassB),打印结果如下图所示: