结构体和类
c++中结构体和类的唯一区别是成员的默认访问权限不同,其它的方面都是一样的。
对象的内存布局
实例化一个类之后,对象的地址就是类中第一个声明的成员的地址,紧接着的是第二个成员,以此类推。
按照上述说法,对象的大小应该是类中各成员大小的总和,然而事实并非总是如此。
空类
空类中没有任何数据成员,按照该公式计算得出的对象长度为0字节。类型长度为0,则此类的对象不占内存空间。而实际情况是,空类的长度为1字节。如果对象完全不占用内存空间,空类就无法取得实例对象的地址,this指针失效,因此不能被实例化。
而类的定义是由成员数据和成员函数组成的,在没有成员数据的情况下,还可以有成员函数,因此仍然需要做实例化。分配1字节的空间用于类的实例化,这1字节的数据并没有被使用。
内存对齐
当发生了内存对齐的时候,对象的大小也不会等于各成员大小之和。下面说一下内存对齐的规则。
在为结构体和类中的数据成员分配内存时,结构体中当前数据成员类型的长度为M,指定的对齐值为N,那么实际对齐值为q =min(M, N),成员的地址安排在q的倍数上。最后计算对象的大小的时候,还需要让对象的大小是对齐粒度的整数倍。
接下来看一个例子:假设c++要求的对齐值为8字节。
cpp
struct Test
{
short s;
int n;
};
int main()
{
Test test;
return 0;
}
对于test对象,假设其地址为0x08,这是2的整数倍地址,因此s的地址也是0x08,因为int的大小为4字节,而0x0a并不是4的整数倍地址,所以n的地址应该是0x0c。此时计算得test的大小为4+4=8字节。注意这还不是最终的大小。
test的象的大小和c++默认的对齐值相等,是8的整数倍,所以最终大小为8字节。
假设有一个对象大小为10字节,其中最大的成员大小为4字节,此时取4字节作为对齐值,结构体的大小必须是4的整数倍,所以为12字节。
既然有默认的对齐值,就可以在定义结构体时进行调整,在C++中可使用预编译指令#pragma pack(N)调整对齐大小。
当结构体中以数组作为成员时,计算对齐值是根据数组元素的长度,而不是数组的整体大小。
当结构体中出现结构体类型的数据成员时,不会将嵌套的结构体类型的整体长度加入对齐值计算中,而是以嵌套定义的结构体使用的对齐值进行对齐。
this
在调用对象的成员函数的时候,传递给rcx寄存器的参数(x64环境下真正的第一个参数)就是this,它是一个指针,保存了对象的首地址,同时也是对象中第一个成员的地址。
静态数据成员
类的静态数据成员和局部静态变量一样,都是含有特殊作用域的全局变量。它们的初始值会被编写进链接后的可执行文件中,当操作系统把程序加载至内存中时就已经存在了,当程序运行结束之后它们才从内存中消失,所以它们的生命周期和对象的生命周期是不一样的。
静态数据成员和对象是一对多的关系,因此它不参与对象大小的计算中。
对象作为函数参数
对象作为参数传递的时候,根据对象内存布局的不同,编译器会生成不同的传参方式。下面所说的都是按值传递的情况。
在语法上,把对象当作参数传递的时候,会调用拷贝构造函数。如果没有定义拷贝构造函数,编译器会自动帮我们生成一个拷贝构造函数,把传递的对象的内容浅拷贝到形参对象中。
小型对象直接传递
小型对象是只有几个基本数据类型变量作为成员变量的对象。对于这样的对象,在传递给函数的时候是直接传递对象成员变量给函数,也就是说将对象的每个成员像普通参数一样传递给函数。
cpp
class MyClass1
{
public:
int num1;
int num2;
};
static void fun1(MyClass1 obj)
{
obj.num1 = 1;
obj.num2 = 2;
printf_s("%d %d\r\n", obj.num1, obj.num2);
}
int main()
{
MyClass1 obj1;
obj1.num1 = 1;
obj1.num2 = 2;
fun1(obj1);
return 0;
}
对于上面那样的对象,结构简单,它作为参数的传递方式是这样的:

