在这里插入图片描述

一、结构体类型的声明和自引用

   前⾯我们在学习操作符的时候,已经学习了结构体的基本知识,这⾥稍微复习⼀下结构体类型的声明,然后学习一些新的东西

1.结构体类型的普通声明

   我们来看看结构体声明时的格式:

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

   其中,tag就是这个结构体的名称,member-list就是成员列表,包含了对结构体进行描述的变量,variable-list是创建结构体时需要同时创建的结构体变量
   比如我们举一个例子,我们想要描述一个学生,不能用单个的变量就对学生描述完整,所以我们就要使用结构体,在结构体中,我们可以包含学生的姓名、年龄、性别、学号等等多种信息,完整的描述一个学生
   这里我们就举一个简单的例子,在创建学生这个结构体时,同时创建一个结构体变量,如下:

struct stu
{
	char name[20];
	int age;
	char sex[5];
	char id[20];
}s1;

2.结构体的特殊声明

   在声明结构的时候,可以不完全的声明,可以不写结构体的名字,但是只能用一次,这就是结构体的特殊声明,也叫匿名结构体
   这种情况下,只能在创建结构体时创建好一次性的结构体变量,也就是只能创建全局变量,不能在main函数中创建局部变量,这里我们来创建一个试试:

struct 
{
	char name[20];
	int age;
	char sex[5];
	char id[20];
}x;

在上面的结构体中,结构体变量x就只能使用一次

3.结构体的自引用

   在结构中包含⼀个类型为该结构本⾝的成员是否可以呢?
比如,定义一个链表节点:

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

   这个代码中,data是节点的数据,而struct Node next就包含了下一个节点
   那么上述代码正确吗?如果正确,那么这个结构体的大小是多上?也就是sizeof(struct Node) 是多少?
   仔细分析,其实是不⾏的,因为⼀个结构体中再包含⼀个同类型的结构体变量,这样结构体变量的大小就会无穷的大,是不合理的
   正确的自引用方式是把下一个节点的地址存储起来,一个地址的大小是确定的,不是4个字节就是8个字节,不会存在结构体无限大的情况,如下:

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

二、结构体变量的创建和初始化

1.结构体变量的创建

   刚刚我们讲到了结构体类型的声明,那么创建好一个结构体后,我们怎么创建一个结构体变量呢?
(1)方法就是在创建结构体时,直接在variable-list中创建

(2)我们首先要知道结构体变量的类型是什么,就是struct再加上结构体的名字,然后我们将其当作一个类型使用来创建变量即可
   比如我们刚刚在创建结构体时创建了一个结构体变量s1,现在我们不通过这种方式创建结构体变量,我们就采用普通方式该怎么做呢?如下:

int main()
{
	struct stu s1;
	return 0;
}

   这里我们就是把struct stu当作结构体变量的类型来创建变量,那么这种方式创建结构体变量,和在声明结构体时创建变量有什么不同呢?
   我们可以根据它们的位置来看,一个创建在main函数外,所以创建出来的结构体变量是全局变量,一个创建在main函数外,所以创建出来的结构体变量是局部变量

2.结构体变量的初始化

(1)按结构体成员的顺序初始化
   当我们在按照顺序来初始化结构变量时,可以直接使用一个大括号,按照顺序给对应的成员赋值,比如现在我们要初始化s1
   s1包含的信息有,姓名:zhangsan,年龄:18,性别:男,学号:24101100514,我们来初始化试试:

//注意字符串该有的双引号不要忘记
struct stu s1 = { "zhangsan",18,"male","24101100514" };

(2)按照指定的结构体成员顺序初始化
   这个时候我们就要使用在操作符详解2学到的结构成员访问符,也就是一个点号(.),如果忘记了,可以点进链接复习一下
   接下来我们就使用结构体成员访问符也对s1进行相同的初始化,如下:

struct stu s1 = { .age = 18, .name = "zhangsan",
				.id = "24101100514", .sex = "male" };

