在之前的博客中我们讲到了C语言有三种自定义类型:结构体(结构)、枚举和联合,在这篇博客中我们将更加深入地探讨这三种自定义类型。
结构体
1.结构体的声明
struct tag
{
int a;
char ch;
int arr[3];
double d;
float f;
}t1,t2;
如上,struct是结构体关键字,而tag是结构体命名,一般根据结构体的需要对其进行合适的命名。而大括号内部的是成员变量,它可以是不同类型的变量,成员变量之间用分号隔开。大括号外面的是变量列表,t1,t2是用这个结构体类型来创建的结构体变量。
特殊的结构体类型声明(不完全声明):匿名结构体类型
顾名思义,匿名结构体就是在声明的时候部队结构体命名,没有tag部分。这种结构体类型只能在声明的时候在变量列表创建变量,只能在这里使用一次。 匿名结构体我们一般用在函数内部,在函数内部进行声明并创建变量使用,而出了函数就不再需要的时候我们就可以用这种声明。
2.结构体的自引用
结构体声明时能不能在内部创建一个自己的变量?答案是不能的,因为如果一个结构体内部包含一个同类型结构体的变量时,通俗来说就是会无限套娃,我们是无法得知这种类型创建的变量的内存大小的,所以编译器不会让这种代码通过编译。而真正的结构体自引用是在一个结构体内部包含一个同类型结构体的指针,有了这样的想法,我们就能实现链表等数据结构。 结构体自引用不能用匿名结构体类型。
typedef struct Node
{
int data;
struct Node* next;
}Node;
在这里我们用typedef关键字对这个结构体类型重命名了。这个结构体类型包含一个数据变量和一个该结构体类型的指针。
3.结构体变量的定义和初始化
结构体有两种定义形式,一种是在声明时直接在变量列表中定义,另外一种是用这个结构体类型去创建变量,这两种多余的时候都能直接对变量初始化。
在对结构体初始化的时候要用一个大括号,与数组一样,对多个元素同时赋值要用大括号括起来。如果一个结构体内部包含另一个结构体,在初始化的时候,内部的结构体的初始化也要用一个大括号括起来。
struct Stu
{
char name[20];
int age;
}s1={"zhangsan",18};
struct School
{
struct Stu s;
int score;
}S1={{"zhangsan",18},100};
int main()
{
struct Stu s2 = { "lisi",19 };
struct School S1 = { {"lisi,19",85} };
return 0;
}
4.结构体内存对齐
上面我们已经能够了解结构体的基本使用了,现在要深入讨论一个问题,结构体的大小。要计算结构体的大小就要知道结构体的内存对齐。
struct S1
{
char ch1;
char ch2;
int n;
};
struct S2
{
char ch1;
int n;
char ch2;
};
int main()
{
int size1 = sizeof(struct S1);
int size2 = sizeof(struct S2);
printf("%d\n", size1);
printf("%d\n",size2);
return 0;
}
对于上面这一个代码,我们可能会不理解,为什么结构体的大小不是成员变量大小的和呢?为什么成员变量相同,只是位置换了结构体大小却不一样了呢?
下面我们先来讲一些结构体内存对齐的规则,相信看完之后我们就能很容易解决上面的两个问题、
(1)第一个成员在于结构体变量偏移量为0的地址处。偏移量就是距离结构体起始位置所隔的字节数,比如起始位置开始第一个字节偏移量为0,第二个字节偏移量为1;
(2)其他成员变量要对齐到偏移量为某个数字(对齐数)的整数倍的地址处。
对齐数=编译器默认对齐数 与 该成员大小 的较小值。
vs编译器中默认对齐数为8,其他的编译器则没有默认对齐数,他们的对齐数就是自身的大小。
比如第二个成员为int类型的变量,它的起始位置就是偏移量为4的整数倍的位置。
(3) 结构体的大小为最大对齐数(每一个成员都有一个对齐数,最大对齐数就是他们之中的最大值)的整数倍
(4) 如果嵌套了结构体的情况下,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的大小就是所有对齐数中的最大对齐数的整数倍。
拿上面的struct S1举例,第一个成员char ch1 对齐到偏移量为0的位置,占一个字节.而ch2 自身大小为一个字节,默认对齐数是8,所以他的对齐数是1 ,可以放到偏移量为1的位置。n自身是4个字节,默认对齐数是8,所以n的对齐数是4,放到偏移量为4 的整数倍的位置,刚好可以放到偏移为4的位置
。
此时这三个元素所占的空间为8个字节,而这个结构体的最大对齐数为4,8是4的整数倍,所以这个结构体的大小就是8个字节。
以此类推,struct S2创建的变量的大小为12个字节。
为什么会存在内存对齐?
大部分的参考资料都是如是说的:
(1)平台原因(移植原因)
不是所有的硬件平台都能访问任意地址上的任意数据的。某些硬件平台只能在某些地址处去某些特定类型的数据,否则抛出硬件异常。假如某个硬件平台只能在地址为2的倍数的地址处去读取整型,这就需要内存对齐来保证可移植性。
(2)性能原因
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。
原因在于,为了访问未对齐的内存,编译器需要做两次内存访问;而对齐的内存访问只需要一次内存访问。
总的来说,结构体的内存对齐就是拿空间来换取时间的做法。
在设计结构体的时候,怎么做才能尽可能节省空间?
我们可以把内存小的成员尽量集中放在一起,就比如上面的S1和S2,他们的成员顺序,内存也不同。
我们也可以对编译器的默认对齐数进行修改,可以用#progma这个预处理指令来修改默认对齐数。
#progma pack(4) 就是把默认对齐数修改为4;
#pragma pack() 就是取消设置的默认对齐数,还原为编译器的默认对齐数。
struct St1
{
char ch1;
double d;
char ch2;
};
#pragma pack(4)
struct St2
{
char ch1;
double d;
char ch2;
};
#pragma pack()
int main()
{
printf("%d\n", sizeof(struct St1));
printf("%d\n", sizeof(struct St2));
return 0;
}
对于上面这两个结构体,当我们修改默认对齐数为4的时候,St2的大小就变成了16个字节。
如果我们不想要结构体内存对齐的话,可以把默认对齐数修设置为1;
当我们觉得某个结构体用默认对齐数的话内存浪费太大了或者不太合适,在声明这个结构体的时候对默认对齐数进行修改,声明完之后再改回来。
5.结构体传参
结构体传参我们之前讲到过传值和传址的区别,传值调用的时候形参压栈会浪费空间和时间,增加系统开销,所以我们最好传结构体的时候传它的地址。如果我们不想在函数内部误操作改变其内容,我们可以在形参部分用const对其进行修饰。
6.结构体实现位段
位段的声明和结构体类似,但是又有一点区别,有两个不同:
(1)位段的成员必须是int 、unsigned int、signed int 或者char类型的数据
(2)位段的成员名后面有一个冒号和数字。
位段的位是比特位的意思,成员变量冒号后面的数字就是分配给成员的比特位的个数。因为我们写代码时有些变量的取值范围没有这么大,用不到这么多的比特位,而位段能节省内存空间。位段是一种节省空间的做法,位段是一种特殊的结构体,而结构体是以空间换时间的做法。那么位段的结构体大小怎么算?比如下面的struct A的类型的变量内存是多大。
struct A
{
int a : 10;
int b : 10;
int c : 20;
int d;
};
int main()
{
printf("%d\n", sizeof(struct A));
return 0;
}
我们要先了解位段的内存分配:
1 首先要确定一点,位段的成员类型只有int 和char 以及他们的有符号和无符号类型。同时,我们在使用位段的时候成员都是同一个类型的,不会把不同的成员放在同一个位段里,这样做就搞得太复杂了。同时,成员后面的数字不能大于该类型原来的大小。
2.位段的空间开辟是按照需要以4个字节(int)或者1个字节(char)的方式来开辟的。
3.位段涉及很多不确定因素,位段是不跨平台的,注意可移植的程序应该避免使用位段。
拿上面的struct A来举例,由于位段成员是int类型,首先开辟四个字节(32个比特位),低十个比特位存a,然后接下来十个比特位存b。这时候开辟的四个字节剩余的比特位只有12位了,不够c的空间,所以再开辟四个字节的空间,前面四个字节剩余的空间就不会用了。然后将c存进新开辟的四个字节的低20个比特位中。d是一个整型类型,所以我们需要再开辟四个字节来存储d,所以这个位段的内存大小为12个字节。
因为编译器分配内存的时候是按一个或者四个字节来分配的,所以还是会有空间浪费,但是这样可以减少空间的浪费,提高空间的利用率。
位段的跨平台问题:
1.int位段被当成有符号数还是无符号数是标准未定义的,在位段中的int是不确定是unsigned int还是signed int 的,其他的时候单独使用int都是默认为signed int ,而char类型是signed char 还是unsigned char不管在不在位段中都是C语言标准未定义的。
2.位段中数据的最大位数不能确定(比如int类型在十六位机器上是16个比特位,而在三十二位机器上是32个比特位,如果把位段中的int写成30个比特位,可以在三十二位机器上正常运行,但是在十六位机器上就会出问题)。
3.位段中的成员在内存中是从左向右分配还是从右向左分配(同一个字节实现使用低地址还是先使用高地址)是标准未定义的,我们上面举例的规则是从低地址开始分配,这只适用于vs编译器、
4.当一个结构体包含两个位段,第二个位段成员比较大,无法容纳与第一个位段剩余的位时,是舍弃剩余的位,还是使用这些剩余的位,然后将剩余的数据存储到新开辟的空间中,这是不确定的。就比如我们上面举例中的c变量,在vs编译器中是直接舍弃前一块空间剩余的位,将c的数据全部存在新的空间中,但是在其他的编译器中我们是不知道是不是这样的。
枚举
枚举顾名思义就是一一列举,把可能的值都列举出来。在我们生活中可以一一列举的东西也有很多,比如一周是从星期一到星期天,人的性别是男和女等等。
枚举类型的定义,一一周七天为例:
enum DAY
{
Mon,
Tues,
Wed,
Thur,
Fri,
Sat,
Sun
};
如上图,枚举成员之间是用逗号隔开的,也没有类型之分。
当我们用枚举类型来创建变量时,只能用枚举的可能取值来给变量赋值。
比如
int main()
{
enum DAY d = Fri;
return 0;
}
我们之前提到过枚举的成员是一种枚举常量,而枚举常量其实也是有具体的值的,默认取值是从0开始,比如Mon的取值时0,Tues的取值是1。我们也可以更改枚举常量的取值,比如我们在声明时可以Thur=100,这样的话Thur的值就是100了,而它后面的值依次加一。
enum DAY
{
Mon,
Tues,
Wed,
Thur=100,
Fri,
Sat,
Sun
};
int main()
{
printf("%d\n", Mon);
printf("%d\n", Tues);
printf("%d\n", Wed);
printf("%d\n", Thur);
printf("%d\n", Fri);
printf("%d\n", Sat);
printf("%d\n", Sun);
return 0;
}
我们可能想到之前提到过的#define也可以定义常量,那为什么要用枚举呢?
枚举的优点:
1.增加代码的可读性和可维护性。对于#define定义的标识符常量,在预编译期间就将标识符进行了替换,所以我们是无法调试观测#define定义的常量的,而枚举相当于一种类型,是可以调试的。
2.和#define相比,枚举有类型检查更加严谨,而#define是不会类型检查的,只是进行替换操作。
3.防止了命名污染。
4.便于调试。
5.使用方便,一次可以定义多个变量。
联合(共用体)
联合也是一种特殊的自定义类型,这种类型定义的变量也包含一系列的成员,特征是这些成员共用一块空间,所以也叫共用体。
联合的声明与结构体相同,联合的关键字是union。
union Un
{
int a;
char c[5];
};
因为联合具有所有成员共用一块空间的特性,所以联合的成员的地址和整个联合的地址时相同的。当两个变量不会同时被使用的时候,就可以使用联合。在使用联合的时候,对其中一个成员进行赋值,会改变另一个成员的值,直接覆盖了。
联合的大小:
联合的所有成员共用一块内存,所以联合的大小至少是最大成员的大小。
联合也存在内存对齐,联合的内存大小也是成员最大对齐数的整数倍。
就比如上面声明的联合,char c[5]的对齐数是1,占用连续的五个字节的空间,a的对齐数是4,占用前四个字节,所以这个联合最大对齐数是4,所以整个联合的大小是8.
联合的特性也可以用来判断当前机器是大端存储还是小端存储。
int fun(void)
{
union
{
char ch;
int n;
}Un;
Un.n = 1;
return Un.ch;
}
int main()
{
int ret = fun();
if (1 == ret)
{
printf("小端存储\n");
}
else
{
printf("大端存储\n");
}
return 0;
}
上面的代码中我们用了一个匿名联合,在声明的时候定义了一个联合变量U你。
因为联合的成员是共用一块空间的,而ch占用这块空间的第一个字节,n占用这块空间的四个字节,这个联合的大小也是四个字节。 当我们对n赋值为1时,如果是小端存储,低位字节放在低字节处,那么n的第一个字节存放01,高地址的三个字节存放00 00 00,这时ch的值也被改成01。如果是大端存储,低位字节被存放在高地址处,那么n的存储内容就是 00 00 00 01,那么ch的内容就是00 ,所以可以用这个特点来判断机器的字节序存储方式。