这里直接使用了RCX寄存器保存num1和num2这两个成员变量,传递obj1的时候直接把使用rcx传递。这实际上是将内存中的obj1拷贝到了rcx中,创建了一个obj1的副本作为参数传递给fun1。
同时可以看到,成员变量num1先于num2定义,在传递参数的时候num2比num1先进栈。类对象中数据成员的传参顺序为最先定义的数据成员最后压栈,最后定义的数据成员最先压栈。
对象的体积变大时
如果对象有多个成员变量,该对象在作为参数传递的时候,编译器生成的汇编代码会把这个对象的内存空间,复制到栈上去,然后把栈上对象的起始地址作为参数传递给函数(通过rcx传递)。
被调函数操作参数对象的时候,就是在操作复制得到的那块内存区域。
cpp
class MyClass2
{
public:
int num1;
int num2;
int num3;
char ch;
};
static void func2(MyClass2 obj)
{
obj.num1 = 1;
obj.num2 = 2;
obj.num3 = 3;
obj.ch = 'A';
printf_s("%d %d %d %c\r\n", obj.num1, obj.num2, obj.num3, obj.ch);
}
int main()
{
MyClass2 obj2;
obj2.num1 = 1;
obj2.num2 = 2;
obj2.num3 = 3;
obj2.ch = 'a';
func2(obj2);
return 0;
}
main函数中的关键代码反汇编如下:

可以查看rsi指向的内存空间:

被调函数内部操作参数的汇编代码如下:

对象中含有数组的情况
这和上面那种情况是一样的处理方式。
对象作为返回值
对象作为返回值的情况和对象作为参数的情况差不多,但是其中又有些不同。
开启优化的情况下
假设接收返回值的变量为a。主调函数会在自己的栈帧中开辟一块空间供a使用,并把a的首地址作为参数传递给被调函数(即使被调函数没有任何参数),被调函数直接在a的内存空间中构造需要返回的对象,不涉及拷贝操作。
未开启优化的情况下
假设接收返回值的变量为a。主调函数会在自己的栈帧中开辟一块空间供a使用,并把a的首地址作为参数传递给被调函数(即使被调函数没有任何参数),被调函数在自己的栈帧上开辟一块内存空间构造需要返回的对象b,并返回b的首地址。主调函数拿到b的首地址之后,会先把b的内存空间复制到一个临时对象c中,然后把c的内存空间复制给a。
cpp
#include <stdio.h>
class MyClass
{
public:
int num;
int arr[10];
};
MyClass getMyClass()
{
MyClass obj;
obj.num = 0x42;
for (size_t i = 0; i < 10; i++)
{
obj.arr[i] = i;
}
return obj;
}
int main(int argc, char const *argv[])
{
MyClass obj = getMyClass();
printf_s("%d %d %d\r\n", obj.num, obj.arr[0], obj.arr[9]);
return 0;
}
先来看看主调函数把接收返回值的对象的首地址传递给被调函数:

接下来看看被调函数在自己的栈帧中构造需要返回的对象:

仔细观察getMyClass的反汇编代码,可以发现它其实就是在main函数中的obj的地址空间上构造需要返回的对象的。构造完成之后,返回了构造完成的对象的首地址(其实就是main传递给getMyClass的参数)。
也就是说,getMyClass函数其实已经在main的obj中构造了对象了。
下面是main函数的反汇编代码,注意观察其中处理getMyClass返回值(RAX)的汇编代码:

可以看到,main函数再次从getMyClass返回的首地址开始复制对象到一个临时对象上,并把这个临时对象拷贝给obj。
没错,这里有做了很多多余的步骤:本来getMyClass返回之后,main中的obj对象就已经初始化完成了,但是main函数依然复制了obj的内存空间构建了一个临时对象,并将该临时对象的内存空间复制到了obj的内存空间中。
为什么会多一次拷贝?原因如下:
- 调试模式:编译器为了更容易调试,即使传入了目标地址,也可能会在函数内部创建局部对象
- 确保语义正确:严格按照C++标准语义,先构造局部对象,再拷贝返回
- 调试器友好:在函数栈帧上有完整的局部对象,方便查看
安全地传递和返回对象
假设我们有一个类:
cpp
#include <stdio.h>
class Person
{
public:
Person()
{
name = new char[40];
}
~Person()
{
if (name)
{
delete[] name;
name = nullptr;
}
}
char* getName()
{
return name;
}
private:
char* name;
};
void Show(Person person)
{
printf_s(person.getName());
}
Person* GetPerson()
{
Person p;
return &p;
}
int main(int argc, char const *argv[])
{
Person p;
Show(p);
p = GetPerson();
return 0;
}
在调用Show函数时,我们传递给Show函数的p是以复制的方式传递的,但是p对象内部有name这一个字符指针,复制的时候只是复制了地址。Show函数内部使用的person和main函数的p内部的name的值是一样的。Show函数调用结束后,person会执行析构函数,这会让main中的p的name也被delete掉。
同样在调用GetPerson的时候,GetPerson中的p对象在函数返回之后会执行析构函数,这会让main中的p内部的name的指针被delete掉。
我们可以通过定义拷贝构造函数来解决这样的问题。