三、结构体内存对齐

   经过简单的复习,我们了解了结构体的基本用法,接下来我们开始讲结构体的新知识点,就是去计算结构体的大小,这也是一个热门的考点,因为这涉及到了内存对齐

1.内存对齐规则:

⾸先得掌握结构体的对⻬规则:

  1. 结构体的第⼀个成员对⻬到和结构体变量起始位置偏移量为0的地址处
  2. 其他成员变量要对⻬到某个数字(对⻬数)的整数倍的地址处,对⻬数 = 编译器默认的⼀个对⻬数 与 该成员变量大小的较小值。VS 中默认对齐数的值为8。Linux中 gcc 没有默认对⻬数,对齐数就是成员自身的大小
  3. 结构体总大小为最大对齐数(结构体中每个成员变量都有⼀个对齐数,所有对齐数中最大的)的整数倍
  4. 如果嵌套了结构体的情况,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构体的整体大小就是所有最⼤对齐数(含嵌套结构体中成员的对齐数)的整数倍

是不是感觉一点都看不懂,没关系,我们来结合例子一条一条开始解析,不要害怕它,到后面理解了就会觉得很简单

练习

练习1

struct S1
{
	char c1;
	int i;
	char c2;
};

int main()
{
	printf("%zd\n",sizeof(struct S1));
	return 0;
};

   首先我们来看第一条规则:结构体的第⼀个成员对⻬到和结构体变量起始位置偏移量为0的地址处
   这是什么意思呢?其实意思就是这个结构体的第一个成员存放在结构体变量的最开始的第一个字节,不会存放在后面的任何位置
   其中偏移量就是该空间和结构体变量最开始的第一个字节的距离,我们画个图来理解一下:
在这里插入图片描述
   然后就把结构体变量的第一个成员从0偏移量位置开始放置,在这个例子中我们就把c1放进去,如下:
在这里插入图片描述
   第一个成员我们就成功放进去了
   接下来我们来看第二个成员,是一个整型变量,需要涉及到第2条规则了,我们首先知道什么是对齐数,对齐数就是我们存储成员时,这个成员存储时,它的位置必须在对齐数整数倍的位置
   是不是有一点绕,等一下我们画图解释就要好多了,现在我们在这之前还要学会计算对齐数是多少,我们知道VS默认对齐数为8,而整型的大小为4个字节,成员的对齐数就取其中较小的那个,所以整型成员的对齐数是4
   现在我们就可以拿一个整型成员来举一个例子,它可以存储在哪些位置呢?如下:
在这里插入图片描述
   我们来看看这些箭头指向的偏移量有什么特点,分别是0,4,8这三个偏移量的位置,可以看出它们的特点:都是对齐数4的倍数
   这就是第2条的含义,算出成员的对齐数,也就是看这个成员的大小和VS默认的对齐数8,看哪个更小,取小的作为这个成员的对齐数,然后将该成员放到对齐数整数倍的位置
   所以在存储我们的第二个成员整型的i的时候,就必须找到对齐数4的倍数的位置,而现在0的位置被第一个变量c1占据了,所以只能去4这个位置存放i,如图:
在这里插入图片描述
   这就是第二个成员i的存放位置,接下来我们来看第三个成员怎么存放
   第三个成员c2也是字符类型,字符类型的大小为1个字节,比VS默认对齐数小,所以字符型的对齐数就是1,那么所有整数都是1的倍数呀,我们就可以直接将c2放在整型i后面,如下:
在这里插入图片描述
   最后我们来计算这个结构体的大小,我们来数数从最开始到结束占据了多少个字节,注意,中间没有存放数据的空间相当于浪费了,虽然没有存放东西,但是也要算进结构体的大小
   我们数出来应该是9个字节,那么答案到底是不是9个字节呢?我们来运行代码试一试
