文章目录
前言
自定义类型 包括结构体(struct),联合体(union),枚举(enum)。
结构是一些值的集合,这些值称为成员变量。结构的每个成员可以是不同类型的变量。
数组与结构体的比较如下图所示
1. 结构体的基本使用
1.1 结构体变量的声明
struct Tag
{
member-list;
}variable-list;//可以直接在此处创建结构体变量,
//也可以在主函数中“类型 + 变量名”创建,即struct Tag s;
//(此时struct Tag与int都是类型名)
1.2 结构体变量的创建和初始化
#include <stdio.h>
struct Book //结构体类型
{
char book_name[20];
char author[20];
float price;
char id[19];
}b4,b5,b6; //结构体变量
int main()
{
//按照结构体成员顺序初始化
struct Book b1 = { "鹏哥C语言","鹏哥",38.8f, "PG20240520" };
//按照指定顺序初始化
struct Book b2 = { .id = "DG20240520", .book_name = "蛋哥Linux", .author = "蛋哥", .price = 55.5f };
printf("%s %s %f %s\n", b1.book_name, b1.author, b1.price, b1.id);
printf("%s %s %f %s\n", b2.book_name, b2.author, b2.price, b2.id);
return 0;
}
在VS2022 X86环境的测试结果如下
浮点数可能存在数据丢失
可以看到,38.8在内存中是没办法精确保存的,那么我们在判断浮点数值的时候也要考虑到这一点,一般情况下我们会考虑误差,如下图所示
#include <stdio.h>
#include <math.h>
int main()
{
float f = 38.8;
if (fabs(f - 38.8) < 0.000001)
printf("相等");
else
printf("不相等");
return 0;
}
在VS2022 X86环境的测试结果如下
相等
1.3 结构体的特殊声明
当在声明结构体的时候,如下所示,没有给Tag,那么这种声明称为不完全声明,这种结构体称为匿名结构体,这种结构体只能用一次,在声明的时候就要创建结构体变量。
struct
{
char name[10];
int age;
}s;
如下图所示,可以看到,s的类型是unnamed-tag
,而完全声明的s1类型是S,因为本来应该显示的是Tag,结果s都没有,且没有Tag,不能创建新的结构体变量。
我们看一下下面这个代码
struct
{
char name[10];
int age;
}s;
struct
{
char name[10];
int age;
}* ps;
int main() {
ps = &s;
return 0;
}
如下图所示,对于ps=&s
,系统会给出不兼容的提示,那么也就是说二者类型不同,虽然二者的成员一致,但系统还是会当做两个类型来看。
因此,不要匿名声明结构体,可以使用typedef关键字对其重命名,但这时候结构体有名字了,不是匿名结构体了,如下代码所示。
typedef struct
{
int a;
}Node;
int main()
{
Node n;
return 0;
}
1.4 结构体的自引用
顾名思义,也就是结构体自己引用自己
但是下面的代码绝对是不可以的,因为如果这样写的话,struct S就是无穷大了
struct S
{
char name[10];
int age;
struct S Next;
};
一般情况下我们采用指针(这是数据结构链表会用到的知识)
struct S
{
char name[10];
int age;
struct S* Next;
};
但是结构体自引用就不要对匿名结构体重命名了,Node在定义之前就用了,编译器不认识,根本编不过去
typedef struct
{
int a;
//Node* next; 错误
}Node;
int main()
{
Node n;
return 0;
}
一般情况下我们是这样定义链表的一个节点,
typedef struct Node
{
int data;
struct Node* next;
}Node;
补充:
数据结构线性表包括顺序表,链表,顺序表在内存中连续存放,与数组类似,但链表使用结构体自引用来实现,每一个节点包括两部分,一部分是数据域,用来存放数据,另一部分是指针域,用来存放指针,指向下一个节点,如下图所示。
2. 内存对齐
先看一个例子
#include <stdio.h>
#include <stddef.h>
struct S1
{
char a;
char b;
int n;
};
struct S2
{
char a;
int n;
char b;
};
int main() {
printf("%zd\n", sizeof(struct S1));
printf("%zd\n", sizeof(struct S2));
printf("%zd %zd %zd\n", offsetof(struct S1, a), offsetof(struct S1, b), offsetof(struct S1, n));
printf("%zd %zd %zd\n", offsetof(struct S2, a), offsetof(struct S2, n), offsetof(struct S2, b));
return 0;
}
在VS2022 X86环境的测试结果如下
8
12
0 1 4
0 4 8
可以看到,struct S1与struct S2均包含两个char类型和一个int型的变量,但调换成员顺序之后,大小发生了改变,这就是内存对齐导致的。
同时我们用库函数offsetof计算了结构体内每个结构体成员相较于结构体变量起始位置的偏移量。(其头文件为<stddef.h>
)。
官网记录offsetof的使用规则如下所示
2.1 对齐规则
1.结构体的第一个成员对齐到结构体起始位置偏移量为0的地址处
2.结构体的其他成员要对齐到对齐数的整数倍的地址处
对齐数 = 编译器默认的对齐数与该成员变量大小的较小值
VS中默认的数是8
Linux中gcc没有默认对齐数,对齐数就是成员变量自身大小
3.结构体的大小为最大对齐数的整数倍(所有成员的对齐数中最大的)
4.结构体中嵌套结构体,嵌套结构体成员对齐到自己成员中最大对齐数的整数倍处,结构体的大小是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍
struct S1的内存分布如下所示
struct S2的内存分布如下所示
以上分析与我们算出的struct S1与struct S2的大小、各成员偏移量一致。
我们接下来看一个嵌套结构体的例子
#include <stdio.h>
struct S3
{
double d;
char c;
int i;
};
struct S4
{
char c1;
struct S3 s3;
double d;
};
int main()
{
printf("%zd\n", sizeof(struct S4));
return 0;
}
在VS2022 X86环境的测试结果如下
32
struct S3的内存分布如下所示
struct S4的内存分布如下所示
我们可以看到,有很多内存被浪费掉了。
2.2 为什么会有内存对齐
大部分的参考资料给出下面的解释
1.平台原因(移植原因):
不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能砸某些地址处取特定类型的数据,否则抛出硬件异常。
2.性能原因:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需一次。假设一个处理器总是从内存中取8个字节,则地址必须是8的倍数。如果我们能保证所有的double类型的数据的地址都对齐成8的倍数,那么就可以用一个内存操作来读或写值了。否则,我们可能需要执行两次内存访问,因为对象可能被分放在两个8字节内存块中。
简而言之,结构体的内存对齐是拿空间来换取时间的做法。
对于第二点,我们举个例子
对于下面这个代码
struct S
{
int a;
double d;
};
我们分别从未对齐和对齐两种情况讨论,以字节为单位存储和访问,且字节在内存中是倒着存,每个字节内部,每个比特位从右往左存,如下图所示
对于未对齐的内存,即未对齐情况下的d,如果要访问d的话,要访问两次,而对齐情况下只需要访问一次。
那么在设计结构体的时候,既要满足对齐,又要节省空间,我们可以,
让占用空间小的成员尽量集中在一起。
比如下图前面提到的例子,S1比S2所占空间小,其中占用空间小的char类型数据分布在一起,比较紧凑。
2.3 修改默认对齐数
#pragma
这个预处理指令,可以改变编译器的默认对齐数。
#include <stdio.h>
#pragma pack(1)//设置默认对齐数为1
struct S
{
char c1;
int i;
char c2;
};
#pragma pack()//取消设置的对齐数,还原为默认
int main()
{
printf("%zd\n", sizeof(struct S));
}
在VS2022 X86环境的测试结果如下
6
struct S的内存分布如下所示
结构体在对齐不合适的时候,我们可以自己更改默认对齐数。
3. 结构体传参
传参一般包括两种,传值与传址,一般推荐传址,因为传址能够对结构体进行修改,而传值,无论是访问还是修改,操作的都是结构体的复制品。且复制一个结构体需要开辟一块新的内存空间,导致内存的浪费。
#include <stdio.h>
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;
}
在VS2022 X86环境的测试结果如下
1000
首选Print2函数,原因如下
1.函数传参的时候,参数需要压栈,会有时间和空间上的系统开销
2.如果传递一个结构体对象的时候,结构体过大,参数压栈的系统开销比较大,所以会导致性能的下降。
结论:
结构体传参的时候,要穿结构体的地址。
#include <stdio.h>
struct S
{
int data[1000];
int num;
};
struct S s = { {1,2,3,4},1000 };
void Change1(struct S s, int i)
{
s.num = i;
}
int main()
{
Change1(s, 3);
printf("%d\n", s.num);
return 0;
}
在VS2022 X86环境的测试结果如下
1000
即传值调用不会改变结构体内部成员的值。
#include <stdio.h>
struct S
{
int data[1000];
int num;
};
struct S s = { {1,2,3,4},1000 };
void Change2(struct S* ps, int i)
{
ps->num = i;
}
int main()
{
Change2(&s, 3);
printf("%d\n", s.num);
return 0;
}
在VS2022 X86环境的测试结果如下
3
即传值调用会改变结构体内部成员的值。
4. 结构体实现位段
4.1 什么是位段
其实内存对齐是牺牲空间换时间,而位段是牺牲时间换空间。
位段中的位指的是二进制的位!!!
位段的声明和结构体类似,有两个不同:
1.位段的成员必须是int, unsigned int或signed int,在C99中位段成员的类型也可以有其它。
2.位段的成员后面有一个冒号和数字。
比如下面代码中A就是一个位段类型,
#include <stdio.h>
struct A
{
int _a : 2;
int _b : 5;
int _c : 10;
int _d:30//不能超过32个,16位机器下int是4个字节,32个bit
};
int main()
{
printf("%zd\n", sizeof(struct A));
return 0;
}
在VS2022 X86环境的测试结果如下
8
4.2 位段的内存分布
1.位段的成员可以是int, unsigned int, signed int或者是char等类型。
2.位段的空间上是按照需要以4个字节(int)或者一个字节(char)的方式开辟的。
3.位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。
#include <stdio.h>
struct S
{
int a : 3;
int b : 4;
int c : 5;
int d : 4;
};
int main()
{
struct S s={0};
printf("%zd\n", sizeof(struct S));
s.a = 10;
s.b = 12;
s.c = 3;
s.d = 4;
return 0;
}
在VS2022 X86环境的测试结果如下
4
我们假设内存以int即4个字节开辟空间(VS2022)。
一方面,给定的空间是从右往左使用还是从左往右使用是不确定的;
另一方面,一个字节即8个比特位在存储了一个成员变量后,剩下的比特位不足以存储下一个成员变量时,剩下的内存是接着用,还是浪费呢?
我们假设,从右向左使用,并且不浪费一个字节的剩余空间。(VS2013是浪费的,现在22不浪费)
那么内存分布大致是下面这样,a只有3个比特位,10的二进制是1010,存不下,从低位开始存
调试结果如下图所示
4.3 位段的跨平台问题
1.int位段被当成有符号数还是无符号数是不确定的。
2.位段中最大的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机器会出问题)。
3.结构中的成员在内存中从左向右分配,还是从右向左分配,标准尚未定义。
4.当一个结构体包含两个位段,第二个位段成员比较大,无法容纳第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的。
关于上面第二点提到16位机器,int大小是2个字节,16个bit
总结:
跟结构相比,位段可以达到相同的效果,并且可以很好的节省空间,但是有跨平台的问题存在。
4.4 位段的应用
下面是网络协议中,IP数据报的格式,我们可以看到其中很多的属性只需要几个bit位就能描述,这里使用位段,能够实现想要的效果,也节省了空间,这样网络传输的数据报大小也会较小一些,对网络的畅通是有帮助的。
4.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;
}
总结
内存对齐,计算结构体的大小是很热门的考点,大家着重理解;结构体在链表的定义中使用,数据结构的内容。也回顾了传值调用与传址调用,有兴趣的小伙伴可以下去敲敲代码~