什么是结构体【详解】

本期介绍🍖

主要介绍:什么是结构体,结构体的声明、定义、初始化、以及传参,匿名结构体类型,如何通过结构体来实现链表数据结构,结构体在内存中是如何存储的(即:结构体内存对齐),什么是内存对齐👀。


文章目录

    • 一、什么是结构体🍖
    • 二、结构体的声明、定义、初始化🍖
    • 三、匿名结构体类型🍖
    • 四、结构体自引用🍖
    • 五、结构体内存对齐🍖
      • 5.1 结构体对齐规则🍖
      • 5.2 为什么存在内存对齐🍖
      • 5.3 修改默认对齐数🍖
      • 5.4 成员在内存中在存储顺序🍖
      • 六、结构体传参🍖

          我们知道C语言本身就存在着一些语法类型,也就是内置类型(譬如:char、short、int、long、float、double)。但在实际应用中仅仅只有这些类型是不够用的,因为我们无法单一的用内置类型来描述一个复杂的对象,就譬如:一个人、一本书等等。所以C语言就给出了除内置类型之外的一种类型:自定义类型,有了这种类型我们就能够自己来构建类型了。而比较常用的自定义类型有:结构体、联合体、枚举。


        一、什么是结构体🍖

          结构体是C语言中一种重要的数据类型,该数据类型由一组称为成员的数据所组成,其中每个成员可以是不同类型的。结构体通常用来表示类型不同但是又相关的若干数据。(注意:数组与结构体一样,也是一种值的集合,只不过数组的每个元素都要是相同类型的,而结构体的每个成员可以是不同类型的


        二、结构体的声明、定义、初始化🍖

          首先,要使用结构体就必须声明(创建) 一个结构体类型,就必须用到结构体关键字:struct。如何声明一个结构体类型,如下所示。其中tag为结构体标签,用来区分不同的结构体类型。struct tag表示结构体类型,声明完结构体后就可以用该类型来创建变量了。member-list;为成员列表,它是结构体所包含的基本的结构类型。variable-list;表示变量列表,这个列表可写可不写,写了就代表你用上面所创建的结构体类型定义了一个该类型的变量,没写则表示你仅仅只创建了一个结构体类型。

        struct tag
        {member-list;
        }variable-list;
        

        注意:

         1.若结构体声明是在mian函数之外的,那么列表后创建的结构体变量是一个全局变量。

         2.结构体成员变量也可以是另一个结构体类型,这种用法被称为:嵌套结构体。

         3.在结构体声明结束的时候,千万不要忘记{}后面是有一个;的。


          声明完结构体类型,就可以用它来定义和初始化结构体变量了。注意:结构体初始化与数组一样需要用{}。举个例子,如下图所示:

        struct grade
        {double math;
        	double english;
        };
        struct student
        {char name[20];//姓名
        	int age;//年龄
        	char sex[5];//性别
        	char id[20];//学号
        	struct grade;//成绩(这是一个嵌套结构体类型)
        };
        int main()
        {//定义、初始化结构体类型
        	struct student ly = {"张三", 23, "男", "2117305789", {98.5, 66.0}};
        	return 0;
        }
        

          不知道大家在用结构体类型定义一个变量时,有没有觉得结构体的类型名太长了。其实可以通过typedef来重定义结构体的类型名,如下所示:

        typedef struct student
        {char name[20];//姓名
        	int age;//年龄
        	char sex[5];//性别
        	char id[20];//学号
        	struct grade;//成绩(这是一个嵌套结构体类型)
        }stu;
        

          注意这里的stu并不是创建的变量,而是对于struct student的重命名。之后我们既可以用stu来创建变量也可以用struct student来创建。


        三、匿名结构体类型🍖

          我们在声明结构的时候,可以不完全的声明,也就是不写结构体标签,所以称之为匿名。如下所示:

        struct
        {int a;
        	char b;
        	double c;
        }x;
        

          这种语法结构C语言是支持的,但不被我们所喜,因为这种声明出来的结构体类型我们只能用其定义一次变量,之后想用都用不了了。为什么呢?难道我们之后要用struct类型来创建变量吗?别扯淡了,struct可是结构体关键字啊!总之,除非你只想用一次你创建的结构体类型,或者不想该结构体类型被别人使用,这时可以用匿名结构体类型。

          还值得注意的一点是,就算两个匿名结构体类型的成员变量完全一样,但在编译器看来它们两个仍然是两个完全不同的类型。如下举例所示:

        struct
        { int a;
        	 char b;
        	 float c;
        }x;
        struct
        { int a;
        	 char b;
        	 float c;
        }a[20], *p;
        int main()
        {p = &x;
        	return 0;
        }
        

        四、结构体自引用🍖

          我们知道在数据在内存中有许多存储的方式,就譬如:顺序表、链表等等。其中顺序表是在内存中连续存放的,而链表是打乱着存放的,其并不连续。如下图所示:

          我们在读取数据的时候顺序表只要知道起始位置,就可以依次找到各个数据。但链表却不行啊,因为它在内存中并不是连续的,而是乱序存放的。问题来了,那链表该怎么往外取数据呢? 其实也不难,是不是只要1可以找到2,2可以找到3,3可以找到4,4可以找到5,不就可以把存在不同内存位置中的数据全部依次的取出来了嘛,数据就如同被一个链条串起来了一样,故称为:链表结构存储。那该怎么实现链表呢?


          想法一: 在结构中包含一个类型为该结构本身的成员

        struct Node
        { int data;
         struct Node next;
        };
        

          其实我们只需要在结构体中包含一个类型为该结构体本身的成员,这样就可以像套娃一样,在一个数据中找到另一个数据,在另一个数据中又找到另另一个数据,以此下去。这样不就可以实现链表结构了嘛!假设这个想法可以吧,那再仔细思考一下,当使用sizeof()来计算该类型的结构体大小时,结果又是多少?

        #includestruct Node
        { int data;
         struct Node next;
        };
        int main()
        {printf("%d\n",sizeof(struct Node));
        }
        

          可见,C语言是不支持结构体中包含一个类型为该结构体本身的这种语法,为什么呢?因为编译器也无法得知该结构体类型的大小呀,每个结构体套一个自身,这种思想不就是函数递归操作(没有限制条件的)嘛,理论上可以套无数个该类型的结构体,你说编译器能计算出它的大小吗?这压根就是一个错误的写法。所以我们无法通过结构体中包含下一个结构体这种写法来实现链表的,我们需要另辟思路了。


          想法二: 在结构中包含一个指向类型为该结构自身的指针

        struct Node
        { int data;
         struct Node* next;
        };
        

          稍加思索,不难得出这个想法其实是可行的。如下图所示,我们只要就把2节点的地址赋给1节点,把3节点的地址赋给2节点,把4节点的地址赋给3节点,把5节点的地址赋给4节点,最后给5节点赋上一个NULL(表示不指向任何位置)。如此不就可以通过一个节点能够找到下一个节点这种方式逐个找遍所有节点了嘛,不就实现链表了嘛。

          而且该想法必不会像先前的想法那样,在计算类型所占内存大小时无法被获知。因为,这样设计的结构体类型的第二个成员变量本质上就是一个指针,而指针无非就是4/8个字节嘛,怎么可能会出现无法计算的情况呢?指针又不套娃!!!


        五、结构体内存对齐🍖

          不知道大家在学习结构体的时候有没有思考过结构体它在内存中到底是如何存放的,结构体多占内存真的是所有成员变量大小的总和吗?会与数组一样随着下标的增长地址由低到高变化吗?大家先来猜一猜,下边两个结构体S1和S2在内存中会占用多少空间:

        #includestruct S1
        {char a;
        	int b;
        	char c;
        };
        struct S2
        {char a;
        	char b;
        	int c;
        };
        int main()
        {printf("%d\n", sizeof(struct S1));
        	printf("%d\n", sizeof(struct S2));
        	return 0;
        }
        

          这两个结构体所含的成员变量个数和类型都一样啊,都是由一个int和两个char类型构成的。那么按照一贯的思维,我们必然会得出这两个结构体理应都占用6个字节的内存空间啊。但事实真的是这样吗?

          答案当然是否定的,从上图就可以看出结果完全与我们所思所想的不同。有人就要问了:不因该啊,为什么会结果会得出大于结构体成员变量大小总和这种情况啊?这样做不就浪费内存空间了嘛! 而且还可以看出S1和S2两个结构体的成员仅仅只是顺序不同,最后得出的结果也是不一样的!那这到底是怎么回事呢?这就不得不好好说一说结构体在内存中的存放方式了(也就是结构体内存对齐)了。


        5.1 结构体对齐规则🍖

          结构体对齐规则:

        1. 第一个成员在相对于结构体变量起始位置偏移量为0的地址处

          (通俗点来说,就是第一个成员变量的地址与结构体起始位置的地址是相同的)如下图所示:

        2. 其他成员变量要对齐到<对齐数>的整数倍处

          (对齐数 = 编译器默认对齐数与该成员变量大小的较小值),vs编译器默认对齐数为8,gcc没有默认对齐数这一说。

        3. 结构体的总大小为最大对齐数(每个成员的都有一个对齐数)的整数倍

        4. 如果是嵌套结构体的情况,嵌套结构体的对齐数就是其自身的最大对齐数。(同理数组的对齐数就是其元素的对齐数)

          在VS编译器中存在一个宏offsetof(type, member),可以用来计算一个成员在结构体类型所创建的变量中的偏移量是多少。其中type是指想要被计算的结构体类型,member是该结构体中的成员。注意:在使用offsetof()前应该先引用一个头文件

        #include#includestruct S1
        {char a;
        	int b;
        	char c;
        };
        int main()
        {printf("%d\n", offsetof(struct S1, a));
        	printf("%d\n", offsetof(struct S1, b));
        	printf("%d\n", offsetof(struct S1, c));
        	return 0;
        }
        

          这与我们之前所述的情况几乎是一摸一样。


        5.2 为什么存在内存对齐🍖

          通过上面的讲解,我们发现结构体在内存中存储时,会故意浪费掉一些空间,来使得结构体成员在某些边界处对齐。那为什么要这么做呢?原因有两个:

        1. 平台原因(移植原因):

          不是所有的硬件平台都能够访问任意地址处的任意数据的,某些硬件平台只能在某些地址处取特定类型的数据,否则就会抛出硬件异常。

        2. 性能原因:

          数据结构(尤其是栈)因该尽可能的在自然边界上对齐。原因在于,为了访问未对齐的数据,处理器需要做两次内存访问,而对齐的内存只需要一次访问就够了。

          总体上来说,结构体的内存对齐就是一种用空间换取时间的做法,浪费空间来换取性能上的提升。那在设计结构体的时候,我们既要满足对齐,又要节省空间,如何做到:让占用空间小的成员尽量集中到一起,就可以降低一些空间的浪费了。举个例子:


        5.3 修改默认对齐数🍖

          其实VS编译器中的默认对齐数(也就是8)是可以进行编译修改的,通过#pragma预处理指令来进行修改。举个例子:

        #include#pragma pack(1)//设置默认对齐数为1
        struct S1
        {char a;
        	int b;
        	char c;
        };
        int main()
        {printf("%d\n", sizeof(struct S1));
        	return 0;
        }
        #pragma pack()//取消设置的默认对齐数,还原为默认
        

          结构在对齐方式不合适的时候,我么可以自己更改默认对齐数。


        5.4 成员在内存中在存储顺序🍖

          由下图可知,结构体成员变量在内存中存放的顺序是这样的:


        六、结构体传参🍖

          结构体与普通内置类型一样也有两种传参方式:1. 传值,2. 传址。那我们到底该选择哪一种传参方式更好呢?其实吧,选择传递结构体变量的地址更好,为什么呢?如果是传值,函数在传参的时候参数是需要压栈的,若此时结构体过大,参数压栈时系统的开销也就较大,会导致性能的下降。可传址就不同了,不管你结构体有多大我传递的地址永远是4/8个字节,开销远比传值小的多。而且传值和传址都能够实现对结构体变量的调用,故首选传址调用。下面是两个不同的传参方法:

        struct S
        { int data[1000];
         int num;
        };
        //结构体传参
        void print1(struct S s)
        { printf("%d\n", s.num);
        }
        //结构体地址传参
        void print2(struct S* ps)
        { printf("%d\n", ps->num);
        }
        int main()
        { struct S s = {{1,2,3,4}, 1000};
        	 print1(s);  //传结构体
        	 print2(&s); //传地址
        	 return 0;
        }
        

          注意:如果是结构体变量地址,我们需要用->来访问结构体的成员;而如果是结构体变量名,我们需要用.来访问结构体成员。

          注意:相同类型的结构体变量,我们可以直接通过=来进行赋值操作,不需要像数组那样访问到每一个元素然后才能进行赋值,因为结构体是一个类型啊。


        这份博客👍如果对你有帮助,给博主一个免费的点赞以示鼓励欢迎各位🔎点赞👍评论收藏⭐️,谢谢!!!

        如果有什么疑问或不同的见解,欢迎评论区留言欧👀。