C语言第十二章自定义类型:结构体
一.结构体类型的声明
1.结构体的概念
结构体是程序员自定义的数据类型,允许将不同类型的数据组合在一起。结构体是一些值的集合,这些值称为成员变量。结构的每个成员可以是不同类型的变量。
2.结构体的声明
struct tag
{
member-list;
} variable-list;
上述形式就是结构体的声明格式,其中struct tag为结构体类型;tag为结构体标签;struct为结构体的关键字;member-list为成员列表,用于创建不同数据类型的内容;variable-list为结构体变量名,表示用户用结构体类型创建的变量。下面举一个例子,创建一个学生结构体:
struct Stu
{char name[20];//名字 int age;//年龄 char sex[5];//性别char id[20];//学号
}; //分号不能丢
上述代码创建了struct Stu类型的结构体,其中的成员变量有学生的名字、年龄、性别和学号。这里需要注意的是:在创建结构体时,大括号外的分号不可以舍去。
3.结构体变量的创建和初始化
#include <stdio.h>
struct Stu
{char name[20];//名字 int age;//年龄 char sex[5];//性别 char id[20];//学号
}s3;
int main()
{//按照结构体成员的顺序初始化 struct Stu s1 = { "张三", 20, "男", "20230818001" };printf("name: %s\n", s1.name);printf("age : %d\n", s1.age);printf("sex : %s\n", s1.sex);printf("id : %s\n", s1.id);//按照指定的顺序初始化 struct Stu s2 = { .age = 18, .name = "lisi", .id = "20230818002", .sex =
"⼥" };printf("name: %s\n", s2.name);printf("age : %d\n", s2.age);printf("sex : %s\n", s2.sex);printf("id : %s\n", s2.id);return 0;
}
上述代码首先创建了关于学生的结构体,内部的成员有:名字、年龄、性别和学号。然后紧随其后创建了全局的结构体变量s3,这里在括号后面的分号前创建的就是结构体变量,因为该结构体变量是在大括号外部创建的,所以该变量为全局变量。
进入mian函数,首先创建了局部结构体变量s1,并进行了顺序初始化。对应着结构体成员的位置进行一一对应的顺序初始化。接下来打印操作用到的是结构体成员访问操作符:‘.’ ,对结构体成员进行访问,并打印初始化成功的结构体变量s1的结构体成员。
下来的结构体变量s2的初始化属于指定顺序的初始化,根据结构体成员放位操作符和指定的顺序进行内容的初始化。
4.结构体的特殊声明
在结构体声明的过程中,我们可以进行结构体的不完全创建。此时的声明就是结构体的特殊声明,该结构体的类型也被称为匿名结构体类型。
//匿名结构体类型
struct
{int a;char b;float c;
} x;
struct
{int a;char b;float c;
} a[20], *p;
上面两种结构体的声明形式均为特殊声明,均没有结构体正常声明的结构体标签,只有结构体的关键字。那么问题来了?
在上面代码的基础上,下面的代码合法吗?
p = &x;
结果告诉我们:不合法!原因是:编译器会把上面的两个声明当成完全不同的两个类型,所以是非法的。 匿名的结构体类型,如果没有对结构体类型重命名的话,基本上只能使用一次。
5.结构体的自引用
在结构中包含一个类型为该结构本身的成员是否可以呢?
比如定义一个链表的节点:
struct Node
{int data;struct Node next;
};
上述代码正确吗?如果正确,那sizeof(struct Node) 是多少?
上述代码是错误的,因为一个结构体中再包含一个同类型的结构体变量,就像病态的套娃一样,一个结构体内部竟然内放的是和自身一样大的结构体和一个数据,这显然不正确。结构体变量的大小就是无穷大,是不合理的。 正确的自引用方式如下:
struct Node
{int data;struct Node* next;
};
上述结构体的自引用操作为正确的,因为一个结构体内部储存了数据和一个指针变量,这显然是符合常理的。
在结构体自引用的过程中,夹杂了typedef对匿名结构体类型重命名,也容易引入问题,看看 下面的代码,可行吗?
typedef struct
{int data;Node* next;
}Node;
答案是不行的,因为Node是对前面的匿名结构体类型的重命名产生的,但是在匿名结构体内部提前使用Node类型来创建成员变量,这是不行的。
解决方案如下:定义结构体不要使用匿名结构体。
typedef struct Node
{int data;struct Node* next;
}Node;
二.结构体的内存对齐
理解了结构体的声明,下来我们就该学习计算结构体的大小:结构体的内存对齐。
1.内存对齐规则
(1)结构体的第一个成员对齐到和结构体变量起始位置偏移量为0的地址处。
其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
对齐数=编译器默认的一个对齐数与该成员变量大小的较小值。
- VS 中默认的值为 8
- Linux中 gcc没有默认对齐数,对齐数就是成员自身的大小
下面的习题以VS为例。
结构体总大小为最大对齐数(结构体中每个成员变量都有一个对齐数,所有对齐数中最大数)的整数倍。
如果嵌套了结构体的情况,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。
接下来进行结构体大小的练习:
//练习1
struct S1
{char c1;int i;char c2;
};
printf("%d\n", sizeof(struct S1));
//练习2
struct S2
{char c1;char c2;int i;
};
printf("%d\n", sizeof(struct S2));
//练习3
struct S3
{double d;char c;int i;
};
printf("%d\n", sizeof(struct S3));
//练习4-结构体嵌套问题
struct S4
{char c1;struct S3 s3;double d;
};
printf("%d\n", sizeof(struct S4));
上述四个练习和解答图片均为结构体大小的计算练习,在上述四张图解中,不同颜色表示不同变量的存储位置,一个小黑框表示一个字节。其中灰色颜色的框框表示该字节的内存为了内存对齐而浪费掉了。
2.为什么存在内存对齐?
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于:为了访问未对齐的内存,处理器需要两次内存访问,而对齐的内存访问仅需要一次访问。内存对齐是为了提高计算机系统的性能和效率。现代计算机的CPU在访问内存时,通常以特定大小的块(通常为4字节或者8字节)为单位进行读取。如果没有内存对齐,CPU可能需要多次访问内存才能获取完整数据,从而导致性能下降。假设一个处理器总是从内存中取8个字节,则地址必须是8的倍数。如果我们能保证将所有的double类型的数据的地址都对齐成8的倍数,那么就可以用一个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问操作,因为该数据可能被分放在两个8字节内存块中。
如果不存在内存对齐,用练习1进行举例:
struct S1
{char c1;int i;char c2;
};
printf("%d\n", sizeof(struct S1));
上述两张图片分别为不存在内存对齐和存在内存对其的情况,首先计算机的CPU在访问内存时,通常以4字节或者8字节的内存块为单位进行内存的读取。如果计算机CPU以4字节的内存块为单位进行内存的读取。当存在内存对齐时,每次CPU读取内存都会完整的读取到一个变量的数据;但是不存在内存对齐时,计算机CPU以4字节内存单位块进行读取,有可能会读取到不完整的数据,这时还需要下一次内存读取才能获取该残缺的数据。
所以这就是内存对齐的好处,结构体的内存对齐原理就是拿空间来换取时间。
3.修改默认对齐数
现在我们知道了内存对齐的好处,知道了内存对齐是以空间换取时间和效率。那么如果我们既想要内存对齐,又要节省空间,该怎么做呢?
一种方法是:让占用空间小的成员尽量集中在一起,另一种方法就是:修改默认对齐数。下面举第一种方法的例子:
//例如:
struct S1
{char c1;int i;char c2;
};
struct S2
{char c1;char c2;int i;
};
由于上述两种不同的结构体成员定义顺序,造成了结构体大小的不同。(在结构体成员不改变的前提下,将占用空间小的变量集中定义,会节省空间)。下面解答怎样修改默认的内存对齐数:
#pragma 这个预处理指令,可以改变编译器的默认对齐数。下面给出使用的例子:
#include <stdio.h>
#pragma pack(1)//设置默认对⻬数为1
struct S
{char c1;int i;char c2;
};
#pragma pack()//取消设置的对⻬数,还原为默认
int main()
{printf("%d\n", sizeof(struct S));return 0;
}
上述代码中,通过预处理指令对默认的内存对齐数进行修改,第一次使用,将默认的内存对齐数修改为1,第二次预处理目的是:取消第一次设置的对齐数1,重新还原为默认值。
通过上述的修改默认对齐数的学习,结构体在内存对齐方式不合适的时候,我们可以自己更改默认对齐数。从而到达预期的目的。
三.结构体传参
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函数。
原因: 函数传参的时候,参数是需要压栈,会有时间和空间上的系统开销。 如果传递一个结构体对象的时候,当结构体过大时,参数压栈的的系统开销比较大,所以会导致性能的下降,效率不高。而传地址不会有此类问题。
结论:结构体传参的时候,最好传递结构体的地址。
四.结构体实现位段
1.什么是位段?
位段是C语言中的一种数据结构特性,允许在结构体中按位分配成员变量的存储空间。通过位段,可以精确控制每个成员占用的空间大小,从而节省内存空间。
位段的声明方式是在结构体成员后添加冒号和位数(单位是比特位),例如:
struct A
{int _a:2;int _b:5;int _c:10;int _d:30;
};
位段的特点有:
1.节省空间:位段允许将多个成员变量压缩到一个整型变量中,减少内存的占用。
2.不能对成员变量取地址:因为位段的允许多个成员压缩到一个字节中,在计算机中一个字节对应一个地址,所以当多个变量压缩在一个字节的空间时,就不可以对其成员变量进行取地址。
A就是⼀个位段类型。那位段A所占内存的大小是多少?
printf("%d\n", sizeof(struct A));
2.位段的内存分配
1. 位段的成员可以是 int、 unsigned int 、signed int 或者是 char 等类型。
2. 位段的空间上是按照需要以4个字节( int )或者1个字节( char )的方式来开辟的。
3. 位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应避免使用位段。
//⼀个例⼦
struct S
{char a:3;char b:4;char c:5;char d:4;
};
struct S s = {0};
s.a = 10;
s.b = 12;
s.c = 3;
s.d = 4;
//空间是如何开辟的?
上述图片解释了位段的空间开辟,因为位段是不跨平台的,所以在这里假设位段分配空间的顺序为从右向左;假设第一个位段剩余空间无法容纳第二个位段成员时,对剩余空间进行舍弃,开辟新的空间存放第二个位段成员;假设位段中一次最多开辟8个比特位。
假设完成后就可以进行位段空间分配的分析了,首先开辟8个比特位,从右向左的3个比特位用于存放第一个位段成员初始化的值10的二进制补码,这时可以发现放不下,则只取10的二进制补码后3位放入开辟好的第一个位段空间中;此时剩余5个比特位的空间,对于存放第二个位段成员够用,所以先不开辟新的空间,继续在该空间存储第二个位段成员。将第二个位段成员12的二进制补码的后4位,从右向左存放在剩余空间处;此时空间只剩下最左边的1个比特位,明显不够存储第三个位段成员,所以此时从右段进行新空间的开辟(8个比特位)。然后从右向左存储第三个位段成员3的二进制补码的后5位;此时剩余的空间为3个比特位,不够存储最后一个位段成员,所以继续进行新空间的开辟(8个比特位),这时将最后一个位段成员4的二进制补码的后4位从右向左存储在新开辟的空间处。
最后内存存储的具体数值为:01100010 00000011 00000100化为16进制的内存形式为:62 03 04。刚好等于上述图片的内存监视区的数值。
3.位段的跨平台问题
1. int 位段被当成有符号数还是无符号数是不确定的。
2. 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机器会 出问题,但是在32位机器却不会出现问题。)
3. 位段中的成员在内存中从左向右分配,还是从右向左分配,C语言标准尚未定义。
4. 当一个结构包含两个位段,第二个位段成员比较大,第一个位段剩余的空间无法容纳,是舍弃 剩余的位还是利用,这是不确定的。
总结: 跟结构体相比,位段可以达到同样的效果,并且可以很好的节省空间,但是有跨平台的问题存在。
4.位段的应用
下图是网络协议中,IP数据报的格式,我们可以看到其中很多的属性只需要几个bit位就能描述,这里使用位段,能够实现想要的效果,也节省了空间,这样网络传输的数据报大小也会较小一些,对网络的畅通是有帮助的。
5.位段使用的注意事项
位段的几个成员共有同一个字节,这样有些成员的起始位置并不是某个字节的起始位置,那么这些位置处是没有地址的。内存中每个字节分配一个地址,一个字节内部的bit位是没有地址的。所以不能对位段的成员使用&操作符,这样就不能使用scanf函数直接对位段的成员输入值,只能是先输入放在一个变量中,然后赋值给位段的成员。如下代码所示:
struct A
{int _a : 2;int _b : 5;int _c : 10;int _d : 30;
};
int main()
{struct A sa = {0};scanf("%d", &sa._b);//这是错误的 //正确的⽰范 int b = 0;scanf("%d", &b);sa._b = b;return 0;
}