一.前言
在C语言中,不仅有int、char、short、long等内置类型,C语言还有一种特殊的类型——自定义类型。该类型可以由使用者自己定义,可以解决一些复杂的个体。
二.结构体
2.1结构体的声明
我们在利用结构体的时候一般是用于描述一些有多种因素的对象。比如我们要描述一个学生,那么学生就有他的姓名,性别,年龄,如果我们使用内置类型的话,非常麻烦,我们就可以定义一个结构体类型用来描述一个学生:
//定义了一个学生类型
struct student
{
char name[15];//学生姓名
char sex[5];//性别
int age;//年龄
};
我们创建了类型之后还得依靠此类型创建变量。
2.2结构体变量的创建和初始化
我们要时刻记住,我们创建的是一个类型,而不是一个变量,我们要根据此类型来创建我们需要的变量。 下来,我们依据上面创建的学生类型来创建变量并进行初始化:
#include <stdio.h>
//定义了一个学生类型
struct student
{
char name[15];//学生姓名
char sex[5];//性别
int age;//年龄
};
int main()
{
//在创建变量的同时进行初始化
//该初始化必须得按照结构体内部成员的顺序进行初始化
struct student stu1 = { "zhangsan","nan",18 };
printf("%s\n", stu1.name);
printf("%s\n", stu1.sex);
printf("%d\n", stu1.age);
//利用访问操作符可实现自定义顺序进行初始化
struct student stu2 = { .age = 21,.name = "lisi",.sex = "nv" };
printf("%s\n", stu2.name);
printf("%s\n", stu2.sex);
printf("%d\n", stu2.age);
return 0;
}
我们除了在主函数内创建结构体变量外,还可以在定义结构体的同时进行创建变量:
#include <stdio.h>
struct student
{
char name[15];
char sex[5];
int age;
}stu1;
int main()
{
struct student stu2 = { 0 };
return 0;
}
stu1和stu2都是结构体类型变量,区别是stu1是全局变量存放在静态区,而stu2是局部变量存放在栈上。
2.3结构体访问操作符
结构体访问操作符有.(句点操作符)和->(箭头操作符)。句点操作符用于结构体变量的访问,而箭头操作符用于结构体指针来访问结构体变量。该操作符我在之前已经介绍过了,大家可以看我之前的博客。结构体中的访问运算符-CSDN博客https://blog.csdn.net/xsc2004zyj/article/details/136722599?spm=1001.2014.3001.5502
2.4结构体的特殊声明
结构体除了正常的声明外,还有一种特殊的声明方式,叫做匿名结构体。
匿名结构体就是在声明结构体的时候不给结构体标识符,而直接创建一个变量。
//匿名结构体,该结构体没有标识符
struct
{
char name[15];
char sex[5];
int age;
}stu;
而对于匿名结构体类型来说,该结构体基本上只能使用一次,因为该结构体没有标识符,后续没法再对此来创建变量。
下面提出一个问题:
#include <stdio.h>
struct
{
int a;
char b;
float c;
}x;
struct
{
int a;
char b;
float c;
}*p;
int main()
{
p = &x;
return 0;
}
主函数中的p = &x;是否合法?
答案是:非法,因为以上两个结构体的成员变量完全一样,并且都使用匿名的方式,所以编译器会把两个声明当成两个完全不同的类型,所以是非法的。
2.5结构体的自引用
如果我们在描述某个对象的时候,需要用到结构体的自引用,我们应该如何写?
struct stu
{
int age;
struct stu s;
};
上面代码正确么?如果正确,那么sizeof(struct stu)的大小是多少呢?上面的代码其实是错误的,如果这样进行自引用,那么一个结构体变量的空间将会无穷大,因为一个结构体里面永远还有一个结构体。
正确的应该是存放一个该结构体的指针,因为一个指针的大小不是四个字节,就是八个字节。而且该指针也指向了一个该类型的变量,所以也实现了结构体的自引用。
struct stu
{
int age;
struct stu *s;
};
2.6利用typedef重命名结构体类型
我们在创建结构体变量的时候每次都要写出struct tag x;这样写起来非常麻烦,并且有人可能粗心大意而忘记struct。所以,我们在声明结构体的时候,可以利用typedef关键字给该结构体重新起个名字,用该名字来创建变量。
#include <stdio.h>
typedef struct student
{
char name[14];
int age;
}stu;
int main()
{
stu s;
struct student s2;
return 0;
}
此时,在声明时分号前面就不是创建变量了,还是该结构体的一个新名字——stu。在创建变量的时候,stu和struct student是一个意思。
我们也可以利用typedef来解决匿名结构体的问题,我们只需要给匿名结构体重新起一个名字,利用该名字创建变量就行了。这时,该匿名结构体与正常声明的结构体没有区别。
#include <stdio.h>
typedef struct
{
char a;
int b;
}X;
int main()
{
X x;
X s;
return 0;
}
这样就解决了匿名结构体只能使用一次的局限。我们可以利用重命名的结构体进行创建变量,初始化等操作。
三.结构体内存对齐
我们已经基本了解了结构体的内容,下来我们来讨论结构体中的热门话题:结构体的大小。这也是最近热门的考点:结构体内存对齐。
3.1对齐规则
在理解内存对齐之前,我们得先了解结构体内存对齐的规则。
- 结构体的第一个成员对齐到和结构体变量起始位置偏移量为0的地址处
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。对齐数 = 编译器默认的一个对齐数 与 该成员变量大小间的较小值。VS上的默认对齐数 = 8。Linux中gcc没有默认对齐数,对齐数就是成员自身的大小
- 结构体的总大小等于最大对齐数(结构体的每一个成员变量都有对齐数,所有对齐数中最大的一个)的整数倍。
- 如果嵌套了结构体的情况,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。
下来我来一一介绍结构体的对齐规则。我们通过一个练习来引出:
struct S1
{
char c1;
int i;
char c2;
};
int main()
{
printf("%d\n", sizeof(struct S1));
return 0;
}
我们利用该结构体来进行解说,判断该结构体的大小。有的人会想,该结构体的成员两个char,一个int,那就占6个字节,是这样么?
我们看到,结果是12个字节,与我们的猜测不符。这就是因为结构体内部存在内存对齐规则。
在上图,我借助练习题详细介绍了结构体对齐规则如何理解,以及内存是如何进行对齐的。大家先仔细理解上图,后进行下面几个结构体大小的练习。
3.1.1练习1
#include <stdio.h>
struct S2
{
char c1;
char c2;
int i;
};
int main()
{
printf("%zd\n", sizeof(struct S2));
return 0;
}
3.1.2练习
#include <stdio.h>
struct S3
{
double d;
char c;
int i;
};
int main()
{
printf("%zd\n", sizeof(struct S3));
return 0;
}
3.1.3练习3
#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;
}
3.2为什么存在内存对齐
大部分的参考资料是这样说的:
3.2.1平台原因(移植原因):
不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定 类型的数据,否则抛出硬件异常。
3.2.2性能原因:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要⼀次访问。假设⼀个处理器总是从内存中取8个字节,则地址必须是8的倍数。如果我们能保证将所有的double类型的数据的地址都对齐成8的倍数,那么就可以用⼀个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问,因为对象可能被分放在两个8字节内存块中。
总的来说:结构体的内存对齐是为了拿空间换取时间的做法。
3.2.3那么如何尽可能节省空间的浪费呢?
我们来分析一下,这两个结构体:
struct S1
{
char c1;
int i;
char c2;
};
struct S2
{
char c1;
char c2;
int i;
};
S1和S2的成员类型都一样,只是存储的顺序不同,那这两个结构体的大小是否一样大呢?
我们看到S1和S2的成员虽然相同,但是两者的大小却不同,我们来分析一下。
那么,我们在设计结构体的时候,既要满足对齐,又要节省空间,如何做到:
让占用空间小的成员尽量集中到一起。
3.2.4修改默认对齐数
#pragma这个预处理指令可以修改编译器的默认对齐数
//利用#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;
}
我们在上面已经分析该结构体的大小为12个字节(在VS默认8为对齐数的情况下),而我们利用#pragma已经将编译器的默认对齐数修改为1,结果还是12个字节么?
我们分析后得出,修改默认对齐数后该结构体的大小变成了6字节。
四.结构体传参
我们创建好结构体变量后,可能需要把该结构体变量的某个成员变量传给某个函数。而我们到现在由两种传参的方式:值传递和地址传递。那我们应该选择哪一种方式对结构体变量进行传参呢?
//结构体传参
#include <stdio.h>
struct S
{
char c1;
char c2;
int i;
};
//值传递
void test1(struct S s)
{
printf("%c\n", s.c1);
printf("%c\n", s.c2);
printf("%d\n", s.i);
}
//地址传递
void test2(struct S* ps)
{
printf("%c\n", ps->c1);
printf("%c\n", ps->c2);
printf("%d\n", ps->i);
}
int main()
{
struct S s = { 'a','b',10 };
test1(s);
test2(&s);
return 0;
}
我们看到,无论是值传递,还是地址传递,都可以达到我们的目的。那我们到底应该选那种方式呢?
我们首先要知道,值传递中的形参是实参的一份临时拷贝,会在栈上存储其拷贝内容,也就意味着会消耗内存,那如果该结构体的大小非常大的话,我们在栈上就会消耗很多内存。
无论是何种传参方式,都会进行压栈操作,而值传递在压栈过程中机会在时间和空间上有大量的系统开销。而地址传递的话,指针的大小不是8个字节就是4个字节,在压栈过程中不会有太多空间和时间上的开销。
所以,在结构体传参的时候,要传结构体的地址
五.结构体实现位段
利用结构体实现位段。位段这个概念大家可能没听过,但段位大家肯定不陌生。位段是一种特殊的结构体类型,位段必须得依靠结构体来实现。
5.1什么是位段?
位段的声明和结构体是类似的,但有两个不同:
- 位段的成员必须是int 、unsigned int、或者signed int,在C99中位段成员的类型也可以选择其他类型。
- 位段的成员后面有一个冒号和一个数字。
比如:
struct S
{
int _a : 2;
int _b : 5;
int _c : 10;
int _e : 30;
};
通常在创建位段式结构体的时候习惯在每个变量前面加上下划线,以此来说明这是位段式结构体。
我们看到结构体和位段式结构体的区别就在于位段的每个成员后面多了一个冒号和一个数字,这是什么意思呢?
这每个成员后面的数字表示该成员占的bit位,_a占2个bit位,_b占5个bit位,_c占10个bit位。为什么要这样设置变量呢?
我们想,如果_a里面只会存1,2,3这三个数,1二进制就是01,2二进制就是10,3二进制就是11,所以存这三个数用两个bit位就够了。以此类推,_b就是存只需5个bit位就能表示的数,_c就是只存10个bit位就能表示的数。这样就可以大大减少空间的浪费。那位段式结构体是怎样储存数据的呢?是如何达到节省空间的?下面我们来了解位段的内存对齐规则。
5.2位段的内存对齐
位段的成员最好都是同一种类型的,位段会根据成员类型来开辟空间,比如成员是int型,就一次开辟4个字节,如果是char型,就一次开辟一个字节。下面我们举一个例子。
那该位段的大小是不是跟我的结论一样呢?
我们看到,该位段的大小与我们的推测相同。我们再来看一下该位段是如果写入数据的。
我们分析完之后,在VS上调试一下,发现s的内存如下,和我们分析的一样,占三个字节,存储的是0a 0c 05。
5.3位段的跨平台问题
- int位段是被当成有符号数还是无符号数是不确定的。
- 位段中最大位的数目是不能确定的。(16位机器最大16,32位机器最大32,写成27,在16位机器会出问题。)
- 位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。(在VS上默认从右向左分配)
- 当⼀个结构包含两个位段,第⼆个位段成员比较大时,无法容纳于第⼀个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的。(VS上默认舍弃)
总结:跟结构相比,位段可以达到同样的效果,并且可以很好的节省空间,但是有跨平台的问题存在。
5.4位段的应用
下图是网络协议中,IP数据报的格式,我们可以看到其中很多的属性只需要几个bit位就能描述,这里使用位段,能够实现想要的效果,也节省了空间,这样网络传输的数据报大小也会较小⼀些,对网络的畅通是有帮助的。
5.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;
return 0;
}
完!