本篇主要给大家分享类对象在内存中是如何进行内存分配的。咱们都知道类包含属性和方法,那么,类属性和方法在内存中是如何进行分配的呢?
一、对象大小
以下代码声明了一个Base类,类中有成员方法、成员属性 、静态方法、静态属性,那么,根据这个类的定义创建一个类对象,所占的内存空间为多少呢?
#include <iostream>
using namespace std;
class Base{
public:
void f(){};//成员方法
void g(){};//成员方法
static void s_f(){};//静态方法
public:
int x,y;成员属性
static int z;//静态属性
};
int main(int argc, char const *argv[])
{
Base base;
cout << "size of base = " << sizeof(base) << endl;
cout << "address of base = " << &base << endl;
cout << "address of x = " << &base.x << endl;
cout << "address of y = " << &base.y << endl;
return 0;
}
程序输出:
类对象大小为8, 这说明类对象的大小和成员属性相关! 大家可以自己修改成员属性的类型或者新增成员属性进行测试!并且base对象的地址和属性x的地址是相同的!
为什么会这样呢?
类定义其实是一个模子,编译器根据这个模子的定义来创建一个类对象,那么每个类对象就应该有自己的独立内存空间!咱们前面一节讲到内存中有一块叫代码区的地方,那里应该是来存放咱们类定义的地方(类的模子)。
大家再仔细想想,如果将类的所有信息(成员和属性)都复制一份到类对象中,是不是需要更多的内存,如果类对象非常多,这个开销是非常大!所以,能将更多信息和对象进行分离是C++编译器充分抽象和设计的结果。
类成员属性肯定是需要跟类对象走的,这个应该很好理解,所以刚才的程序输出的大小就是 int x, y; 的大小 8 ;而成员方法、静态方法、静态属性是不跟类对象走的,所以这些信息是不占类对象的空间大小的。
这就是单个对象的内存模型,即,在没有虚函数的情况下,只存储非静态成员变量。
如果有虚函数,那么又会不一样!下面将成员函数 g() 定义为虚函数:
#include <iostream>
using namespace std;
class Base{
public:
void f(){};//成员方法
virtual void g(){};//虚函数
static void s_f(){};//静态方法
public:
int x,y;成员属性
static int z;//静态属性
};
int main(int argc, char const *argv[])
{
Base base;
cout << "size of base = " << sizeof(base) << endl;
cout << "address of base = " << &base << endl;
cout << "address of x = " << &base.x << endl;
cout << "address of y = " << &base.y << endl;
return 0;
}
程序输出:
此时,类对象大小为 16 , 这说明类对象的大小还和虚函数有关系!
这里要注意,如果有虚函数则会在内存中开辟一个虚函数表,虚函数表是一个数组结构;类对象中会新增一个指针来指向该虚函数表(虚函数表是类实现多态的重要手段),在我们这个示例中可以看到,base的地址和成员x的地址相差了8个字节,这新增的8字节就是用来保存虚函数表地址的!
二、成员函数和静态函数的区别
成员函数与静态函数都是不占对象的内存,那么,对象是如何能够调用这些函数的呢?或者说成员函数与静态函数有什么区别呢?
下面代码演示如何通过函数对象/函数指针的方式去调用成员函数和静态函数:
#include <iostream>
#include <functional>
using namespace std;
class Base{
public:
void f(){
cout << "member function f() \n";
};//成员方法
virtual void g(){};//虚函数
static void s_f(){
cout << "static function f() \n";
};//静态方法
public:
int x,y;成员属性
static int z;//静态属性
};
int main(int argc, char const *argv[])
{
Base base;
base.f();
base.s_f();
function<void(Base*)> fun1 = &Base::f;//通过函数对象调用成员方法
function<void()> fun2 = &Base::s_f;//通过函数对象调用静态方法
fun1(&base);
fun2();
return 0;
}
上面的代码能够正确编译并且正确执行!通过function对象的定义,我们能够看出成员函数和静态函数在底层的实现上是有区别的,成员函数要比静态函数多一个Base*的参数,这个参数在cpp代码中是没有的,是编译之后自动加上的!
这个自动加上的参数也就是咱们this指针的由来!当你要将成员函数声明为const类型,实际编译器会将自动加上的参数添加上const修饰符!形如:void f(const Base* this)
三、虚函数表
在没有继承的情况下,虚函数会让类对象的大小多8个字节用来存储虚函数表的地址,并且,这个过程是在编译阶段自动加上的,该地址存储在对象的起始地址,这个结论在前面已经得到验证!
下面就看看虚函数表到底是长什么样的:
#include <iostream>
using namespace std;
class Base
{
private:
int x, y;
public:
Base(){}
virtual ~Base(){
cout << "Base Destructor" << endl;
}
virtual void test(){
cout << "call member function" << endl;
}
virtual void testInt(int n){
cout << "call member function int n = " << n << endl;
}
static void staticFunc(){
cout << "static call function " << endl;
}
};
int main(){
typedef void (*pClassFunc1)(void*);//类成员函数指针
typedef void (*pClassFunc2)(void*, int);//类成员函数指针
Base base;
printf("base %p \n", ((pClassFunc1**)&base)[0][0]);//base 0x4012f8
printf("base %p \n", ((pClassFunc1**)&base)[0][1]);//base 0x401330
printf("base %p \n", ((pClassFunc1**)&base)[0][2]);//base 0x40135c
printf("base %p \n", ((pClassFunc2**)&base)[0][3]);//base 0x401388
((pClassFunc1**)&base)[0][0](&base);//调用complete析构函数
((pClassFunc1**)&base)[0][1](&base);//调用deleting析构函数
((pClassFunc1**)&base)[0][2](&base);//调用test函数
((pClassFunc2**)&base)[0][3](&base, 1);//调用testInt函数
return 0;
}
通过上面的代码,我们直接将对象头部8字节地址取出来,然后根据该地址存储的信息去找到虚函数表,再像数组一样一个个去取到虚函数的地址。这里会生成4个虚函数,每个虚函数的地址不一样并且不连续! 虚函数信息如下:
Base类中虚函数表信息结构如下图:
通过上图,我们可以看到虚函数表中的函数的顺序是和声明的顺序一致,谁先声明为virtual,那么谁就在虚函数表中的顺序排前面。
四、内存对齐
类对象的大小还受到内存对齐的影响。也就是类成员属性在内存中存储并不一定是连续的。例如上例中的Base类的大小为16,因为刚好内存是对齐的,如果新增一个属性z:
class Base
{
private:
int x, y, z;
public:
Base(){}
virtual ~Base(){
cout << "Base Destructor" << endl;
}
virtual void test(){
cout << "call member function" << endl;
}
virtual void testInt(int n){
cout << "call member function int n = " << n << endl;
}
static void staticFunc(){
cout << "static call function " << endl;
}
};
此时,Base类的大小就变成了24,因为要满足8字节对齐(以单位最大为参考)。
如果不希望使用内存对齐(比如通讯协议就不应该使用内存对齐),可以使用#pragma pack(1)来做预处理,这样编译器就按照1字节对齐来进行处理。
#pragma pack(n)作为一个预编译指令用来设置多少个字节对齐的。值得注意的是,n的缺省数值是按照编译器自身设置,一般为8,合法的数值分别是1、2、4、8、16。
即编译器只会按照1、2、4、8、16的方式分割内存。若n为其他值,是无效的。
五、为什么要进行内存对齐
尽管内存是以字节为单位,但是大部分处理器并不是按字节块来存取内存的.它一般会以双字节,四字节,8字节,16字节甚至32字节为单位来存取内存,我们将上述这些存取单位称为内存存取粒度.
现在考虑4字节存取粒度的处理器取int类型变量(32位系统),该处理器只能从地址为4的倍数的内存开始读取数据。
假如没有内存对齐机制,数据可以任意存放,现在一个int变量存放在从地址1开始的联系四个字节地址中,该处理器去取数据时,要先从0地址开始读取第一个4字节块,剔除不想要的字节(0地址),然后从地址4开始读取下一个4字节块,同样剔除不要的数据(5,6,7地址),最后留下的两块数据合并放入寄存器.这需要做很多工作。
现在有了内存对齐的,int类型数据只能存放在按照对齐规则的内存中,比如说0地址开始的内存。那么现在该处理器在取数据时一次性就能将数据读出来了,而且不需要做额外的操作,提高了效率。
有了内存对齐的知识,那咱们在定义struct和class时就应当注意,不同的定义顺序会导致类的大小不一样。并且,在某些情况下,我们不应该使用内存对齐!(例如制定通讯协议)
至此,咱们对单个类的内存结构有了深刻的认识,后面一篇,我再对继承类和多继承的情况做详细解析!