自定义类型篇Ⅰ
零 关于自定义类型
对于一些基本数据类型,就像 int 可以存放整数数据,例如表示我有一百块钱,可以定义作 int money = 100;
数组是相同类型的集合,例如一个班级的学生姓名,可以定义做 char stuName[] = {“鲁鲁”, “花花”,“福宝”};
如果我们想要储存每个学生的信息,包括学生的姓名、年龄、电话、身高体重…这些不同的数据类型,数组就无法满足我们的要求了,不光是数组,已有的数据类型都没法很好的储存和管理这样的信息。我们不妨自己定义一个,满足我们要求的类型~😏
一、结构体
结构体 属于用户 自定义的数据类型,是一些值的 集合,这些值称作 成员变量,其中每个成员可以是不同类型的变量
已知的内置类型能表达的内容有限 、单一,复杂对象就可以用结构体表示
1.1 结构体的声明
结构体、包括所有自定义类型的声明 不占用栈里的储存空间
struct tag //结构体类型(tag 为结构体标签)
{
member - list; //成员列表
}variable - list; //变量列表(可有可无)
没有变量列表时,结构体大括号 {}
后面也是需要加分号 ;
的,写作:
struct tag //结构体标签
{
member - list; //成员列表
};
此时一个结构体类型就声明成功,而他的类型就是 struct tag
,定义变量的时候和 int 一样,把类型名写在变量名前面即可~
🙋🌰举栗子 – 声明一个学生:
// 一个学生信息可以放入一个结构体变量中储存
// 这里定义学生信息包括:姓名、年龄、学号
// 分别用 char 类型数组、整型、char 类型数组 进行储存
struct Student
{
char name[20];
int age;
char id[20];
}; // 分号不能忘记
// 这个结构体的类型名就为:struct Student
特殊的声明:匿名结构体
匿名结构体:省略了结构体标签 tag,只能在声明时定义结构体变量,好处是可以防止别人调用
struct
{
char name[20];
int age;
char id[20];
}Stuff1; // 结构体变量
struct
{
char name[20];
int age;
char id[20];
}s1, *ps, arr[5]; // 结构体变量指针, 结构体变量数组
int main()
{
// 正确访问
Stuff1.age = 33; // 可以直接赋值的
// 错误访问
*ps = Stuff1; // 报错
Stuff1 = s1; // 报错
return 0;
}
注意: 即使两个匿名结构体成员变量一样,编译器也认为他们是两个类型,不能进行相互间的赋值。
1.2 结构体的自引用
所谓自引用就是 结构体中包含一个类型为该结构本身的成员。
注意:需要以 结构体指针 的方式写,不然无法计算结构体的大小!
(参考数据结构里的 链表)
// 一个结构体里放 指向下一个结构体的指针 和 该结构体中需要存放的数据
struct ListNode
{
int data;
struct ListNode* next;
};
结构体的 typedef:
在定义结构体变量时,我们需要写出 struct ListNode n1 这样的代码,难免会感到结构体类型名很长、很复杂,这时的 typedef
仍旧管用,写法如下:
typedef struct ListNode
{
int data;
struct ListNode* next;
}Node;
//这里将 struct ListNode --> Node
值得注意的是:即使 typedef 了,在自引用结构里,还是得用原来的结构类型
int main()
{
Node n1;
return 0;
}
有观点认为:不建议使用 typedef,因为这个写法确实简洁了但是看不出原来的类型是结构体还是枚举,或是别的什么。
1.3 结构体的 定义&初始化
/********************************** 结构体声明 **********************************/
// 【书】:书名 + 作者 + 定价 + 书号
struct Book
{
char book_name[20];
char author[20];
int price;
char id[20];
}b4 = {"同桌冤家","yhy",36,"TS10001"}, b5;
struct Book b5;
// b3,b4,b5 是都类型为 struct Book 的结构体变量 (并且是全局变量)
// 【学生】:名字 + 年龄 + 学号 + 喜欢的书
struct Stu
{
char name[20];
int age;
char id[20];
struct Book favorite_book;
};
结构体的定义方式:
- 直接定义,不初始化
/********************************** 主函数部分 **********************************/
struct Book b1;
- 定义时 初始化 :将结构体里的成员顺序写在
{}
中,成员间以,
隔开
如果每个成员在之前加上.成员名 = 初始化内容
,就可以乱序书写了。
struct Book b2 = {"小大人丁文涛", "yhy", 35, "TS38492"}; // 顺序
struct Book b3 = {.author = "yhy",.book_name = "丁克舅舅"}; // 乱序
在结构体声明中定义的结构体变量,也同样可以如此初始化!(见声明处)
结构体内的结构体类型成员,也同理初始化如下
struct Stu s1 = { "Kevin",24,"N170409113",{"小时代","gjm",88,"TS123"}};
1.4 结构体传参
结论:结构体传参时,推荐 传址传参(要传结构体的地址!!!)
struct SS
{
int data[100];
int num;
};
void print1(struct SS s) // 不推荐
{
printf("%d %d %d %d\n", s.data[0], s.data[1], s.data[2], s.num);
}
void print2(struct SS* ps) // 推荐
{
// 下面两种访问均可,看个人喜好
printf("%d %d %d %d\n", ps->data[0], ps->data[1], ps->data[2], ps->num);
printf("%d %d %d %d\n", (*ps).data[0], (*ps).data[1], (*ps).data[2], (*ps).num);
}
int main()
{
struct SS s = { {1,2,3},999 };
print1(s);
print2(&s);
return 0;
}
函数传参的时候,参数是需要压栈,会有时间和空间上的系统开销。
如果传递一个结构体对象的时候,结构体过大,参数压栈的的系统开销比较大,所以会导致性能的下降。
二、🚩结构体的内存对齐
研究 内存对齐 讨论的是 结构体大小如何计算 的问题,总体来说,这种规则是一种 拿空间换取时间 的做法。
(注意:这里的所有实验都是基于 Windows 系统下的 VS 环境中讨论的 ~)
2.1 引子
结构体在内存中到底是如何存放的呢?
先看一组结构体,他们成员类型一致,让我们来输出他们的类型大小
struct S1
{
char c1;
int i;
char c2;
};
struct S2
{
char c1;
char c2;
int i;
};
printf("%d\n", sizeof(struct S1));
printf("%d\n", sizeof(struct S2));
------------
输出结果
12
8
两个结果竟截然不同。
为解决如此诡异的现象,我们不由得想到去分析一下,每个成员分别是如何储存的,这里我们需要引入一个 C 库宏:
2.2 成员偏移 - offsetof
-
offsetof(type, member)
作用:返回一个 结构体某个成员 相对于 结构体开头 的偏移量,单位为 字节
type:一个 struct 类型。
member:type 类型中的某个成员。
头文件:<stddef.h>
返回值类型: size_t(可理解为 int)
使用如下:
struct S1 // 类型大小:12
{
char c1; // 0 1 2 3 空间浪费了
int i; // 4(4~7)
char c2; // 8 9 10 11 空间浪费了
};
struct S2 // 类型大小:8
{
char c1; // 0
char c2; // 1 2 3 空间浪费了
int i; // 4(4~7)
};
int main()
{
printf("%d ", offsetof(struct S1, c1)); // 结果见结构体定义处的注释
printf("%d ", offsetof(struct S1, i));
printf("%d\n", offsetof(struct S1, c2));
printf("%d ", offsetof(struct S2, c1));
printf("%d ", offsetof(struct S2, c2));
printf("%d\n", offsetof(struct S2, i));
return 0;
}
------------
输出结果:
0 4 8
0 1 4
好像更懵了,内存对齐规则🤡究竟是什么规则!!!
2.3 🚩内存对齐规则
-
🎯结构体的 第一个成员 直接对齐到相对于结构体变量 起始位置为 0 的偏移处
-
🎯从第二个成员开始,要对齐到某个 对齐数 的 整数倍 的偏移处
对齐数:结构体 成员类型自身大小 和 默认对齐数 的较小值 (VS 环境下默认对齐数为 8;Linux 环境默认不设对齐数,则对齐数就是结构体成员类型自身大小)
助记:min(sizeof(int),8) -
🎯整个 结构体的大小 是 最大对齐数 的 整数倍
每个结构体成员都有一个对齐数,其中最大的对齐数就是最大对齐数
助记:max(每个成员的min) -
如果结构体中嵌套了结构体类型为成员:
嵌套的结构体 对齐到 自己的最大对齐数 的 整数倍 处(即该结构体类型大小的整数倍处)
结构体的整体大小仍是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
2.4 🚩实操一下
(详细分析一个 Struct S1:
struct S1 // 结构体类型大小:max(1,4,1)--> 4 --> 12
{
char c1; // 0
int i; // min(4,8)--> 4 --> 4-7
char c2; // min(1,8)--> 1 --> 8
};
-
第一步:按照第一条规则,安置第一个成员
- 👣 第一个成员为 char 类型,放于偏移量 0 处,占 1 个字节,存在 {0} 处 第二步:按照第二条规则,从第二个开始,依次安置后面所有成员
- 👣 第二个成员为 int 类型,类型大小 4,默认对齐数 8,对齐数取两者最小 4。char c1 已经放在 {0} 位置,往后找对齐数 4 的整数倍的位置即 {4},int 按大小占 4 个字节,就可以存在 {4,5,6,7} 四个位置上。
- 👣 最后一个成员为 char 类型,类型大小 1,默认对齐数 8,对齐数取两者最小 1。int i 已经放到了位置 {7},往后找对齐数 1 的整数倍的位置即 {8},char 按大小占 1 个字节,就可以存在 {8} 这个位置上。 最后一步:按照第三条规则,计算结构体类型整体大小
- 👣这里三个成员的对齐数分别是 1、4、1,取最大 4,又根据前两步得知,各个成员存放于 {0} ~ {8} 共计 9 字节。所以,我们从 9 往后找 4 的倍数,最近的就是 12 啦。12 也自然为结构体 S1 的类型大小 ~
(简化版分析写在代码注释啦!熟练使用后就可以用眼睛看出简单结构体大小了哦 😉
(接下来的结构体,其分析步骤都大致相同,此处不赘述
struct S2 // 结构体类型大小:max(1,1,4)--> 4 --> 8
{
char c1; // 0
char c2; // min(1,8)--> 1 --> 1
int i; // min(4,8)--> 4 --> 4-7
};
struct S3 // 结构体类型大小:max(8,1,4)--> 8 --> 16
{
double d; // 0-7
char c; // min(1,8)--> 1 --> 8
int i; // min(4,8)--> 4 --> 12-15
};
struct S4 // 结构体类型大小:max(1,8,8)--> 8 --> 32
{
char c1; // 0
struct S3 s3; // max(8,1,4)--> 8 --> 8-23
double d; // min(8,8)--> 8 --> 24-31
};
ps:
一道百度笔试题:写一个宏,计算结构体中某变量相对于首地址的偏移,并给出说明
考察:offsetof 宏的实现
#define MY_OFFSET_OF(TYPE, MEMBER) ((size_t)&((TYPE*)0)->MEMBER)
// 这样可能更好理解
#define MY_OFFSET_OF(TYPE, MEMBER) ((size_t)&((TYPE*)NULL)->MEMBER)
2.5 内存对齐的意义
至于…为啥会有内存对齐呢?比较官方的解释可以了解一下:
- 平台原因(移植原因):
不是所有的硬件平台都能访问任意地址上的任意数据的;
某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。 - 性能原因:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。
原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。
总体来说:结构体的内存对齐是 拿空间来换取时间 的做法。
2.6 省空间的小技巧
在设计结构体时,尽可能把占用空间小的成员放在一起
通过观察上述案例 struct S1 和 struct S2 成员相同只是位置不同,结构体所占的空间也不相同。struct S1 有更多的浪费空间,而我们想让空间被利用,就可以选择把占空间的小的类型放在一块进行声明。
2.7 修改 默认对齐数
用
#pragma pack(对齐数);
声明进行设置,一般将对齐数设为 2n 大小,使用完成后#pragma pack();
可以恢复默认对齐数
(欢迎手动计算验证哦:
#pragma pack(1) // 设置默认对齐数
struct S1_Re // 结构体总大小:max(1,1,1)--> 1 --> 6
{
char c1; // 0
int i; // min(4,1)--> 1 --> 1-4
char c2; // min(1,1)--> 1 --> 5
};
#pragma pack() // 恢复默认对齐数
int main1_4()
{
printf("%d\n", sizeof(struct S1_Re)); // 6
return 0;
}
三、位段
跟结构相比,位段可以达到同样的效果,并且可以很好的 节省空间,但是有跨平台的问题存在。
位段中的 位 指的是 二进制位。
-
位段的声明和结构是类似的,有两个不同:
-
- 位段的成员类型必须是 int、unsigned int、signed int 或 char。
-
- 位段的成员名后边有一个冒号和一个数字。
- 理解补充:位段没有对齐规则,位段存在本身是为了节省空间,而对齐是牺牲空间换时间。
3.1 声明
(如注释所见,是一些错误的思考和自言自语:
struct A
{ // int 类型 4byte - 32bit(32 个比特位,这些成员按照声明占位量挨个放?)
int _a : 1; // 32-1=31
int _b : 5; // 31-5=26
int _c : 10; // 26-10=16
int _d : 20; // 16-20 不够了,再申请 4 byte,16+32-20
};
// 数字加起来一共是 36 bit
// 1 byte = 8 bit
// 算起来 5个 byte 便可装下 A,可是为什么 A 的大小是 8 咧?
int main()
{
printf("%d\n", sizeof(struct A)); // 8
return 0;
}
------------
输出结果:
8
3.2 位段的内存分配
- 位段的成员可以是 int unsigned int signed int 或者是 char (属于整形家族)类型
- 位段的空间上是按照需要以 4 个字节(int)或者 1 个字节(char)的方式来开辟的
- 位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段
struct S
{
char a : 3; // 申请 1 字节
char b : 4;
char c : 5; // 申请 1 字节
char d : 4; // 申请 1 字节
};
// 从高地址往低地址放
int main()
{
printf("%d\n", sizeof(struct S)); // 3
struct S s = { 0 };
s.a = 10;
s.b = 12;
s.c = 3;
s.d = 4;
return 0;
}
----------
结果可验证:
&s 内存中放的值为 62 03 04
位段动图演示
-
位段结构体类型大小
-
位段储存方式
-
最后呈现结果的计算过程
3.2 位段的跨平台问题
- int 位段被当成有符号数还是无符号数是不确定的。
- 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机器会出问题。
- 位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。
- 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用,这也是不确定的。