一、前言
结构体是一个很有意思的东西,C语言就支持了,而C++更是在此基础之上增添了许多更加强大的功能,更准确地说,在C++中的结构体,基本可以与类相提并论了。
二、为什么需要结构体?
任何东西设计出来都有其理由,对于编程语言而已,任何一个特性都一定是为了解决某方面的问题的。
考虑一个比较经典的例子,一个简单的学生信息管理系统,需要保存学生信息。
在不使用结构体的情况下,我们大概率会写出下面这样的代码:
#include<string>
using namespace std;
int main() {
string name1; //学生1 姓名
string id1; //学生1 id
int age1; //学生1 年龄
string name2; //学生2 姓名
string id2; //学生2 id
int age2; //学生2 年龄
//....
}
或者更聪明一点的,可以使用数组:
#include<string>
using namespace std;
int main() {
string name[1000]; //学生姓名
string id[1000]; //学生id
int age[1000]; //学生年龄
}
使用数组的情况下,我们就能自己定义索引0位置上的姓名、id、年龄等信息为学生0的信息,以此类推。
这可以吗?这当然可以!
但缺点就是,姓名、年龄、id这些信息是属于一个学生的,分开存放就很容易出现问题。
比如常见的,你写的代码以后可能会交给别人看,如果你还不写注释,别人怎么知道这三个数组是关联起来的、并且还是一一对应的关系呢?
这就是结构体存在的意义:组织数据。
三、使用结构体
结构体的使用并不难,就是将你需要的属性组织成为一个更大的属性,就像是自定义了一个数据类型一样:
#include<string>
using namespace std;
struct Student
{
string name; //学生姓名
string id;//学生id
int age;//学生年龄
};
int main() {
Student stu[100]; //100个学生
stu[0].name = "张三"; //索引为0的学生的姓名
stu[0].id = "10010"; //索引为0的学生的id
stu[0].age = 22; //索引为0的学生的年龄
//以此类推,只需要改变索引即可找到第几个学生,并通过点(.)来设置、获取其身上的属性
}
需要注意的是,结构体必须要先进行声明,因为它仅仅只是组合数据的一种形式而已。
声明的格式如下:
struct /*结构体名字*/{
//结构体属性
};
以关键字struct
来声明结构体,娶个名字,并在后面的大括号中书写你想要组织的数据。
然后当你想要使用的时候,就使用这个自定义的结构体名字来定义数据即可:
Student s;
这就和定义普通数据类型的变量是一样的。
唯一不同的地方就在于,如果你想要访问其中的数据,就需要用点(.)的方式,你可以直接理解为中文汉字“的”:
s.name //s的name
s.age //s的age
s.id //s的id
这就是结构体的基本用法,其实它就起到的一个组织数据的功能,将相关联的数据组织在了一起,方便以后使用。
四、理解结构体
如果只是使用结构体,那么学会前面的东西你就已经可以开始用了,且基本不会出现问题。
但既然是学习C/C++,理解一下其底层原理也是很有必要的,比如:它是如何做到将不同数据类型组织在一起的呢?
最简单的方式就是做个实验:
#include<iostream>
using namespace std;
struct Test
{
int a;
int b;
int c;
};
int main() {
Test t;
cout << &t << endl;
cout << &t.a << endl;
cout << &t.b << endl;
cout << &t.c << endl;
}
上面的代码很简单,就是一个结构体组织了三个int型数据类型。
然后用它定义一个变量t
,并分别打印t
的地址以及t
内三个变量的地址:
打印出来的地址是用十六进制表示的。
可以看到,首先第一个变量a
的地址与t
本身的地址是完全相同的。
然后后面的b、c则是每次在前者的地址基础上加了个4
。
好巧不巧的,int数据类型就占4个字节!
从这里我们就能看出来,结构体实际上就是将其内部的所有基本数据类型放在了一起,并在内存中申请一块连续的内存来存储其中的所有类型,和数组很像。
五、指针偏移
有了前面的铺垫,我们就能理解偏移是怎么回事了。
还是前面的那个结构体,还是获取它们的地址,重新写出下面的代码:
int main() {
Test* t = new Test;
cout << t << endl;
cout << &(t->a) << endl;
cout << &(t->b) << endl;
cout << &(t->c) << endl;
}
上面代码的作用与前面是完全一样的:打印t极其内部所有数据的地址。
通过new动态创建的结构体返回的指针,我们就用的不再是点(.
),而是右箭头(->
),我们通常称其为偏移。
为什么?就因为前面说的,结构体实际上就是一整块内存,各种内部的类型是按顺序放在一整块内存中的。
而我们得到的这个指针,就是这块内存的首地址。
上面通过指针偏移获取实际值的方式,其实就等价于下面这样:
int main() {
Test* t = new Test;
cout << t << endl;
cout << (int*)t + 0 << endl; //t.a地址
cout << (int*)t + 1 << endl; //t.b地址
cout << (int*)t + 2 << endl; //t.c地址
}
因为这里结构体里面就是三个int类型数据,所以我这里就直接将其转换为了int类型的指针,通过直接加数字,编译器就知道要在当前指针地址处加多少个字节(int为4字节)到下一个int数据。
这应该可以让你对指针理解的更加深入一点:无论什么类型的指针,本质就是保存一个地址的变量而已,大小都是一样的。
它类型的意义在于,告诉编译器我们可以访问哪些地方。
但你也看见了,我们是可以通过强制转换来骗过编译器的,明明这里的t是一个结构体Test类型,我却把它当作了int类型数组使用。
实际上,数组与结构体也是一样的,也不过数组只允许组织同一种数据类型而已,访问数组的时候,实际上也是在首地址基础上直接添加地址偏移量得到对应位置上的数据。
而且无论你怎么写,最后经过编译器编译之后,都是通过指针添加偏移量来访问对应的内存数据的。
这便是C/C++强大而又危险的地方,因为它意味着你可以任意的、完全不受控制的操作数据。
六、内存对齐
计算机的内存通常是按照字节组织的,但不同类型的数据在内存中访问时的效率并不相同。
某些硬件平台要求数据类型在内存中按特定的边界对齐,以提高访问效率。例如,32位系统通常要求 4 字节的数据类型(如 int
)必须以 4 字节对齐,即它们的地址必须是 4 的倍数。
内存对齐的主要目的是避免“非对齐访问”问题,这种问题会导致内存访问速度变慢,甚至可能导致程序崩溃(尤其在一些严格要求对齐的体系结构上,如 ARM 或某些老旧的架构)。
相比于数组,结构体由于可以组织任意类型的数据,这很容易导致其内的字段存放的地址不对齐,此时C++ 编译器会对结构体中的成员进行对齐,以保证访问效率和正确性。
比如下面这个结构体:
可以看到,虽然a字段为char类型、只占用一个字节,但a与b之间却足足分配了4个字节的空间、并且每个字段地址都是4的整数倍。
这便是C++编译器内存对齐所做的事。
但我们可以自定义这种行为,vs中可以用指令pack来设置对齐的字节数,比如下图就是将其按1字节进行对齐:
此时a字段地址与b字段地址就只相差一个字节了。
七、位域
正常类型都是以字节为单位进行内存分配,一个字节占用8位二进制,也就是一个char的大小。
但在C++中我们还能更进一步节约空间,也就是直接指定字段的位,注意这里是指定位数、而不是指定字节数。
使用方式便是使用:
在字段后面跟上它占用的位数即可。
此时可以看到,虽然在结构体中有四个int类型的字段,理论上来说应该至少占用16个字节,因为每个int都占用4字节。
但此时由于我们使用了位域,此时四个字段大小全部加起来也才15位,也就是不到两个字节,而整体大小会使用最小int整数倍的大小,也就是一个int大小。
如果改为char,其大小是1个字节,15位就需要两个字节大小: