结构体
目录:
1、结构体类型声明
2、结构体变量的创建和初始化
3、结构体成员访问操作符
4、结构体内存对齐*****(重要指数五颗星)
5、结构体传参
6、结构体实现位段
一、结构体类型声明
其实在指针中我们已经讲解了一些结构体内容了,让我们回顾一下结构体是什么吧~
结构体:是一些值的集合,这些值称为成员变量,结构体的成员变量可以是不同类型的
1、数组与结构体:
数组:相同数据类型值的集合
结构体:不同数据类型的值的集合
struct tag
{member-list;//成员列表
}variable-list;//变量列表
- 结构体可以更好的描述现实世界的复杂实体:现实世界中的实物都是具有属性和行为的,这里的属性就是不同数据类型的成员来描述的,也就是拿什么东西区描述它,而行为就是我们学习面向对象编程的类里的方法(没学过的可以暂时理解为函数),可以实现各种功能,也就是能拿他来做什么,对应不同的行为。
假设有一本书:它的属性是书名,作者,编号,价格,这些就是我们可以用结构体的成员列表来描述而他的行为,比如可以卖出去营利多少,这个就可以写个函数算一下。
//结构体类型:实体书具有的属性集合
struct Book
{ //成员列表char name[20];//书名char author[10];//作者char id[19];//编号float price;//价格
}b1,b2;//变量列表:这样创建是编译器依据数据类型结构体类型创建出来的全局变量
int main()
{
//直接在创建变量的同时对其进行初始化
//位置初始化:必须按照位置来初始化
struct Book b3 = {"鹏哥C语言","鹏哥","GF1234567",38.8f};
//关键字初始化:由你自己指定参数来初始化值,但是必须注意要访问到成员变量才可以进行赋值
struct Book b4 = {.author = "蛋哥",.id = "GF8765432",.name = "蛋哥linux",.price = 59.4f};
//打印结构体内容:通过.和->访问结构体成员
printf("%s %s %s %f\n", b3.author, b3.id, b3.name, b4.price);
p = &b4;
//结构体变量名.结构体成员名 , 结构体指针->结构体成员名
printf("%s %s %s %f", p->author, p->id, p->name, p->price);
return 0;
}
- 结构体类型其实就是一个数据类型的集合,把各种数据类型封装起来用于描述更为复杂的现实世界的实体
- 那我们要创建出实体书才可以用结构体类型来描述他的属性呀,这个变量列表就是我们依据这些属性创建出的实体书,也可以在主函数内部创建并初始化实体书(结构体类型的实体)
打印出的结果:
- 为什么这里的38.8并不是真正的38.8呢?
小数在内存(二进制)中是不一定能够精确存储的,
1、比如10.5 -->1010.1(后面的小数位正好是2**-1也就是0.5)刚好保存
10.8: 就是1010.1(是0.5),1010.11(是0.5+0.25),1010.111(是0.5+0.25+0.125)超纲了所以第三位小数要补0,那么接下来就又要用后面的数凑这个0.8,如果凑不齐就要已知凑下去
2、由于内存小数的精度是有限的,float小数最多32bit位,double最多是52个比特位,有没有可能有一个数字,你就一直往后面凑数就是凑不够,那是不是就是丢失精度了
- 所以我们在比较浮点数的大小的时候实际上是需要接受他们之间有一定的误差值的,误差在多少范围内我认为他们相等,计算他们之间的浮点数差值绝对值,如果小于某个值就认为他们相等
int main()
{ if (fabs(38.8 - 38.8f) < 0.000001){printf("相等");}else{printf("不相等");}return 0;
}
int main()
{float f = 38.8;if (fabs(38.8 - f) < 0.000001){printf("相等");}else{printf("不相等");}return 0;
}
打印字符串的时候直接使用printf(“字符串内容”)就可以,因为实际上是将字符串的首地址传递给printf了,找到地址就可以直接将内容打印了
1、匿名结构体
1、在创建结构体的时候,可以不定义结构体的名字,但是这种结构体创建变量的时候只能够创建全局变量,如果没有对结构体重命名,基本只能使用一次
2、对于这样的结构体,即使内容一样,编译器一样会把他们当成不同的类型,因为没有名字
struct
{int a;char b;float c;
}x;
struct
{int a;char b;float c;
}a[20], *p;
*p = &x//不合法的:因为类型不一样
即使成员变量一样,但是没名字,编译器认为是两种不同的类型
二、结构体的自引用
结构体内包含有同类型的指针,记录着下一个结点的位置,以方便找到一下个数据
1、数据结构
数据结构其实就是数据在内存中的组织结构
线性数据结构
- 1、顺序表:底层是数组,数据在内存中是连续存储的,只是在数组的基础上增加了一些功能,比如增删查改
- 2、链表:数据在内存中不连续存储,一个节点中同时存放着数据域和指针域,一个结点有能力找到与它自身同类型的下一个结点,是通过下一个结构体的地址(指针域)来找到的
- 注意:这里有可能有的朋友们认为结构体的自引用难道不是直接在结构体的成员变量中引用自己同类型的下一个结构吗?
- 那么请接收灵魂拷问:这种结构体的大小是多少?
- 自己里面有同时包含数据域还有一个自己,这就很矛盾了呀
- 那如果是结构体中同时包含数据域和指针域,这个结构体的大小就是可算的,数据域在内存中所占的字节数是可算的,指针的大小x86环境32bit是4字节,x64环境64bit是8字节
2、结构体的重命名
如果我们觉得结构体类型名字实在是太长了,想简写,就可以使用typedef来重命名,但是请注意顺序问题。
typedef struct Node
{int a;//数据域struct Node* next;//指针域
}Node;
将结构体类型由struct Node重定义为Node
1)如果这样使用结构体自引用看似合理却隐含顺序错误:
typedef struct Node
{int a;Node* next;
}Node;
我们是先声明了结构体struct Node,然后再去对其进行重命名的,而不是在创建的时候就可以直接用重命名后的结构体名称了
四、结构体的内存对齐(笔试常考题,重要程度五颗星)
引言:请朋友们看这里两段代码,试着运行一下:
#pragma pack(8) //默认对齐数设置为8,编译器本身就是8,不知道为什么我的编译器没有内存对齐现象,大家可以试试如果输出结果是6 6 ,那么就需要加上这句话,如果是12 8,就不需要加上
struct S1
{char c1;int a;char c2;
}s1;
struct S2
{char c1;char c2;int a;
}s2;
int main()
{printf("%d\n", sizeof(s1));//printf("%d\n",sizeof(struct S1));printf("%d\n", sizeof(s2));//printf("%d\n",sizeof(struct S2));return 0;
}
- 从运行结果可以看出,编译器并不是按照结构体成员变量所占内存的字节数来分配内存空间的,因为结果并不是6
- 那么编译器到底是如何将结构体变量在内存中分配空间的呢?(其实就是给结构体类型分配空间,结构体内部是成员变量,也就是给结构体类型的各个成员变量分配空间)
1、内存对齐
这个规则就叫做结构体内存对齐:
- 结构体的第⼀个成员对⻬到和结构体变量起始位置偏移量为0的地址处
- 其他成员变量要对⻬到某个数字(对⻬数)的整数倍的地址处。
对⻬数 = 编译器默认的⼀个对⻬数 与 该成员变量⼤⼩的较⼩值。
- VS 中默认的值为 8
- Linux中 gcc 没有默认对⻬数,对⻬数就是成员⾃⾝的⼤⼩
- 结构体总⼤⼩为最⼤对⻬数(结构体中每个成员变量都有⼀个对⻬数,所有对⻬数中最⼤的)的整数倍。
- 如果嵌套了结构体的情况,嵌套的结构体成员对⻬到⾃⼰的成员中最⼤对⻬数的整数倍处,结构体的整体⼤⼩就是所有最⼤对⻬数(含嵌套结构体中成员的对⻬数)的整数倍。
- 偏移量:从结构体变量在内存中开辟的空间的起始位置开始由0开始计数,第一个位置的偏移量是0,第二个位置偏移量为2…一直按照这个规则下去就形成了一段具标有偏移量的内存空间,如图所示:
- 而结构体第一个成员对齐到偏移量为0的位置,也就是把c1放置在标号为0的位置
- 编译器默认对齐数:编译器自己决定的一个性质数,vs中默认是8,linux的gcc编译器就默认是成员本身,并不存在对齐数,所以就是按照字节数来分配空间,最终的对齐数,等于编译器默认对齐数和成员变量大小中的较小值,所以从第二个成员变量开始之后,每一个成员变量都是找对齐数,并且对齐到对齐数的整数倍的偏移量上,所以char c2就是对其到偏移量为1那,int n 自身大小是4,默认对齐数是8,最终对齐数是4,所以对齐到4的整数倍上就是偏移量是4那里开始
- 最终将空间分配成这个样子:那么结构体的大小就是8字节啦占(8个内存空间,每个内存空间占1字节)
* - 这段代码的空间分配是这样的,这里up就不算啦,大家可以按照刚刚讲的方法试试呢~
- 第四条规则:关于嵌套结构体大小问题
- 如果嵌套了结构体的情况,嵌套的结构体成员对⻬到⾃⼰的成员中最⼤对⻬数的整数倍处,这个外部的结构体的整体⼤⼩就是所有最⼤对⻬数(含嵌套结构体中成员的对⻬数)的整数倍。
int main()
{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));return 0;
}
- 这里的struct S3大小是16字节,可以自己练习练习奥~
- d占8字节,从偏移量为0开始对其占8字节,到编号为7的位置停止
- c的大小1,默认对齐数8,最终对齐数是1,所以编号为8的偏移量是1的倍数,所以放在8位置
- i的大小是4,默认对齐数8,最终对齐数是4,所以对齐到4的倍数处,9并不是4的倍数,所以不能再9的位置进行对齐,而12是4的倍数,所以从偏移量为12的位置开始对齐占4字节,所以到编号为15的位置,目前大小是16(从0开始占空间数)
- 最终结构体的大小:所有的成员变量的最大对齐数是8,所以16是8的倍数,最终大小是16
- c1是对齐到结构体起始位置偏移量为0的位置,
- 结构体S3对齐到他的成员变量中最大对齐数那,最大对齐数是8,所以最终从偏移量为8的位置开始对齐,+16,对齐到偏移量为23的位置
- d占8字节,对齐数8,所以从24开始对齐+8一直到编号为31的内存空间那,目前大小是32
- 最终大小是所有成员变量最大对齐数的最大值的倍数,32是8的倍数
2、介绍一个宏:offsetof
可以计算结构体成员或者联合体相较于结构体变量起始位置或者是联合体变量起始位置的偏移量,返回的是一个unsigned int也就是一个无符号整形,使用%zd打印
- offsetof(结构体类型,结构体成员变量)
和我们自己推测的在内存中的偏移量的起始位置是一致的:
五、为什么存在内存对齐呢?
⼤部分的参考资料都是这样说的:
- 平台原因 (移植原因):
不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
2.性能原因:(后面有详细解释):
- 数据结构(尤其是栈)应该尽可能地在⾃然边界上对⻬。
- 原因在于,为了访问未对⻬的内存,处理器需要作两次内存访问;⽽对⻬的内存访问仅需要⼀次访问。假设⼀个处理器总是从内存中取8个字节,则地址必须是8的倍数。
- 如果我们能保证将所有的double类型的数据的地址都对⻬成8的倍数,那么就可以⽤⼀个内存操作来读或者写值了。否则,我们可能需要执⾏两次内存访问,因为对象可能被分放在两个8字节内存块中。
- 总体来说:结构体的内存对⻬是拿空间来换取时间的做法。
解释:如果是一个32位机器,那么就有32根地址总线,每一根线都能读操作或写操作一个比特的地址,(读操作,写操作,以及数据是如何由内存传入cpu进行处理的,相关知识点请看深入指针1哈)
1、那么就是一次操作4字节,如果不进行内存对齐就会导致你需要两次访问才能把n这个整形类型的变量访问完全
2、如果对齐的,你只需要找到从4这个位置访问4字节,一次就能完全访问到n,但是相应的,它也会浪费一定的空间,相当于用空间换时间
1、如何更好的布局结构体呢?
那在设计结构体的时候,我们既要满⾜对⻬,⼜要节省空间,让占用空间小的成员尽量的集中在一起,这样可以很有效的利用本应该被浪费掉的空间。
- 将c1和c2集中在一起,明显结构体占用空间小了
- 注意:这里并不是说将小的成员变量都放在结构体的前边的意思,只是说把小的成员都集中在一起!,位置倒是无所谓,可以自己试试n,c1 , c2这个顺序,你会发现大小还是8
2、修改默认对齐数
#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;
发现是6了,因为我们将对齐数修改为1了,就是紧挨着存放的意思,因为内存编号上的所有的值都是1的倍数嘛。
- 在函数内部的执行顺序是这样的:首先主函数,找到结构体s,从前往后编译,默认对齐数被修改为1,计算出了大小是6,接着恢复了默认对齐数为8。
- 但是请注意,一般我们的默认对齐数都设置成2的次方数,1,2,4,8,16…等等,而不会设置为3,5,7等等。
3、结构体传参
1、传值调用
2、传址调用
struct S {int a[10];float age;
};
void print1(struct S s1)
{int i = 0;for (i = 0; i < 10; i++){printf("%d ", s1.a[i]);}printf("\n");printf("%f", s1.age);
}
void print2(struct S* p)
{int i = 0;for (i = 0; i < 10; i++){printf("%d ", p->a[i]);}printf("\n");printf("%f", p->age);
}
int main()
{struct S s1 = { {1,2,3,4,5,6,7,8,9,10},20.0};print1(s1);//传值调用printf("\n");print2(&s1);//传址调用return 0;
}
- 传值调用:直接传递结构体名字,形参同样使用结构体类型变量接收,形参是对实参的一份临时拷贝,会在内存中开辟一份临时的与实参等大的空间,在将实参中的数据拷贝出来进行函数处理,访问通过点操作符,但是涉及到修改的地方是没办法在主函数中的原变量上体现的
- 传址调用:&结构体名,传递结构地址,实参使用结构体类型指针接收,这个指针又有能力找到结构体,并且通过->能够访问结构体成员,并对其进行实质性的修改,可以实质性的对主函数中的相关变量修改
- 所以其实传值调用能做的,传址调用都可以做做到但是传值调用未必能做传址调用能做到的(比如在函数内部修改主函数中的值)
- ⾸选print2函数。
原因:
函数传参的时候,参数是需要压栈,会有时间和空间上的系统开销。
如果传递⼀个结构体对象的时候,结构体过⼤,参数压栈的的系统开销⽐较⼤,所以会导致性能的下降。
结论:
结构体传参的时候,要传结构体的地址。
六、结构体实现位段
位段的声明和结构是类似的,有两个不同:
- 位段的成员必须是 int、unsigned int 或signed int或者char(本质上也是整形) ,在C99中位段成员的类型也可以选择其他类型。
- 位段的成员名后边有⼀个冒号和⼀个数字。位:指的是二进制位(bit),需要多大bit位空间就写多少
- 如果是char类型一次给1个字节操作,如果是int类型一次给4字节操作
struct S//结构体本来应该是16字节大小
{int _a;int _b;int _c;int _d;
}
struct A//实现位段之后就是8字节
{int _a:2;int _b:5;int _c:10;int _d:30;
};
int main()
{printf("%zd", sizeof(struct A));return 0;
}
- 结构体中的每一个成员是int,本质上应该开辟4字节=32比特,但是这些bit位我并需要都用上,只需要用到一些,需要多少的比特位就在冒号后面写多少就可以了
- 那么我们把位段中的位加在一起:47,那不应该是6字节嘛,哈哈别太过分啦,编译器能压缩到8字节已经很不错了,肯定会有一些空间浪费掉了,那么接下来跟我一起看看8字节是如何得来的吧~
1、结构体位段在内存中的分配
让我们看看这段代码:
struct A
{char _a : 3;char _b : 4;char _c : 5;char _d : 4;
};
//A就是一个位段
int main()
{printf("%zd", sizeof(struct A));return 0;
}
-
是这样的编译器一次性是可以给开辟好空间的,这里我为了方便就一个字节一个字节拿出来给大家演示
-
-
位段是有很多不确定性因素存在的:
-
1、取出一个字节之后数据是由左向右还是由右向左,进行放入,是不确定的,取决于编译器,假设:从右向左
-
2、如果一个字节剩余的比特位不够放下一个位段成员,是继续把剩余的比特位展占用,还是直接开辟一个新的字节空间进行使用是不确定的,假设浪费
-
运行结果是3,果然vs编译器是这样默认的
那么对其进行赋值是不是我们所说的从右向左赋值呢,并且不够的位浪费掉呢,我们来验证一下
struct A
{char _a : 3;char _b : 4;char _c : 5;char _d : 4;
}a1;
int main()
{a1._a = 10;a1._b = 12;a1._c = 3;a1._d = 4;return 0;
}
转换为二进制位是(因为内存空间是一字节也就是8bit,所以放入数据要转换为二进制放入,1个二进制位就是1个bit位):
- 10:1010
- 12:1100
- 3:11
- 4:100
推测一波是这样放的:
- 由于a只开辟了3bit,所以存放的时候去掉最高位,注意这里都是这样的把超出的位高位去掉,留下符合数目的低位,那么这里留下剩余的三位进行存储,从右向左,剩余的空间补齐0,其余的以此类推
- 最后由于在内存中存放的值是以十六进制查看的,所以我们将每4个bit位化为一组得到一个16进制数
那我们查看一下内存空间中的值:
2、如何打开内存
按住F11,点击调试–窗口–内存–窗口几都行,在输入&a1,就是找到a1在内存中的位置,好继续观察它在内存中存储的值
继续按F11,是进行一步一步调试用的,也就是程序一行代码一行代码执行,每次执行一行涉及到变量变化的,变量的值(监视窗口:打开方式一样只是打开的是监视)和内存中的值(内存窗口)都会有所变化。
- 注意:这里采用了4列显示,大家右上角也有个列,可以自行调整,每一列都是1个字节(2个16进制的数,1个16进制数是4bit,2个16进制数正好是8bit,也就是1个字节),这里我们推测是用了3字节我就拿出了4个字节的空间查看效果,如果是整形的话4字节,大家也可以设为4列来查看整形的值,char的话1字节,大家可以1列查看,当然4列也是可以查看到效果的。
- 大家在调试的过程中,执行完a1_a这句代码,会显示02 00 00 00,这是a的值被放入内存空间了,继续按住F11,执行完a1_b发现,62 00 00 00,这是b的值被放入了…,所以大家看到了确确实实是在一个字节中从右向左赋值的
3、位段成员没有地址
1、位段的⼏个成员共有同⼀个字节,这样有些成员的起始位置并不是某个字节的起始位置,那么这些位置处是没有地址的。内存中每个字节分配⼀个地址,⼀个字节内部的bit位是没有地址的。
2、所以不能对位段的成员使⽤&操作符,这样就不能使⽤scanf直接给位段的成员输⼊值,只能是先输⼊放在⼀个变量中,然后赋值给位段的成员。
struct A
{char _a : 3;char _b : 4;char _c : 5;char _d : 4;
}a1;
//A就是一个位段
int main()
{int a = 10;scanf("%d",&a);a1._a = a;//正常scanf("%d", &a1._b);//报错return 0;
}
4、位段的不跨平台性
因为具有这些不跨平台性,所以要跨屏平台时,别使用位段
它既具有了在能实现结构体功能的基础上再减少空间,又带来了不跨平台的风险
- int 位段被当成有符号数还是⽆符号数是不确定的。
在位段中,如果位段成员是int类型就一次开辟4个字节进行操作,那么这四个字节的第一个bit位是0还是1是不确定的
- 位段中最⼤位的数⽬不能确定。(16位机器最⼤16,32位机器最⼤32,写成27,在16位机器会出问题。
在早期的16位机器中,int的大小是2字节,所以位段成员如果是int类型就必须不能超过16个bit,那么
int _d:30;
这个代码在16位机器上就是报错的,在32位机器上就是正常的
- 位段中的成员在内存中从左向右分配,还是从右向左分配,标准尚未定义。
- 当⼀个结构包含两个位段,第⼆个位段成员⽐较⼤,⽆法容纳于第⼀个位段剩余的位时,是舍弃剩余的位还是利⽤,这是不确定的。
5、位段的应用
下图是⽹络协议中,IP数据报的格式,我们可以看到其中很多的属性只需要⼏个bit位就能描述,这⾥使⽤位段,能够实现想要的效果,也节省了空间,这样⽹络传输的数据报⼤⼩也会较⼩⼀些,对⽹络的畅通是有帮助的。网路好比高速公路,如果都是一些小的数据,那么传输效率高,如果封装的很大,数据也多就会造成拥堵。
简易的讲解一下意思:计算机pc端1的用户发送了一条呵呵的短信,他怎么能够保证发送到你室友那,而不是发送给你的女朋友呢
计算机网络体系结构有许多层,每一层都有相应的功能,保证着你的数据正确发送,而每一层都有自己的数据格式,那么每一层就需要将要发送的数据进行加工(封装)上自己这一层的功能与格式之后进行相应处理,在继续传送数据,而到了网络层如果使用IP协议(协议就是使得计算机数据能够按照规定进行传输的约定),我们给它就数据封装上了数据上面的这些东西
类比一下你想发一个易碎的杯子快递,就需要层层给快递封装上泡沫,胶布,等等,还需要填写上相应的信息作为格式,如果你只是发一个很小的,那么我拿一个很大的箱子给你封装上就是浪费了,所以需要多少我就封装多大就可以了,那么位段同样也是这个道理,这回再去看开头的就比较好理解了