在这里插入图片描述
   运行结果居然不是9,这是为什么呢?这就要涉及到我们的第3条规则:结构体总大小为最大对齐数(结构体中每个成员变量都有⼀个对齐数,所有对齐数中最大的)的整数倍
   我们这个结构体中最大的对齐数是4,所以整个结构体的大小应该是4的倍数,而离9最近,并且是4的倍数的数是12,所以结构体的大小为12个字节
但是我们也发现一个问题,结构体这样来存储,是否浪费了太多不必要的空间,如图:
在这里插入图片描述
   在这个结构体中一共12个字节,我们用到的一共也只有6个字节,浪费了一半的空间,那为什么结构体还要这样存储数据呢?我们后面会讲到,也会讲到相应的解决办法

练习2

#include <stdio.h>

struct S2
{
	char c1;
	char c2;
	int i;
};

int main()
{
	printf("%zd\n", sizeof(struct S2));
	return 0;
}

   在上一个练习中,我们已经讲完了前三个规则,而这个练习也只会用到这些规则,在练习4中才会加入我们的第4个规则,所以现在赶紧开始思考吧!记得思考完再看下面的解析
   这个练习的结构体成员和上一个练习的一模一样,只是位置不同,我们来看看它们存储时不同的
   首先第一个成员c1还是老样子,直接放进去,如图:
在这里插入图片描述
   接下来看第二个成员,是char类型的c2,大小为1,比VS默认对齐数小,所以它的对齐数是1,任何整数都是1的倍数,所以可以直接挨着c1存放c2,如图:
在这里插入图片描述
   最后就是一个整型成员i,它的对齐数为4,所以必须对齐4的倍数的偏移量,如图:
在这里插入图片描述
   可以看到我们现在已经把三个成员分别记录下来了,总大小是8个字节,而我们的最大对齐数是4,而8是4的倍数,所以结构体的总大小就是8个字节
   我们来看看代码运行结果:
在这里插入图片描述
   我们可以很明显的感觉到,同样的结构体成员,只是不同顺序,就会让结构体的大小有很大的变化,所以想要节省空间,我们就可以采用将小的成员放在前面,把大的成员放后面的方法

练习3

#include <stdio.h>

struct S3
{
	double d;
	char c;
	int i;
};

int main()
{
	printf("%zd\n", sizeof(struct S3));
	return 0;
}

   首先第一个成员的类型是double,它的大小是8个字节,和VS默认对齐数一样大,所以它的对齐数就是8,但是由于它是第一个成员,最开始的偏移量为0,也是8的倍数,所以它可以直接从最开始存放,如图:
在这里插入图片描述
   第二个成员是char类型,这里不多赘述了,直接把c放在d的后面,如图:
在这里插入图片描述
   接着就是最后一个成员i,它是整型,对齐数是4,所以要对齐到4的倍数上去,这里已经只剩从9开始的位置了,所以不能放下i
   要从偏移量为12的位置来存放i,如图:
在这里插入图片描述
   最后我们数下来结构体的大小是16个字节,那么结构体的大小是否就是16个字节呢?我们还需要进行最后一步,就是看16是否是最大对齐数的倍数
   最大对齐数我们刚刚讲到了,是8,很明16是它的倍数,所以这个结构体的大小就是16个字节
   我们来看看运行结果:
在这里插入图片描述

练习4

#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;
}

   这个练习比较长,它涉及到了结构体嵌套的问题,也就是第4个规则,我们边讲边就解释了第4个规则
   我们首先来看结构体S4的第一个成员,是char类型,这里我就直接给出答案,如图:
在这里插入图片描述
   接下来就是嵌套的结构体3,也就是练习3中的那个结构体,要想知道它要放在哪里就要来看我们的第4条规则:
   如果嵌套了结构体的情况,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构体的整体大小就是所有最⼤对齐数(含嵌套结构体中成员的对齐数)的整数倍
   根据第4条规则的描述,我们需要找到嵌套的结构体的最大对齐数,我们在练习3已经讲过了,S3的最大对齐数是8,S3的整体大小是16个字节,所以要从8的倍数位置放下16个字节,如图:
在这里插入图片描述
   最后一个成员是double类型的d,对齐数为8,而刚刚我们从偏移量为8的位置到了偏移量为23的位置,下一个24刚好就是8的倍数,所以可以直接放进去,如图:
在这里插入图片描述
   目前算出来结构体S4的大小为32个字节,当然我们要看这个结构体的最大对齐数,就是8,而32是8的倍数,所以S4的大小就是32个字节
   我们来看看运行结果:
在这里插入图片描述

2.为什么有内存对齐

平台原因(移植原因)

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

性能原因

   数据结构(尤其是栈)应该尽可能地在⾃然边界上对⻬。原因在于,为了访问未对⻬的内存,处理器需要作两次内存访问;⽽对⻬的内存访问仅需要⼀次访问
   假设⼀个处理器总是从内存中取8个字节,则地址必须是8的倍数。如果我们能保证将所有的double类型的数据的地址都对⻬成8的倍数,那么就可以⽤⼀个内存操作来读或者写值了
   否则,我们可能需要执⾏两次内存访问,因为对象可能被分放在两个8字节内存块中,总体来说:结构体的内存对⻬是拿空间来换取时间的做法

优化做法

   那在设计结构体的时候,我们既要满足对齐,又要节省空间,如何做到呢?在前面举例我们也讲到过,我们来重新看一下S1和S2:

struct S1
{
 char c1;
 int i;
 char c2;
};
struct S2
{
 char c1;
 char c2;
 int i;
};

   它们的成员一致,只是顺序不一样,但是S2只有8个字节,而S1却是12个字节,技巧就是将小的成员放在前面,大的成员放在后面

3.修改默认对齐数

   #pragma 这个预处理指令,可以改变编译器的默认对⻬数,#pragma pack()则可以取消#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));
 return 0;
}

   由于现在默认对齐数为1,所以对于字符型,对齐数为1,对于整型,对齐数为1,所以不会造成空间浪费,会紧紧放在一起,如图:
在这里插入图片描述
   由于每个成员最后得到的对齐数都是1,所以最后总空间6满足是1的倍数的条件,所以结构体最后大小为6个字节,如图:
在这里插入图片描述

四、结构体传参

我们来看一个例子,试着分析一下print1函数和print2函数哪一个更优:

#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;
}

   这个例子中我们分别采用了传值调用和传址调用,那么哪种方式更好呢?我们首先要知道传值调用和传址调用的区别,可以参照博客:【C语言】手把手带你拿捏指针(1)(初始指针)
   然后我们开始分析,在传值调用时,函数会创建一个和这个结构体一模一样的结构体形参,此时会占用空间,如果结构体很大,那么传值调用时就会浪费空间
   并且传值调用的形参是实参的一份临时拷贝,修改这里的形参不能更改实参,也就是对函数中的结构体形参修改影响不了真实的那个结构体
   而我们的传址调用很明显更好用,因为函数接收时只需要创建一个指针形参,不是4个字节就是8个字节,节省了空间,并且通过指针形参的地址可以修改我们真正的结构体
   再走回我们刚刚举的例子,很明显print2函数比print1函数更好,原因:

  • 函数传参的时候,参数是需要压栈,会有时间和空间上的系统开销
  • 如果传递⼀个结构体对象的时候,结构体过⼤,参数压栈的的系统开销⽐较⼤,所以会导致性能的下降,而传地址就可以节省开销

所以综上,在结构体传参时,选择传地址,也就是传址调用会优于传值调用

   本次分享就到此结束了,如果你熟练掌握了这些知识,那你基本上就可以拿捏结构体这个部分的知识了,当然还有一个附加知识:用结构体实现位段,这个我们下期再讲
   如果有问题欢迎随时询问,bye~~

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