目录
一.结构体
结构是一些值的集合,这些值称为它的成员,一个结构中的各个成员可能具有不同的类型。
前期要点回顾:C语言深度解析--结构体
1.1.结构的声明
在声明结构时,必须列出它包含的所有成员。这个列表包括每个成员的类型和名字。
struct tag //struct:关键字,tag:类型名
{
member-list;//成员列表
}variable-list;//变量列表
需要注意的是:类型名,成员列表和变量列表不能全部省略,它们至少要出现两个。
案例一:
struct Stu//Stu结构体类型名
{
char name[20];
int age;
double score;
}s1;//结构体变量
//创建全局变量
struct Stu s2;
int main()
{
//创建局部变量
struct Stu s3;
return 0;
}
这里定义了一个结构体类型struct Stu,再使用类型创建3个变量s1,s2和s3 (两种方法创建结构体变量)。
案例二:
struct
{
int a;
char b;
float c;
}x;
//这个声明创建了一个名叫x的变量,它包括三个成员:一个整数,一个字符和一个浮点数
struct
{
int a;
char b;
float c;
}a[20],*p;
//这个声明创建了a和p。a是一个数组,它包含了20个结构。p是一个指针,它指向这个类型的结构
上述案例称为匿名结构体,因为没有类型名tag,所以后面不能再定义相关类型的数据。
这两个声明被编译器当作两个两种截然不同的类型,即使它们的成员列表完全相同。因此,变量a和p的类型与x的类型不同,所以下面这条语句是非法的
p=&x;
标签tag可以很好地解决匿名结构体带来的问题。标签tag字段允许为成员列表提供一个名字,这样它就可以在后续的声明中使用。标签允许多个声明使用同一个成员列表,并且创建同一种类型的结构。
1.2.结构的自引用
在一个结构内部包含一个类型为该结构本身的成员是否合法呢?
struct Node
{
int data;
struct Node b;
};
这种类型的自引用是非法的,因为成员b是另一个完整的结构,其内部还将包含它自己的成员b。这第二个成员又是另一个完整的结构,它还将包括它自己的成员b。这样重复下去永无止境。这有点像永远不会终止的递归程序。但下面这个声明确实合法的,你能看出其中的区别吗?
struct Node
{
int data;
struct Node* b;
};
这个声明和前面那个声明的区别在于b现在是一个指针而不是结构。编译器在结构的长度确定之前就已经知道指针的长度,所以这种类型的自引用是合法的。
如果你觉得一个结构内部包含一个指向该结构本身的指针有些奇怪,请记住它事实上所指向的是同一种类型的不同结构。
声明结构时可以使用的另一种良好技巧是用typedef创建一种新的类型。
结构体重新命名
typedef struct Node
{
int data;
struct Node* next;
}Node;//将struct Node重命名为Node
匿名结构体重新命名,但不可以自引用
typedef struct
{
int data;
Node* next;//err
}Node;
这个声明的目的是为这个结构创建类型名Node。但是它失败了。类型名直到声明的末尾才定义,所以在结构声明的内部它尚未定义。解决方案是定义一个结构标签来声明next。
typedef struct S
{
int data;
struct S* next;
}Node;
结构体指针重命名
typedef struct Node
{
int data;
struct Node* next;
}Node,*pNode;
//pNode等价于struct Node*
1.3.结构体变量的定义和初始化
普通结构体的初始化
和数组一样,结构变量也可以在声明的同时进行初始化,初始化式中的值必须按照结构成员的顺序进行显示。
struct Book
{
char name[20];
float price;
char id[12];
}s1 = { "C语言",55.5,"PGC001" };
struct Book s2 = { "Linux",99.9,"DG001" };
int main()
{
struct Book s2 = { "数据结构",66.6,"HG001" };
return 0;
}
嵌套结构体的初始化
//结构体嵌套初始化
struct Node
{
struct Book b;
struct Node* next;
};
int main()
{
struct Node n = { {"汤神Java",66.8,"TG001"},NULL };
return 0;
}
1.4.结构成员的访问
结构成员的直接访问
结构变量的成员是通过点操作符(.)访问的。点操作符接受两个操作数,左操作数就是结构变量的名字,右操作数就是需要访问的成员的名字。这个表达式的结果就是指定的成员。
结构成员的间接访问
如果你拥有一个指向结构的指针,你该如何访问这个结构的成员呢?C语言提供了一个更为方便的操作符来完成这项工作--(->)操作符(也称为箭头操作符)。和点操作符一样,箭头操作符接受两个操作数,但左操作数必须是一个指向结构的指针。
struct Book
{
char name[20];
float price;
char id[20];
};
int main()
{
//初始化
struct Book b = { "C语言", 29.9, "1122" };
//使用.
printf("%s %f %s\n", b.name, b.price, b.id);
//使用->
struct Book* ps = &b;
printf("%s %f %s\n", ps->name, ps->price, ps->id);
return 0;
}
1.5.结构体内存对齐
我们在计算一个结构体所占空间大小时,本质考察的是结构体的内存对齐规则。那结构体中的成员变量是如何对齐的呢?
结构体的对齐规则:
- 第一个成员在与结构体变量偏移量为0的地址处;
- 其它成员变量要对齐到某个数字(对齐数)的整数倍的地址处。对齐数 = 编译器默认的一个对齐数与该成员大小的较小值。VS中默认对齐数是8; Linux没有默认对齐数,它是按照自身大小来对齐的;
- 结构体总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍;
- 如果嵌套了结构体,嵌套的结构体对齐到自己的最大对齐数的整数倍,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
案例一:
struct S1
{
char c1;//1
int i;//4
char c2;//1
};
分析:
案例二:
struct S2
{
char c1;//1
char c2;//1
int i;//4
};
分析:
案例三:
struct S3
{
double d;//8
char c;//1
int i;//4
};
分析:
案例四:
struct S3
{
double d;//8
char c;//1
int i;//4
};
struct S4
{
char c1;//1
struct S3 s3;//16
double d;//8
};
分析:
为什么要内存对齐?
- 平台原因(移植原因): 不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常;
- 性能原因: 数据结构(尤其是栈)应该尽可能地在自然边界上对齐。 原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。
总体来说:结构体的内存对齐是拿空间来换取时间的做法。
那在设计结构体的时候,我们既要满足对齐,又要节省空间,如何做到? 答案:让占用空间小的成员尽量集中在一起。
案例:
struct S1
{
char c1;
int i;
char c2;
};
struct S2
{
char c1;
char c2;
int i;
};
int main()
{
struct S1 s1 = { 0 };
struct S2 s2 = { 0 };
printf("%d\n", sizeof(s1));//12
printf("%d\n", sizeof(s2));//8
return 0;
}
分析:s1和s2的类型成员一模一样,但是s1和s2的大小不同。发现让占用空间小的成员尽量集中在一起有助于节省空间。
1.6.修改默认对齐数
之前我们见过了#pragma这个预处理指令,这里我们再次使用,可以改变我们的默认对齐数。
案例:
#pragma pack(4)//设置默认对齐数为4
struct S
{
char c;
double d;
};
int main()
{
struct S s;
printf("%d\n",sizeof(s));//12
return 0;
}
分析:
结论:结构体在对齐方式不合适的时候,那么我们可以自己调整默认对齐数。
1.7.offsetof宏
offsetof,是一个宏函数,用于计算结构体中某变量相对于起始地址的位置的偏移量。
#include<stddef.h>
size_t offsetof(structName, memberName);
案例:
struct S3
{
double d;//8
char c;//1
int i;//4
};
int main()
{
struct S3 s3;
printf("%d\n", sizeof(s3));//16
printf("%u\n", offsetof(struct S3, d));//0
printf("%u\n", offsetof(struct S3, c));//8
printf("%u\n", offsetof(struct S3, i));//12
return 0;
}
1.8.结构体传参
struct S
{
int data[10];
int num;
};
//结构体传参
void print1(struct S ss)
{
printf("%d\n", ss.num);
}
//结构体地址传参
void print2(struct S* pss)
{
printf("%d\n", pss->num);
}
int main()
{
struct S s = { {1,2,3,4}, 1000 };
print1(s); //传结构体
print2(&s); //传结构体地址
return 0;
}
上面的print1和print2函数哪个好些? 答案是:print2好些。原因:
函数传参的时候,参数是需要压栈,会有时间和空间上的系统开销。如果传递一个结构体对象的时候,结构体过大,参数压栈的的系统开销比较大,所以会导致性能的下降。所以我们在使用结构体传参时,最好传递结构体的地址。
1.9.位段
位段的声明和结构类似,但它的成员是一个或多个位的字段。这些不同长度的字段实际上存储于一个或多个整型变量中。位段的声明和任何普通的结构成员声明相同,但有两个例外。首先,位段成员必须声明为int,signed int或unsigned int类型。其次,在成员名的后面是一个冒号和一个整数,这个整数指定该位段所占用的位的数目。
注意:用signed或unsigned整数显示地声明位段是个好主意。如果把位段声明为int类型,它究竟被解释为有符号数还是无符号数是由编译器决定的。
例如:
struct AA
{
int _a;
int _b;
int _c;
int _d;
};
struct A //A就是一个位段类型
{
int _a : 2;//_a这个成员占2个bite位:00 01 10 11,则表示的取值:0 1 2 3
int _b : 5;//_b这个成员占5个bite位
int _c : 10;//_c这个成员占10个bite位
int _d : 30;//_d这个成员占30个bite位
};
int main()
{
printf("%d\n", sizeof(struct A));//8
printf("%d\n", sizeof(struct AA));//16
return 0;
}
位段的内存分配:
- 位段的成员可以是 int,unsigned int,signed int 或者是char(属于整形家族)类型;
- 位段的空间上是按照需要以4个字节(int)或者1个字节(char)的方式来开辟的;
- 位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。
案例分析:
struct S
{
char a : 3;
char b : 4;
char c : 5;
char d : 4;
};
int main()
{
struct S s = {0};
printf("%d\n",sizeof(struct S));//3
s.a = 10;
s.b = 12;
s.c = 3;
s.d = 4;
return 0;
}
分析:
我们可以看到结构体中的每个成员变量都是char
类型的,而位段的空间是按照4个字节(int)或者1个字节(char)的方式来开辟的,所以编译器会先为其分配一个字节(8bite)的空间。
那数据是如何存放的呢?我们不妨设位段的数据是从右向左存放的。首先,变量a占据了3bite,变量b占据4bite,此时开辟一个字节(char)的空间,可以同时放下变量a和b。这时,第一个字节只剩下1bite的空间,而变量c需要占5bite的空间,此时第一个字节的空间显然是不够的。这时我们需要开辟第二个字节(char)的空间。
那变量c是在使用完第一个字节剩下的1bite之后再使用第二个字节的空间,还是直接使用第二个字节的空间呢?我们不妨假设变量c直接舍弃第一个字节剩余的空间,然后在第二个字节上自右向左占据5bite的空间。此时的第二字节只剩下3bite的空间,而变量b又需要4bite的空间,显然空间是不够的。所以我们再开辟第三个字节(char)的空间,然后自右向左占据4bite的空间来存放变量d。此时的结构体刚好需要3字节的空间,和程序运行结果完全相同。
那位段的的空间开辟是否真如上面所猜想的那样?接下来我们通过调试来进一步分析:
a的值为10,对应的二进制数为00001010
,而其对应的位段为3bite,所以截断为010
。将它自右向左放到第一个字节空间的最右面; b的值为12,对应的二进制数为00001100
,而其对应的位段为4bite,所以截断为1100
。将它自右向左紧邻着变量b存放; c的值为3,对应的二进制数为00000011
,而其对应的位段为5bite,所以截断为00011
。将它自右向左放到第二个字节空间的最右面; d的值为4,对应的二进制数为00000100
,而其对应的位段为4bite,所以截断为0100
。将它自右向左放到第三个字节空间的最右面。
三个字节空间存放的内容分别如下:
01100010
对应的16进制数为:
0x62
;00000011
对应的16进制数为: 0x03;
00000100 对应的16进制数为:0x04。
通过调试可以发现,结构体变量s在内存中确实是如我们所预想的那样存放的。
位段的跨平台问题:
- int位段被当成有符号数还是无符号数是不确定的;
- 位段中最大位的数目不能确定;(16位机器最大16,32位机器最大32,写成27则在16位机
器会出问题。) - 位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义;
- 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的。
总结:跟结构体相比,位段可以达到同样的效果,但是可以很好节省空间,但是有跨平台的问题存在。
位段的应用:应用于IP数据报的格式等。
二.枚举
枚举,也就是列举,即把所有可能的取值一一列举出来。
2.1.枚举的定义
enum tag
{
values1,
values2,
values3
} variable-list;
//enum:枚举关键字
//tag:枚举标签
//enum tag:枚举类型
//values1:枚举常量
//variable-list:枚举变量
举例:
enum Sex
{
MALE,
FEMALE,
SECRET
};
enum Day
{
Mon,
Tues,
Wed,
Thur,
Fri,
Sat,
Sun
};
enum Color
{
Red=5,
Green,
Blue
};
以上定义的 enum Sex, enum Day, enum Color 都是枚举类型。{ }中的内容是枚举类型的可能取值,也叫枚举常量。
对于定义在枚举内部的常量,是存在初始值的,默认从0开始,依次递增1。例如:
enum Color
{
RED,
GREEN,
BLUE
};
int main()
{
printf("%d\n", RED);//0
printf("%d\n", GREEN);//1
printf("%d\n", BLUE);//2
return 0;
}
当然,在定义的时候也可以赋初值,且枚举常量的值也会随着修改的值依次递增1,例如:
enum Color
{
RED=5,
GREEN,
BLUE
};
int main()
{
printf("%d\n", RED);//5
printf("%d\n", GREEN);//6
printf("%d\n", BLUE);//7
return 0;
}
2.2.枚举的使用
那枚举常量的值在后期是否可以随意修改呢?答案是否定的。
案例一:
enum Color
{
RED = 5,
GREEN,
BLUE
};
int main()
{
RED = 8;
return 0;
}
原因如下:
案例二:
enum Color
{
RED = 5,
GREEN,
BLUE
};
int main()
{
enum Color d = 1;//err
enum Color e = BLUE;//ok
return 0;
}
分析:
1是整型,而d是enum Color类型,二者类型并不匹配。在.c文件下执行代码并没有报错,而在.cpp文件下中却报错。所以只能拿枚举常量给枚举变量赋值,才不会出现类型的差异。
2.3.枚举的优点
- 增加代码的可读性和可维护性;
- 和#define定义的标识符比较枚举有类型检查,更加严谨;
- 防止命名污染(封装);
- 便于调试(而#define定义的常量在调试时不能看到标识符);
- 使用方便,一次可定义多个常量。
三.联合(共用体)
联合也是一种特殊的自定义类型,这种类型定义的变量也包含一系列成员,特征是这些成员共用同一块空间 ,所以联合体也叫共用体。当你想在不同的时刻把不同的东西存储于同一个位置,就可以使用联合。
3.1.联合的定义
union tag
{
member1;
member2;
}variable-list;
//union:联合体关键字
//tag:联合标签名
//union tag:联合类型
//{}:成员列表
//variable-list:变量
举例:
union Un
{
char c;
int i;
};
int main()
{
union Un u;
return 0;
}
注意:联合变量可以被初始化,但这个初始值必须是联合第1个成员的类型,而且它必须位于一对花括号里面。
3.2.联合的特点
联合的成员是共用同一块内存空间的,这样一个联合变量的大小,至少是最大成员的大小(因为联合至少得有能力保存最大的那个成员)。
案例一:
union Un
{
char c;
int i;
};
int main()
{
union Un u;
printf("%d\n",sizeof(u));//4
printf("%p\n",&u);
printf("%p\n",&(u.c));
printf("%p\n",&(u.i));
return 0;
}
运行结果:
我们可以发现:
由于sizeof(u)的值为4,那么联合变量的大小则为最大成员的大小。而最大成员为i,对应的类型为int,所以值为4。
由于&u,&(u.c)和&(u.i)的值均为0059FE98,那么联合的成员是共用同一块内存空间的。
案例二:
union Un
{
char c;
int i;
};
int main()
{
union Un u;
u.c = 0x55;//55 cc cc cc
u.i = 0;//00 00 00 00
return 0;
}
运行结果:
union Un u = { 0 };对应的内存:
u.c = 0x55;对应的内存:
u.i = 0;对应的内存:
可知,联合的成员是共用同一块内存空间的。
面试题:判断当前计算机的大小端存储
大端模式和小端模式:
方案一:强制类型转换
int main()
{
int i = 1;
//01 00 00 00
if (1 == *(char*)&i)//强制转化为char*类型,解引用得到一个字节
{
printf("小端\n");
}
else
{
printf("大端\n");
}
return 0;
}
方案二:联合的方式
int check_sys()
{
union Un
{
char c;
int i;
}u;
u.i = 1;//01 00 00 00
return u.c;//c是i的第一个字节,01
}
int main()
{
if (1 == check_sys())
{
printf("小端\n");
}
else
{
printf("大端\n");
}
return 0;
}
3.3.联合大小的计算
规定:
- 联合的大小至少是最大成员的大小;
- 当最大成员大小不是最大对齐数的整数倍的时候,就要对齐到最大对齐数的整数倍。
案例一:
union Un
{
char arr[5];//5
int i;//4
};
int main()
{
printf("%d\n", sizeof(union Un));//8
return 0;
}
分析:
当成员是数组时,我们看对齐数看的是数组成员的类型。字符数组c中的每一个元素在内存中所占大小为1个字节,VS默认对齐数为8,取两者最小值,即为1;
成员i在内存中所占大小为4个字节,VS默认对齐数为8,取两者最小值,即为4;
因为联合中已经存放了5个字节,而我们要对齐到最大对齐数4的整数倍,即为8,因此最后计算出来的联合体的大小就为8。
案例二:
union Un
{
short arr[7];//14 2
int i;//4 4
};
int main()
{
printf("%d\n", sizeof(union Un));//16
return 0;
}
分析:
当成员是数组时,我们看对齐数看的时数组成员的类型。字符数组arr中的每一个元素在内存中所占大小为2个字节,VS默认对齐数为8,取两者最小值,即为2;
成员i在内存中所占大小为4个字节,VS默认对齐数为8,取两者最小值,即为4; 因为联合中已经存放了14个字节,而我们要对齐到最大对齐数4的整数倍,即为16,因此最后计算出来的联合体的大小就为16。
总结:
在一个成员长度不同的联合里,分配给联合的内存数量取决于它的最长成员的长度。这样,联合的长度总是足以容纳它最大的成员。如果这些成员的长度相差悬殊,当存储长度较短的成员时,浪费的空间是相当可观的。在这种情况下,更好的方法是在联合中存储指向不同成员的指针而不是直接存储成员本身。所有指针的长度都是相同的,这样就解决了内存浪费的问题。当它决定需要使用哪个成员时,就分配正确数量的内存来存储它。