前言
C语言的数据类型包括基本类型(内置类型)、构造类型(自定义类型)、指针类型和空类型(void),其中基本类型就是我们常见的整形、浮点型,而自定义类型则包括数组、结构体、枚举、联合(共用体),数组我们已经非常熟悉了,今天我们主要学习自定义类型中其他几种类型:结构体、枚举以及联合。
一、结构体
结构体是一些值的集合,这些值称为成员变量;结构的每个成员可以是不同类型的变量,所以结构常用来描述复杂对象。
1、结构体的声明
一般声明
结构体的声明一般由结构体关键字 + 结构体标签 + 成员列表组成:
struct tag
{
member - list;
}variable - list;
例如描述一本书:
struct Book
{
char name[20];
char author[20];
char num[12];
float price;
};
特殊声明
结构体声明的时候,可以不完全声明,即省略结构体标签,这种结构体被称为匿名结构体:
struct
{
member-list;
}x;
由于匿名结构体没有名字,所以不能在程序的其他位置使用该结构体创建结构体变量,而只能在结构体声明的同时定义结构体变量,也就是说,匿名结构体只能使用一次。
我们可以用匿名结构体来描述一个学生:
struct
{
char name[20];
int age;
char sex[5];
char id[20];
}stu;
2、结构体的自引用
错误的自引用方式
struct Node
{
int data;
struct Node next;
};
上面这种结构体的声明方式是错误的,因为struct Node 中包含了一个struct Node 的Next,而Next中又会包含一个struct Node 的next,这样无限套娃,使得我们无法计算这个结构体的大小;正确的结构体自引用应该是一个结构体中包含指向该结构体的指针,如下所示:
正确的自引用方式
struct Node
{
int data;
struct Node* next;
};
一个结构体中包含了一个指向该结构体的指针,实现了结构体的自引用,同时,由于指针的大小是固定的(4/8个字节),所以该结构体的大小也是可计算的。
3、结构体变量的定义和初始化
结构体定义变量一共有两种方式,一种是在进行结构体声明的同时定义结构体变量,另一种是利用结构体类型来定义结构体变量。
struct Point
{
int x;
int y;
}p1;
struct Point p2;
结构体变量的初始化和数组变量的初始化十分类似,在定义结构体变量的同时赋初值即可。
struct Stu
{
char name[15];
int age;
}s1 = { "zhangsan", 20 };
struct Stu s2 = { "lisi", 22 };
struct Node
{
int data;
struct Point p;
struct Node* next;
}n1 = {10, {4,5}, NULL};
struct Node n2 = {20, {5, 6}, NULL};
4、结构体传参
结构体传参分为两种方式:一种是传递整个结构体,这时形参需要创建一个与源结构体同等大小的空间来接收,结构体过大浪费空间的同时时会十分影响效率;另一种是传递结构体的地址,这时无论源结构体有多大,形都参只需要用一个结构体指针来接收,节省空间的同时提高效率。
struct S
{
int data[1000];
int num;
};
struct S s = { {1,2,3,4}, 1000 };
void print1(struct S s)
{
printf("%d\n", s.num);
}
void print2(struct S* ps)
{
printf("%d\n", ps->num);
}
int main()
{
print1(s);
print2(&s);
return 0;
}
print1 和 print2 相比,首选 print2 函数,原因如下:
函数传参的时候,参数是需要压栈,会有时间和空间上的系统开销。 如果传递一个结构体对象的时候,结构体过大,参数压栈的的系统开销比较大,所以会导致性能的 下降。 结论:结构体传参的时候,要传结构体的地址。
5、结构体内存对齐(重要)
结构体内存对齐是结构体大小的计算规则,是校招笔试和面试过程中一个十分热门的考点,希望大家认真对待。
在学习结构体内存对齐之前,我们先给两组计算结构体大小的题目,看看你能否做对:
#include <stdio.h>
struct S1
{
char c1;
int i;
char c2;
};
struct S2
{
char c1;
char c2;
int i;
};
int main()
{
printf("%d\n", sizeof(struct S1));
printf("%d\n", sizeof(struct S2));
return 0;
}
对答案有疑问的同学不要慌,我们在学习结构体内存对齐的过程中来分析答案的由来。
结构体内存对齐的规则
关于结构体内存对齐规则,大部分参考资料是这样说的:
-
第一个成员在与结构体变量偏移量为0的地址处。 -
其他成员变量要对齐到它的对齐数的整数倍的地址处。
- 对齐数 = 编译器默认的对齐数与该成员变量大小的较小值。
- VS的默认对齐数是8.
- 只有VS编译器下才有默认对齐数的概念,其他编译器下变量的对齐数 = 变量的大小
-
结构体总大小为最大对齐数的整数倍。(最大对齐数为所有变量的对齐数的最大值) -
如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小为所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
知道了最大对齐数的对齐规则,我们再来看上面的练习题:
struct S1
{
char c1;
int i;
char c2;
};
我们假设struct S1的起始位置为图中箭头所示位置,则各位置的偏移量如图;由内存对齐的规则: 第一个成员在与结构体变量偏移量为0的地址处:所以c1在偏移量为0处,且c1占一个字节; 其他成员变量要对齐到它的对齐数的整数倍的地址处:由于 i 的对齐数是4,所以 i 只能从偏移量为4的位置开始存储,且 i 占四个字节; 其他成员变量要对齐到它的对齐数的整数倍的地址处:由于 c2 的对齐数是1,所以 c2 紧挨着 i 存储,且 c2 占一个字节; 结构体总大小为最大对齐数的整数倍:由于最大对齐数为4,所以总对齐数要为4的倍数,大于9的最小的4的倍数为12,所以整个结构体的大小为12个字节。
struct S2
{
char c1;
char c2;
int i;
};
如图:c1 从0偏移处开始,占一个字节;c2 对齐数为1,所以紧挨着 c1 存储,占一个字节;i 对齐数为4,所以在4的整数倍位置 – 4偏移处开始存储,占4个字节;存放完毕后0~7一共占8个字节,因为最大对齐数为4,8为4的整数倍,所以不变。
6、offsetof函数
函数功能
检索结构体成员相对于结构体开头的偏移量。
函数参数
size_t offsetof( structName, memberName );
# size_t 函数返回值,返回成员的偏移量;
# structName 参数,结构体变量名;
# memberName 参数,成员变量名;
函数使用
#include <stdio.h>
#include <stddef.h>
struct S1
{
char c1;
int i;
char c2;
};
struct S2
{
char c1;
char c2;
int i;
};
int main()
{
printf("%d\t", offsetof(struct S1, c1));
printf("%d\t", offsetof(struct S1, i));
printf("%d\n", offsetof(struct S1, c2));
printf("%d\t", offsetof(struct S2, c1));
printf("%d\t", offsetof(struct S2, c2));
printf("%d\n", offsetof(struct S2, i));
return 0;
}
7、为什么存在内存对齐
从上面的例子我们可以看到,结构体内存对齐会浪费一定的内存空间,但是计算机不是要尽可能的做到不浪费资源吗?那为什么还要存在内存对齐呢?关于内存对齐存在的原因,大部分的参考资料是这样说的:
- 平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
- 性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。 原因在于:为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。所以内存对齐能够提高访问效率。
- 总体来说:结构体的内存对齐是拿空间来换取时间的做法。
这里我对原因中的第二点做一下解释:
大家都知道,我们的机器分为32位机器和64位机器,这里的32位和64位其实指的是CPU的位数,而CPU的位数对应着CPU的字长,而字长又决定着CPU读取数据时一次访问多大即空间,即一次读取几个字节,我们以32位机器为例: 如图,32位机器一次访问四个字节的大小,如果不存在内存对齐,那么要取出 i 中的数据需要两次读取,存在内存对齐则只需要读取一次。
设计结构体的技巧
在了解了结构体的对齐规则之后,有没有一种方法能让我们在设计结构体的时候既满足对齐规则,又能尽量的节省空间呢?其实是有的,方法就是:**让占用空间小的成员尽量集中在一起。**就像的习题,我们把占用空间下的 c1 和 c2 放在一起,从而使得 struct S2 比 struct S1 小了四个字节。
8、修改默认对齐数
我们可以使用 “#pragma pack(num)” 命令来修改VS中的默认对齐数。例如:
#include <stdio.h>
#pragma pack(8)
struct S1
{
char c1;
int i;
char c2;
};
#pragma pack()
#pragma pack(1)
struct S2
{
char c1;
int i;
char c2;
};
#pragma pack()
int main()
{
printf("%d\n", sizeof(struct S1));
printf("%d\n", sizeof(struct S2));
return 0;
}
在 struct S2 中,我们通过 " #pragma pack(1) " 命令把VS的默认对齐数设置为1(相当于不对齐),使得其大小变为6。
9、结构体大小计算习题
习题1
#include <stdio.h>
struct S3
{
double d;
char c;
int i;
};
int main()
{
printf("%d\n", sizeof(struct S3));
return 0;
}
d 从0偏移处开始存储,占8个字节,所以0~7;c 紧挨 d 存储,占一个字节,所以8,i 从4的整数倍即12处开始存储,占4个字节,所以12~15;所以0 ~ 15合计16个字节,16为最大对齐数8的倍数,所以不变。
习题2
#include <stdio.h>
struct S3
{
double d;
char c;
int i;
};
struct S4
{
char c1;
struct S3 s3;
double d;
};
int main()
{
printf("%d\n", sizeof(struct S4));
return 0;
}
c1 从0偏移位置开始存储,占一个字节,所以0;struct S3 s3 我们上面已经算出占16个字节,又因为嵌套的结构体对齐到自己的最大对齐数的整数倍处,所以从8的整数倍即8偏移处开始存储,所以8~23;d 从8的整数倍即24偏移处开始存储,占8个字节,所以24~31;合计32个字节,且为最大偏移数8的整数倍,所以不变。
习题3
#include <stdio.h>
#pragma pack(4)
struct tagTest1
{
short a;
char d;
long b;
long c;
};
struct tagTest2
{
long b;
short c;
char d;
long a;
};
struct tagTest3
{
short c;
long b;
char d;
long a;
};
#pragma pack()
int main(int argc, char* argv[])
{
struct tagTest1 stT1;
struct tagTest2 stT2;
struct tagTest3 stT3;
printf("%d %d %d", sizeof(stT1), sizeof(stT2), sizeof(stT3));
return 0;
}
stT1:
a: 0~1 d:2 b:4~7 c:8~11 合计:0~11 = 12(4的倍数);
stT2:
b:0~3 c:4~5 d:6 a:8~11 合计:0~11 = 12(4的倍数);
stT3:
c:0~1 b:4~7 d:8 a:12~15 合计:0~15 = 16(4的倍数);
二、位段
1、什么是位段
在我们的生活中总有一些数据的取值情况是小于一个字节的,比如月份的取值是1~12,那么只需要4个比特位就能表示所有的月份;一周的星期是1 ~ 7,那么只需要3个比特位就能涵盖所有取值;又比如人的性别是男和女,那么只需要一个比特位就能表示所有情况。基于上面这种情况,C语言中出现了位段的概念。
位段:C语言允许在一个结构体中以位(比特位)为单位来指定其成员所占内存长度,这种以位为单位的成员称为 " 位段"或称 “位域” ( bit field) ;利用位段能够用较少的位数存储数据。
2、位段的声明
位段的声明和结构是类似的,只有两个不同:
-
位段的成员必须是 int、unsigned int 、signed int 或者是 char 。 (一般来说,一个结构体的所有位段成员的数据类型是相同的 ,即要么全为 int,要么全为 char) -
位段的成员名后边有一个冒号和一个数字。
例如:
struct S
{
char a : 3;
char b : 4;
char c : 5;
char d : 4;
};
int main()
{
struct S s = { 0 };
s.a = 10;
s.b = 12;
s.c = 3;
s.d = 4;;
}
3、位段的内存分配
- 位段的成员可以是 int unsigned int signed int 或者是 char(属于整形家族) 类型
- 位段的空间是按照需要以4个字节( int )或者1个字节( char )的方式来开辟的。
- 位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。
- 总结:跟结构体相比,位段可以达到同样的效果,且可以很好的节省空间,但是有跨平台的问题存在。
4、位段的跨平台问题
- int 位段被当成有符号数还是无符号数是不确定的。
- 位段中最大位的数目不能确定。(16位机器下 int 最大为16比特,32位机器最大为32比特,如果在32位机器下写成27,在16位机器会上运行时就出问题。
- 位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。
- 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,这些剩余的位是舍弃还是利用,这是不确定的。
以上面的 struct S 为例:
首先,位段成员的数据类型是 char,那么编译器就会在内存中为 struct S 开辟一个字节的空间,如果不够,再继续开辟; 接着, a 占3个比特,b 占4个比特,加起来一共7个比特,所有第一个字节中现在还剩下一个比特的空间; 然后,c 需要5个比特的空间,这里问题来了,c 是直接从后面一个字节中4中拿5个比特,还是说先从后面的字节中拿4个比特,再从前面的字节中拿剩下的一个比特,即当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,这些剩余的位是舍弃还是利用呢?这是C语言标准未定义的; 最后,我们再来看 main 函数,在 main 函数中我们把10赋给结构体中的,我们知道10的二进制序列为 1010,但是 a 变量只有3个比特的大小,所以10会发生截断后将 010 放入 a 中,但是这里问题又来了,010是放进靠左的三个比特,还是放进靠右的三个比特呢?即位段中的成员在内存中从左向右分配,还是从右向左分配呢?这也是C语言标准未定义的; 所以我们说,位段涉及很多不确定因素,是不跨平台的,注重可移植的程序应该避免使用位段。
5、VS下位段的使用习惯
这里我直接说结论,在VS编译器下,位段的使用习惯是这样的:
- 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,直接舍弃剩余的位;
- 位段中的成员在内存中是从右向左分配的;
接下来我们来验证这个结论:还是用上面那个结构体
struct S
{
char a : 3;
char b : 4;
char c : 5;
char d : 4;
};
int main()
{
struct S s = { 0 };
s.a = 10;
s.b = 12;
s.c = 3;
s.d = 4;;
}
以VS下位段的使用习惯条件下: 首先,此结构体 a 和 b 变量占去一个字节中的7的比特位,并把最后一个比特位丢弃;c 变量占一个比特位,并把剩余的3个比特位丢弃;d 变量占4个比特位,并把剩下的四个比特位丢弃;所以 struct S 一共占3个字节打下;然后在 main 函数中把结构体成员全部初始化为0;此时内存中的数据是:00 00 00 00 | 00 00 00 00 | 00 00 00 00; 然后,对于 a 变量来说,由于10的二进制序列为 1010,大于3个比特,所以会10会截断变成 010 后放入第一个字节中靠右的3个比特位中,此时内存中的数据是:00 00 010 10 | 00 00 00 00 | 00 00 00 00; 对于 b 变量来说,12的二进制序列是 1100,b 变量能放下,所以 1100 会放入第一个字节中 a 数据的前面,此时内存中的数据是:01 10 00 10 |00 00 00 00 | 00 00 00 00; 对于 c 变量来说,,3的二进制序列为 11,小于5个比特,所以补0变成 00011 后放入第二个字节中靠右的比特位中,此时内存中的数据是:01 10 00 10 |00 00 00 11 | 00 00 00 00; 对于 d 变量来说,4的二进制序列为 100,小于4个比特,补0变成 0100 后放入第三个字节中靠右的比特位中,此时内存中的数据是:01 10 00 10 |00 00 00 11 | 00 00 01 00; 所以最终内存中的数据变为:01 10 00 10 |00 00 00 11 | 00 00 01 00,转变为16进制就是 0x 62 03 04;
在VS下测试发现结果正如我们预料的那样,所以结论成立。
6、位段的用途
我们了解了位段的优缺点之后,可能有的同学会有疑惑,位段存在这么大的问题,在实际开发中真的会用到它吗?其实是会的,位段的一个常见的用途就是用于 ip数据报,如图:
如图:在 ip数据报中,版本只占4个比特,头部长度只占4个比特,服务类型只占8个比特,等等,如果这些数据我们都用一个整形大小,即32个比特位来存储的话,那么就会在一定程度上增加数据报的大小,从而增加网络负载,减缓传输效率,所以在这里,位段的作用就得到了很好的体现。
二、枚举
1、什么是枚举
顾名思义,枚举就是一一列举,把一个数据可能的取值全部列举出来,比如一周有七天,一年有12个月,性别有男、女,这些都是枚举的使用场景。
2、枚举类型的声明
enum Day
{
Mon,
Tues,
Wed,
Thur,
Fri,
Sat,
Sun
};
enum Sex
{
MALE,
FEMALE,
SECRET
};
enum Color
{
RED = 3,
GREEN,
BLUE
};
以上定义的 enum Day , enum Sex , enum Color 都是枚举类型。 大括号中的内容是枚举类型的可能取值,也叫枚举常量 。这些枚举常量都是有值的,默认从0开始,每次递增1,当然我们也可以在定义的时候为其赋初值,给某一枚举常量赋初值之后,其后面的常量仍然是每次递增1。
3、枚举的优点
我们知道,在C语言中我们可以利用 #define 来定义常量,那为什么还要单独设计出一个枚举类型来定义枚举常量呢?其实是因为枚举有如下优点:
- 增加代码的可读性和可维护性 :我们使用枚举常量来给枚举变量赋值,可以使得这个变量变得有意义,增加其可读性和可维护性;
- 和 #define 定义的标识符相比,枚举有类型检查,更加严谨:在使用像C++这种语法检查较为严格的编程语言时,枚举变量必须用枚举常量来赋值,而不能使用普查常量来赋值;
- 防止了命名污染(封装);
- 便于调试 :用 #define 定义的常量在程序的预处理阶段就会被替换掉,不便于调试观察;
- 使用方便,一次可以定义多个常量;
4、枚举的使用
enum Color
{
RED = 1,
GREEN = 2,
BLUE = 4
};
enum Color clr = GREEN;
三、联合
1、什么是联合
联合是一种特殊的自定义类型,这种类型定义的变量包含一系列的成员,特征是这些成员公用同一块空间(所以联合也叫共用体)。
2、联合的声明
联合的声明与结构体的声明十分类似,只是把关键字 struct 变为了 union。
union tag
{
member - list;
}variable - list;
例如:
union Un
{
char c;
int i;
};
union Un un;
3、联合的特点
联合的成员是共用同一块内存空间的,所以一个联合变量的大小,至少是最大成员的大小(因为联合至少得有保存最大的那个成员的能力)。
union Un
{
int i;
char c;
};
union Un un;
int main()
{
printf("%d\n", &un);
printf("%d\n", &(un.i));
printf("%d\n", &(un.c));
}
因为联合体成员公用同一块内存空间,所以联合变量的地址与每个联合成员变量的地址都是相同的。
4、联合大小的计算
联合大小的计算规则如下:
- 联合的大小至少是最大成员的大小。
- 当最大成员大小不是最大对齐数的整数倍的时候,要对齐到最大对齐数的整数倍。
例如:
union Un1
{
char c[5];
int i;
};
union Un2
{
short c[7];
int i;
};
int main()
{
printf("%d\n", sizeof(union Un1));
printf("%d\n", sizeof(union Un2));
}
union Un1:c 占的对齐数为1,占5个字节;i 的对齐数为4,占4个字节;二者共用一块内存本来只需要5个字节的大小,但是由于需要对齐到最大对齐数的整数倍处,所以 union Un1 最终占8个字节; union Un2:c 的对齐数为2,占14个字节;i 的对齐数为4,占4个字节;二者共用一块内存本来只需要14个字节的大小,但是由于需要对齐到最大对齐数的整数倍处,所以 union Un2 最终占16个字节;
5、利用联合判断大小端
在前面的文章中我们介绍大大小端,并且提供了判断大小端的代码,今天我们用联合的方法来实现对判断大小端的判断:
#include <stdio.h>
int Check_Sys()
{
union
{
char c;
int i;
}un;
un.i = 1;
return un.c;
}
int main()
{
int ret = Check_Sys();
if (ret == 1)
printf("小端\n");
else
printf("大端\n");
return 0;
}
|