目录
在前面的学习中,我们就已经接触过结构体,现在,我们开始正式学习这部分内容。
1. 结构体类型的声明
首先,我们来学习一下结构体类型的声明与创建。
1.1 结构的声明
结构声明的格式如下:
struct tag
{
member-list;
}variable-list;
例如,当我们想要描述一名学生时,我们可以先声明这样的一个结构体类型:
struct Stu
{
char name[20];//名字
int age;//年龄
char sex[5];//性别
char id[20];//学号
}; //分号不能丢
1.2 结构体变量的创建和初始化
在成功声明结构体类型后,我们就可以开始创建结构体变量并进行初始化了。
例如:
#include <stdio.h>
struct Stu
{
char name[20];//名字
int age;//年龄
char sex[5];//性别
char id[20];//学号
}; //分号不能丢
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;
}
进行初始化时默认顺序是声明结构体类型时声明结构体成员的顺序。但是在初始化时也可以直接访问要初始化的结构体成员进行初始化而不需要按照声明顺序进行初始化。
name: 张三
age : 20
sex : 男
id : 20230818001
name: lisi
age : 18
sex : ⼥
id : 20230818002
可以发现,编译器成功打印出了信息,证明我们初始化结构体变量成功。
1.3 结构体类型的特殊声明
在声明结构的时候,可以不完全的声明。
例如:
struct
{
char name[20];
int age;
char sex[5];
char id[20];
}x1;
struct
{
char name[20];
int age;
char sex[5];
char id[20];
}x2,* p;
struct
{
char name[20];
int age;
char sex[5];
char id[20];
}x;
struct
{
char name[20];
int age;
char sex[5];
char id[20];
}x2,* p;
int main()
{
* p = &x1;
}
这里在声明两个结构体类型时并未声明结构体类型名,结构体类型能够正常声明,结构体变量也可以正常创建。这种结构体被称为匿名结构体。
但是,尽管我声明的两个结构体类型的结构体成员一模一样。但是,当我将x1地址赋值给指针p时,编译器却会报错:
Assigning to 'struct (unnamed struct at D:\C\C.test\study.c:15:1)' from
incompatible type 'struct (unnamed struct at D:\C\C.test\study.c:8:1) *'
这是因为,尽管结构体成员一模一样,结构体类型也没有命名,但是编译器依旧会将其作为两个不同的结构体类型。因此在将x1的地址赋值给p时,编译器会提醒这是两种不同类型的结构体。
因此,在声明结构体类型时,最好不要声明匿名结构体类型,给结构体类型取一个明确的名称。即使要声明匿名结构体,也只能保留一个。
1.4 结构体的自引用
在声明结构体类型时能否包含一个类型为该结构体类型的成员呢?
答案是可以的。
那么,是不是就应该这样写呢?
struct Node
{
int data;
struct Node next;
};
这样乍一看是没有问题的,但是仔细思考就会发现,如果要将一个Node类型的结构体变量next作为Node类型的结构体的成员。那么,Node类型应该是什么样的呢?结构体Node的大小又是多少呢?
我们会发现,此时代码就会陷入无限递归定义之中。
因此,我们需要换一种形式来实现预期的效果,而这种方法就是:将成员写为指向该类型的指针。
struct Node
{
int data;
struct Node * next;
};
2. 结构体内存对齐
在掌握了结构体的基本使用之后,我们现在来探讨另一个问题:结构体的大小。
而想要计算结构体的大小就需要知道结构体内存对齐的知识。
2.1 对齐规则
我们先简单了解一下对齐规则:
1. 结构体的第一个成员对齐到和结构体变量起始位置偏移量为0的地址处
2. 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
对齐数 = 编译器默认的一个对齐数与该成员变量大小的较小值。
例如:
Linux中gcc没有默认对齐数,对齐数就是成员自身的大小
VS 中默认的值为 8
3. 结构体总大小为最大对齐数(结构体中每个成员变量都有一个对齐数,所有对齐数中最大的)的 整数倍。
4. 如果嵌套了结构体的情况,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构 体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。
看起来可能很复杂,我们来看看几个示例:
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;
}
我们可以看到,上面的两个结构体类型的成员一模一样,只有声明的顺序区别,那么两个结构体类型的大小是否相同呢?
12
8
我们发现,打印结果并不相同,这个时候,我们来根据上面的对齐规则来思考原因:
我使用的编译器是CLion2024,在我测试了几次后,发现该编译器应该是没有默认对齐数的,对齐数就是成员本身大小。
那么,我们先看看S1:
struct S1
{
char c1;
int i;
char c2;
};
第一个成员c1,类型为char,大小为1,对齐位置为偏移量为0的起始位置,此时总大小为1。
第二个成员i,类型为int,大小为4,对齐位置应为4的倍数,而紧接的位置是1,显然不为4的倍数,因此,内存会偏移到4的位置,然后存储i的数据。此时总大小为8。
第三个成员c2,类型为char,大小为1,对齐位置为1的倍数,紧接的位置为9,属于1的倍数,c2存储在此,此时总大小为9。
之后没有其他成员,此时需要决定整个结构体的大小,该结构体类型中最大对齐数为int类型的大小--4。因此,结构体的大小需要是4的倍数并且大于9。最后,结构体的大小就为12。
看完S1后,我们来看看S2:
struct S2
{
char c1;
char c2;
int i;
};
第一个成员c1,类型为char,大小为1,对齐位置为起始位置0,此时总大小为1
第二个成员c2,类型为char,大小为1,紧接位置为1,为1的倍数,对齐位置为1,此时总大小为2
第三个成员i,类型为int,大小为4,紧接位置为2,不为4的倍数,因此内存偏移到4处,此时总大小为8
之后无成员,该结构体中最大对齐数为int类型的4,此时总大小为8,属于4的倍数,因此,该结构体的大小为8
看完这两个示例之后,我们应该差不多清楚了对齐的规则,接下来我们看看其他的示例:
struct S3
{
double d;
char c;
int i;
};
int main() {
printf("%d\n", sizeof(struct S3));
return 0;
}
类比上面两个示例,我们能轻松得出结果为16
16
那么,当出现结构体嵌套时,该怎么计算呢?
struct S3
{
double d;
char c;
int i;
};
struct S4
{
char c1;
struct S3 s3;
double d;
int i;
};
int main() {
printf("%d\n", sizeof(struct S4));
return 0;
}
在类比上面的示例后,我们很可能觉得答案应该是48,毕竟S3的大小为16,那么S4的内存大小必定为16的倍数,然而,答案并不是48,而是40。
40
那么,这个时候,我们就要好好思考一下对齐规则了。
我们看看第四条中的“嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构 体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍”。
因此,实际上,结构体S3的对齐数应该为S3中的最大对齐数,也就是8。而最后,结构体S4的大小只需要是8的倍数即可,最后结果就是40。
2.2 为什么存在内存对齐?
在学习的内存对齐的规则后,我们明显能发现一个问题,那就是,为了实现内存对齐,很容易就会造成空间的浪费。那么,为什么我们即使要浪费空间也要使用内存对齐呢?
1. 平台原因(移植原因):
不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定 类型的数据,否则抛出硬件异常。
2. 性能原因:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。假设一个处理器总是从内存中取8个字节,则地址必须是8的倍数。如果我们能保证将所有的double类型的数据的地址都对齐成8的倍数,那么就可以用一个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问,因为对象可能被分放在两个8字节内存块中。
总体来说:结构体的内存对齐是拿空间来换取时间的做法。
那在设计结构体的时候,我们如何做到既要满足对齐,又要节省空间呢?
我们可以在声明结构体类型时,让占用空间小的成员尽量集中在一起,例如上面示例中的:
struct S1
{
char c1;
int i;
char c2;
};
struct S2
{
char c1;
char c2;
int i;
};
2.3 修改默认对齐数
#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;
}
原本,结构体S的大小为12,在修改默认对齐数为1后,该结构体大小就变为了6
6
因此,结构体在对对齐方式不合适的时候,我们可以自己更改默认对齐数。
3. 结构体传参
当我们想要将一个结构体作为函数的参数传递时,我们有两种选择。一种是直接将结构体作为参数传递过去,另一种则是将该结构体的地址传递过去。
那么,这两种方法有上面区别呢?
我们知道,传递给函数的参数是形参,而形参是实参的一份临时拷贝。也就是说,如果函数想要调用结构体中的数据时,如果直接将结构体作为参数传递过去,编译器就需要先开辟空间用于临时存储结构体中的数据。如果结构体内存过大,会导致参数压栈的系统开销比较大,会导致性能的下降。相比之下,将地址作为参数传递给函数,函数就可以通过指针来直接访问结构体中的数据,这种方法就不会出现上面的问题,而且更高效。
4. 结构体实现位段
4.1 什么是位段
位段的声明和结构是类似的,但有两个不同:
1. 位段的成员必须是 int 、unsigned int 、 signed int 或者 char ,在C99中位段成员的类型也可以选择其他类型。
2. 位段的成员名后边有一个冒号和一个数字。
例如:
struct A
{
int _a:2;
int _b:5;
int _c:10;
int _d:30;
};
A就是一个位段类型。 那么,位段A所占内存的大小是多少?
printf("%d\n", sizeof(struct A));
8
4.2 位段的内存分配
1. 位段的成员可以是int 、unsigned int、signed int 或 char类型
2. 位段的空间是需要,以4个字节(int)或者1个字节(char)的方式来开辟的
3. 位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。
例如:
#include <stdio.h>
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;
printf("%d\n",s.a);
printf("%d\n",s.b);
printf("%d\n",s.c);
printf("%d\n",s.d);
return 0;
}
2
-4
3
4
我们发现,存储在成员a与b中的数据并不是我们希望的数据,这是为什么呢?
首先,位段S中的成员都为有符号类型的数据。
其中,成员a的大小为3bit,成员b大小为4bit,成员c大小为5bit,成员d大小为6bit
接着,abcd分别赋值为10,12,3,4
10的二进制形式为:1010,因为a大小只有3bit,只能存储010,首位为符号位,因此,结果为2.
12的二进制形式为:1100,而b大小恰好为4bit,能够存储下1100,首位为符号位,为负数,转化为原码形式打印出来结果就是-4。
3,4的2二进制形式分别为:011,100。而c,d的大小分别为5bit,6bit。能够准确存储数据。
4.3 位段的跨平台问题
1. int 位段被当成有符号数还是无符号数是不确定的。
2. 位段中最大位的数目无法确定。(16位机器最大数为16,32位机器最大数为32,如果写成27,在16位机器上会出现问题。
3. 位段中的成员在内存中从左向右分配,还是从右向左分配,标准尚未定义。
4. 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃 剩余的位还是利用,这是不确定的。
总结: 跟结构相比,位段可以实现同样的效果,并且可以很好的节省空间,但是存在跨平台的问题。
4.4 位段的应用
下图是网络协议中IP数据报的格式,我们可以看到其中很多的属性只需要几个bit位就能描述。在这里使用位段,既能够实现想要的效果,也节省了空间,使得网络传输的数据报大小变得较小一些,对网络的畅通是有帮助的。
4.5 位段使用的注意事项
位段的几个成员共有同一个字节,这样有些成员的起始位置并不是某个字节的起始位置,那么这些位置处是没有地址的。内存中每个字节分配一个地址,一个字节内部的bit位是没有地址的
因此不能对位段的成员使用取地址操作符&,而这样也就不能使用scanf直接给位段的成员输入值,只能是先将放输入值放在一个变量中,然后赋值给位段的成员。
例如:
#include <stdio.h>
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;
printf("%d\n",sa._b);
return 0;
}
当我输入1时:
1//此为输入值
1//此为打印值
可以看到,代码成功修改了结构体A中成员b的值。
完