c++中类所占的大小计算并没有想象中那么简单,因为涉及到虚函数成员,静态成员,虚继承,多继承以及空类等,不同情况有对应的计算方式,在此对各种情况进行总结。
首先要明确一个概念,平时所声明的类只是一种类型定义,它本身是没有大小可言的。 我们这里指的类的大小,其实指的是类的对象所占的大小。因此,如果用sizeof运算符对一个类型名操作,得到的是具有该类型实体的大小。
关于类/对象大小的计算
- 首先,类大小的计算遵循结构体的对齐原则
- 类的大小与普通数据成员有关,与成员函数和静态成员无关。即普通成员函数,静态成员函数,静态数据成员,静态常量数据成员均对类的大小无影响
- 虚函数对类的大小有影响,是因为虚函数表指针带来的影响
- 虚继承对类的大小有影响,是因为虚基表指针带来的影响
- 空类的大小是一个特殊情况,空类的大小为1
解释说明
静态数据成员之所以不计算在类的对象大小内,是因为类的静态数据成员被该类所有的对象所共享,并不属于具体哪个对象,静态数据成员定义在内存的全局区。
空类的大小,以及含有虚函数,虚继承,多继承是特殊情况,接下来会一一举例说明
循结构体的对齐原则
为了访问速度和效率,需要各种类型数据按照一定的规则在空间上排列;不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。为了访问未对⻬的内存,处理器需要作两次内存访问;⽽对⻬的内存访问仅需要⼀次访问。
于是有了字节对齐,4个字节是一个自然对齐
为什么是4个字节?
32位机,即计算机数据总线宽度为32个,一次可以处理32位bit(即4个字节)64位机,就是8字节;
举个例子
struct MyStruct
{
char a;
//偏移量为0,满足对齐方式,a占用1个字节,(最大类型字节数为8,占8字节);
double b;
//下一个可用的地址的偏移量为1,不是sizeof(double)=8的倍数,需要补足7个字节才能使偏移量变为8(满足对齐方式),因此自动填充7个字节,b存放在偏移量为8的地址上,它占用8个字节。
int c;
//下一个可用的地址的偏移量为16,是sizeof(int)=4的倍数,满足int的对齐方式,所以不需要VC自动填充,c存放在偏移量为16的地址上,它占用4个字节。
};
//所有成员变量都分配了空间,空间总的大小为1+7+8+4=20,不是结构的节边界数(即结构中占用最大空间的类型所占用的字节数sizeof(double)=8)的倍数,
//所以需要填充4个字节,以满足结构的大小为sizeof(double)=8的倍数,24字节大小
一.简单情况的计算
class Base
{
public:
Base() {
};
~Base() {
};
private:
static int a;
int b;
char c;
};
计算结果:8
静态变量a不计算在对象的大小内,由于字节对齐,结果为4+4=8
二.空类的大小
C++的空类是指这个类不带任何数据,即类中没有非静态(non-static)数据成员变量,没有虚函数(virtual function),也没有虚基类(virtual base class)。
直观地看,空类对象不使用任何空间,因为没有任何隶属对象的数据需要存储。然而,C++标准规定,凡是一个独立的(非附属)对象都必须具有非零大小。换句话说,c++空类的大小不为0
class Base
{
};
Base base;
cout << sizeof(base) << endl; // 1
C++标准指出,不允许一个对象(当然包括类对象)的大小为0,不同的对象不能具有相同的地址。这是由于:
new需要分配不同的内存地址,不能分配内存大小为0的空间
避免除以 sizeof(T)时得到除以0错误
故使用一个字节来区分空类。
但是,有两种情况值得我们注意
第一种情况,涉及到空类的继承。
当派生类继承空类后,派生类如果有自己的数据成员,而空基类的一个字节并不会加到派生类中去。例如
class Empty {
};
struct D : public Empty {
int a;};
sizeof(D)为4。
第二种情况,一个类包含一个空类对象数据成员。
class Empty {
};
class HoldsAnInt {
int x;
Empty e;
};
sizeof(HoldsAnInt)为8。
因为在这种情况下,空类的1字节是会被计算进去的。而又由于字节对齐的原则,所以结果为4+4=8。
继承空类的派生类,如果派生类也为空类,大小也都为1
三.含有虚函数成员
//环境VS2015 x64
首先,要介绍一下虚函数的工作原理:
虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的。编译器必需要保证虚函数表的指针存在于对象实例中最前面的位置(这是为了保证正确取到虚函数的偏移量)。
每当创建一个包含有虚函数的类或从包含有虚函数的类派生一个类时,编译器就会为这个类创建一个虚函数表(VTABLE)保存该类所有虚函数的地址,其实这个VTABLE的作用就是保存自己类中所有虚函数的地址,可以把VTABLE形象地看成一个函数指针数组,这个数组的每个元素存放的就是虚函数的地址。在每个带有虚函数的类 中,编译器秘密地置入一指针,称为v p o i n t e r(缩写为V P T R),指向这个对象的V TA B L E。 当构造该派生类对象时,其成员VPTR被初始化指向该派生类的VTABLE。所以可以认为VTABLE是该类的所有对象共有的,在定义该类时被初始化;而VPTR则是每个类对象都有独立一份的,且在该类对象被构造时被初始化。
假设我们有这样的一个类:
class Base {
public:
virtual void f() {
cout << "Base::f" << endl; }
virtual void g() {
cout << "Base::g" << endl; }
virtual void h() {
cout << "Base::h" << endl; }
};
当我们定义一个这个类的实例,Base b时,其b中成员的存放如下:
指向虚函数表的指针在对象b的最前面。虚函数表的最后多加了一个结点,这是虚函数表的结束结点,就像字符串的结束符”\0”一样,其标志了虚函数表的结束。这个结束标志的值在不同的编译器下是不同的。在vs下,这个值是NULL。而在linux下,这个值是如果1,表示还有下一个虚函数表,如果值是0,表示是最后一个虚函数表。
因为对象b中多了一个指向虚函数表的指针,而指针的sizeof是8,因此含有虚函数的类或实例最后的sizeof是实际的数据成员的sizeof加8。
例如:
class Base {
public:
int a;
virtual void f() {
cout << "Base::f" << endl; }
virtual void g() {
cout << "Base::g" << endl; }
virtual void h() {
cout << "Base::h" << endl; }
};
sizeof(Base)为16。
vptr指针的大小为8,又因为对象中还包含一个int变量,字节对齐得8+8=16。
下面将讨论针对基类含有虚函数的继承讨论:
(1)在派生类中不对基类的虚函数进行覆盖,同时派生类中还拥有自己的虚函数,比如有如下的派生类:
class Derived: public Base
{
public:
virtual void f1() {
cout << "Derived::f1" << endl; }
virtual void g1() {
cout << "Derived::g1" << endl; }
virtual void h1() {
cout << "Derived::h1" << endl; }
};
基类和派生类的关系如下:
当定义一个Derived的对象d后,其成员的存放如下:
可以发现:
1)虚函数按照其声明顺序放于表中。
2)基类的虚函数在派生类的虚函数前面。
此时基类和派生类的sizeof都是数据成员的大小+指针的大小8。
(2)在派生类中对基类的虚函数进行覆盖,假设有如下的派生类:
class Derived: public Base
{
public:
virtual void f() {
cout << "Derived::f" << endl; }
virtual void g1() {
cout << "Derived::g1" << endl; }
virtual void h1() {
cout << "Derived::h1" << endl; }
};
基类和派生类之间的关系:其中基类的虚函数f在派生类中被覆盖了
当我们定义一个派生类对象d后,其d的成员存放为:
可以发现:
1)覆盖的f()函数被放到了虚表中原来基类虚函数的位置。
2)没有被覆盖的函数依旧。
派生类的大小仍是基类和派生类的非静态数据成员的大小+一个vptr指针的大小
这样,我们就可以看到对于下面这样的程序,
Base *b = new Derive();
b->f();
由b所指的内存中的虚函数表的f()的位置已经被Derive::f()函数地址所取代,于是在实际调用发生时,是Derive::f()被调用了。这就实现了多态。
(3)多继承:无虚函数覆盖
假设基类和派生类之间有如下关系:
对于派生类实例中的虚函数表,是下面这个样子:
我们可以看到:
1) 每个基类都有自己的虚表。
2) 派生类的成员函数被放到了第一个基类的表中。(所谓的第一个基类是按照声明顺序来判断的)
由于每个基类都需要一个指针来指向其虚函数表,因此d的sizeof等于d的数据成员加上二个指针的大小。
(4)多重继承,含虚函数覆盖
假设,基类和派生类又如下关系:派生类中覆盖了基类的虚函数f
下面是对于派生类实例中的虚函数表的图:
我们可以看见,三个基类虚函数表中的f()的位置被替换成了派生类的函数指针。这样,我们就可以任一静态类型的基类类来指向派生类,并调用派生类的f()了。如:
Derive d;
Base1 *b1 = &d;
Base2 *b2 = &d;
Base3 *b3 = &d;
b1->f(); //Derive::f()
b2->f(); //Derive::f()
b3->f(); //Derive::f()
b1->g(); //Base1::g()
b2->g(); //Base2::g()
b3->g(); //Base3::g()
此情况派生类的大小也是类的所有非静态数据成员的大小+三个指针的大小
举一个例子具体分析一下大小吧:
#include<iostream>
using namespace std;
class A
{
};
class B
{
char ch;
virtual void func0() {
}
};
class C
{
char ch1;
char ch2;
virtual void func() {
}
virtual void func1() {
}
};
class D: public A, public C
{
int d;
virtual void func() {
}
virtual void func1() {
}
};
class E: public B, public C
{
int e;
virtual void func0() {
}
virtual void func1() {
}
};
int main(void)
{
cout<<"A="<<sizeof(A)<<endl; //result=1
cout<<"B="<<sizeof(B)<<endl; //result=16
cout<<"C="<<sizeof(C)<<endl; //result=16
cout<<"D="<<sizeof(D)<<endl; //result=24
cout<<"E="<<sizeof(E)<<endl; //result=40
return 0;
}
结果分析:
//Win64
1.A为空类,所以大小为1
2.B的大小为char数据成员大小+vptr指针大小。由于字节对齐,大小为8+8=16
3.C的大小为两个char数据成员大小+vptr指针大小。由于字节对齐,大小为8+8=16
4.D为多继承派生类,由于D有数据成员,所以继承空类A时,空类A的大小1字节并没有计入当中,D继承C,此情况D只需要一个vptr指针,所以大小为数据成员加一个指针大小。由于字节对齐,大小为16+8=24
5.E为多继承派生类,此情况为我们上面所讲的多重继承,含虚函数覆盖的情况。此时大小计算为基类大小加本地数据
考虑字节对齐,结果为16+16+8=40
四.虚继承的情况
虚继承和虚函数是完全无相关的两个概念。
虚继承是解决C++多重继承问题的一种手段,从不同途径继承来的同一基类,会在子类中存在多份拷贝。这将存在两个问题:其一,浪费存储空间;第二,存在二义性问题,通常可以将派生类对象的地址赋值给基类对象,实现的具体方式是,将基类指针指向继承类(继承类有基类的拷贝)中的基类对象的地址,但是多重继承可能存在一个基类的多份拷贝,这就出现了二义性。
虚继承可以解决多种继承前面提到的两个问题:
虚继承底层实现原理与编译器相关,一般通过虚基类指针和虚基类表实现,每个虚继承的子类都有一个虚基类指针(占用一个指针的存储空间,4字节)和虚基类表(不占用类对象的存储空间)(需要强调的是,虚基类依旧会在子类里面存在拷贝,只是仅仅最多存在一份而已,并不是不在子类里面了);当虚继承的子类被当做父类继承时,虚基类指针也会被继承。
实际上,vbptr指的是虚基类表指针(virtual base table pointer),该指针指向了一个虚基类表(virtual table),虚表中记录了虚基类与本类的偏移地址;通过偏移地址,这样就找到了虚基类成员,而虚继承也不用像普通多继承那样维持着公共基类(虚基类)的两份同样的拷贝,节省了存储空间。
在这里我们可以对比虚函数的实现原理:他们有相似之处,都利用了虚指针(均占用类的存储空间)和虚表(均不占用类的存储空间)。
虚基类依旧存在继承类中,只占用存储空间;虚函数不占用存储空间。
虚基类表存储的是虚基类相对直接继承类的偏移;而虚函数表存储的是虚函数地址。
虚继承(多重继承和虚函数)
//win64
class CommonBase
{
int co;
};// size = 4
class Base1: virtual public CommonBase
{
public:
virtual void print1() {
}
virtual void print2() {
}
private:
int b1;
};//8副本+8虚指针+8自身+8(虚继承+虚函数构成指针多一个)=32
//此时字节向8对齐,所以基类和自身int此时占8字节,
//字节对齐详细看最开头部分
class Base2: virtual public CommonBase
{
public:
virtual void dump1() {
}
virtual void dump2() {
}
private:
int b2;
};//同理32
class Derived: public Base1, public Base2
{
public:
void print2() {
}
void dump2() {
}
private:
int d;
};//32+32-8+8
解析:如果不是虚继承的类,即便有虚函数也不会因此增加存储空间,如果是虚继承的类,没有虚函数就添加一个虚指针空间,有虚函数不论多少个,就添加两个虚指针空间
虚继承与虚函数
class A
{
public:
virtual void aa() {
}
virtual void aa2() {
}
private:
char ch[3];
}; // 8+8(虚指针) = 补齐 = 16
class B: virtual public A
{
public:
virtual void bb() {
}
virtual void bb2() {
}
}; // 16(副本)+8(虚继承)+8(虚指针) = 32
int main(void)
{
cout<<"A's size is "<<sizeof(A)<<endl;// 16
cout<<"B's size is "<<sizeof(B)<<endl;// 32
return 0;
}