目录
结构体存在的意义是什么?
因为在实际生活中,我们要描述的对象一般都是比较复杂的。例如,要描述一本书籍,包含有作者的姓名char类型,出版社char类型,价格float类型等信息。此时要用一个基本变量类型的数组来存储是不可能的。因为数组是具有相同数据类型元素的集合,然而这些数据显然具有不同的类型。属于构造类型的结构体,可以很好的解决这一问题。
结构体的概念
结构体是一些值的集合,这些值称为成员变量。结构体的每一个成员可以是不同类型,也可以是相同类型。
结构体的优点
结构体不仅可以记录不同类型的数据,而且使得数据结构是“高内聚,低耦合”的,更利于程序的阅读理解和移植,而且结构体的存储方式可以提高CPU对内存的访问速度。
高内聚,低耦合:在一个项目中,每个模块之间相互联系的紧密程度,模块之间联系越紧密,那么耦合性就越高;模块与模块之间联系不紧密,就说明耦合性低,模块的独立性就越好。一个模块中,各个元素的联系紧密程度越高,则内聚性越高,即高内聚。现在的软件结构设计,都会要求“高内聚,低耦合”,来保证软件的高质量!
结构体的声明
以一名学生为例:
struct student
{
char name[20];
int age;
float grade;
};
其中,struct是声明结构体的关键字,student是结构体的名字,内部的name、age、grade是结构体成员,struct student是一种类型。结构体的声明格式可以总结如下:
struct 结构体名
{
结构体成员;
……
};
当然,我们还可能会遇到下面两种写法:
struct student
{
char name[20];
int age;
float grade;
} Stu; / /不一样之处在这
这种写法在声明结构体变量student的同时创建了变量Stu。另一种写法是在声明的同时结合关键字typedef进行类型重命名。
typedef struct student
{
char name[20];
int age;
float grade;
} student;
之后就可以用student创建变量。例如下面给出的一段代码:
typedef struct student
{
char name[20];
int age;
float grade;
}student;
int main()
{
student S = { "zhangsan",18,85 };
return 0;
}
匿名结构体
什么是匿名结构体?匿名结构体就是在声明的时候省略掉了结构体的名字。下面声明的结构体就是匿名结构体。
struct
{
int a;
char b;
float c;
}x;
个人认为,匿名结构体不怎么常用,平时在写代码时应尽量避免使用,这里只是简单的提了一下这东西,直到有这么一回事就可以了,平时很少用。
结构的自引用
在结构中包含一个类型为该结构本身的成员是否可以呢?例如下面代码。
报了个未定义的错误,也就是说,struct Node这种类型还未产生就提前在内部使用了。其实,以上代码是误解自引用的产物。结构体的自引用是指结构体成员中包含一个指向该结构体的指针。正确的写法应该是这样的:
上述代码其实是在创建节点,学了数据结构的读者就深有体会了。
访问结构体成员的两种方法
方法一:
用 . (点操作符)
结构体变量名 . 成员名
方法二:
用 ->箭头访问
结构体指针->结构体成员
想一想,为什么存在第二种访问结构体成员的方法?
因为在某些函数调用时传的是结构体的地址,也就是结构体指针。我们知道,形参是实参的一份临时拷贝,传地址更加高效,也就用4或8字节的空间,但是如果结构体很大,拷贝一份的开销可不止这么多。有了第二种访问方法,就可以很方便的在被调用函数内访问结构体成员,同时提高了效率。
结构变量的初始化
首先得区别初始化和赋值。
struct student
{
char name[20];
int age;
float grade;
};
int main()
{
//在变量创建时就给值,这个叫初始化
struct student stu = { "zhangsan",18,85 };
//变量创建后对成员给值,这个叫赋值
stu.age = 200;
return 0;
}
struct student
{
char name[20];
int age;
float grade;
}stu = { "zhangsan", 18, 85 };//初始化
值得注意的是,结构体只能被整体初始化,不能被整体赋值,想要赋值的话只能把成员逐个地取出来再赋值。 请看代码:
struct student
{
char name[20];
int age;
float grade;
}stu;
int main()
{
stu = { "zhangsan",18,85 };//error
return 0;
}
这种写法是错误的,因为结构体不能被整体赋值。 下面演示如何把成员逐个取出来赋值,在对字符数组(本例中的name)进行赋值时,隐藏了一个细节。
错误写法:
这种写法在对字符数组进行赋值时是错误的,“zhangsan” ,这个表达式的结果为首元素的地址,类型是char*,name是数组名,也是首元素的地址,类型为char*,但是数组名是常量,不可修改,当然会报错。
正确写法:
用strcpy函数对字符数组进行赋值。
结构体内存对齐
为什么存在内存对齐?
> 移植原因。不是所有的硬件平台都能访问任意地址上的任意数据的,某些硬件平台只能在某些 地址处取某些特定类型的数据,否则抛出硬件异常。
> 性能原因。数据结构(尤其是栈)应该尽可能的在自然边界上对齐。原因在于,为了访问未对 齐内存,处理器需要做两次内存访问,而对齐的内存只需要一次访问即可。举个例子,假设处理 器总是从内存中取8个字节,如果我们能保证将所有的double类型的数据的地址都对齐到8的倍 数,那么就可以一次访问读取我们所需的数据。否则,我们可能需要执行两次访问,才能把一个 double类型的数据取出。
红色框为数据块。
总的来说:结构体的内存对齐是拿空间来换取时间的做法。
对齐规则
说明:以下所说的偏移量都是相对于结构体起始位置而言的。
> 结构体的第一个成员总是对齐到偏移量为0的地址处
> 其他成员要对齐到对齐数的整数倍处的地址(下面会解释对齐数)
> 结构体总大小为最大对齐数的整数倍
> 如果嵌套了结构体,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构体的整体大小就是最大对齐数(含嵌套结构体成员的对齐数)的整数倍。
对齐数 = 编译器默认对齐数 与 该成员变量大小 的较小值
VS中默认的对齐数为8,Linux中gcc没有默认对齐数,对齐数就是成员自身的大小。
下面,举一组例子来计算结构体大小,感受上述方法,同时对比分析这两种不同写法的结果。
第一种写法:
请问程序运行的结果是什么? (VS2019下测试)
首先用上述方法分析结果,再将分析得出的答案与程序运行结果比对,看看此处的分析是否正确。
第一步:计算对齐数
自身大小 编译器默认对齐数 对齐数
char c1 —— 1 —— 8 —— 1
int i —— 4 —— 8 —— 4
char c2 —— 1 —— 8 —— 1 最大对齐数为 4
第二步:画图
从偏移量为0的位置到偏移量为8的位置,总共占9个字节,那该结构体的大小是不是就为9字节呢?答案是否定的,别忘了结构体总大小为最大对齐数的整数倍,很明显,9不是最大对齐数4的整数倍,往上去,最先找到的符合的是12。所以该结构体最终的大小为12字节。以上就是分析的全过程,下面来看看程序运行的结果是否与分析得到的结果相同。
可以看到,分析结果与程序运行结果相同,说明上述的分析是正确的。接下来,举第二个例子。
第二种写法:
还是按上述方法分析就可以得出结果了,这里就不再分析了,直接看运行结果。
对比这两种写法,只是把结构体成员调了顺序,得出的结果就不一样,从节约空间的角度来看,第二种写法显然更好。对比这两个写法,得出的结论是:占用空间小的成员要尽量集中在一起,这样可以节省空间。
修改默认对齐数
#pragma 这个预处理指令可以改变编译器的默认对齐数
用法如下:
计算结构体成员的偏移量
size_t offsetof( structName, memberName );
函数 offsetof —— 可以计算出结构体成员相较于起始位置的偏移量
需要引头文件 —— <stddef.h>
用法如下:
#include <stdio.h>
#include <stddef.h>
struct s2
{
char c1;
char c2;
int i;
};
int main()
{
printf("%d\n", offsetof(struct s2, c1));
printf("%d\n", offsetof(struct s2, c2));
printf("%d\n", offsetof(struct s2, i));
return 0;
}
运行结果:
结构体传参
结构体传参的两种方式:
> 直接将结构体传过去
> 将结构体的地址传过去
下面将给出两种传参方式的代码。
struct S
{
int data[1000];
int num;
};
struct S s = { {1,2,3,4,5},999 };//创建变量并初始化
//直接传结构体
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;
}
上面的print1和print2两个函数,你觉得哪一个更好呢?为什么?
如果是我,我觉得print2更好,理由如下:
函数传参的时候,参数是需要压栈的,会有时间和空间上的系统开销。如果直接传递结构体,结构体较大时,参数压栈的系统开销比较大,会导致性能的下降。如果传的是结构体的地址,无非就是4或8个字节(32位或64位),效率相对来说要好很多,结构体越大,越能体现出这种优势。所以,建议结构体传参的时候尽量传结构体的地址。
结构体实现位段
我不敢保证每一位读者都听说过位段,但我相信大家一定听说过段位。接下来,介绍一下什么是位段。
什么是位段?
位段是通过结构体来实现的一种以位(bit位)为单位的数据存储结构,它可以把数据以位的形式紧凑的储存,并允许程序员对此结构的位进行操作。
> 位段的成员必须是int、unsigned int、char等整形家族成员。
> 位段的成员一般都是同一数据类型,例如都是int或char类型。
> 位段的出现就是为了节省空间的。
位段的声明
位段的声明和结构体的声明类似,但也有不同之处。第一点不同之处就是上面提到的,位段的成员一般是int、char等整形家族;第二点不同之处就是位段的成员名后边有一个冒号和一个数字。下面先声明一个位段,再解释位段中的数字到底有什么含义。
//位段的声明
struct A
{
int a : 2;
int b : 5;
int c : 10;
int d : 30;
};
A就是一个位段类型。那么数字2、5、10、30的含义是什么呢?
这些数字的单位都是比特,以数字2为例,2,说明变量a需要占用两个比特位。其他的数字同理。
位段的内存分配
我相信肯定会有小伙伴好奇,上述的位段A占用多大的内存。下面就让程序跑起来,看看结果何。
//位段的声明
struct A
{
int a : 2;
int b : 5;
int c : 10;
int d : 30;
};
int main()
{
printf("%d\n", sizeof(struct A));
return 0;
}
运行结果为:
可以看到,位段A占用8个字节。 下面分析这一结果的缘由。
位段的空间上是按照需要以4个字节(int)或者1个字节(char)的方式来开辟的。需要注意的是,不能写成这样:int a : 40; 在32位平台下,int的大小为4个字节,即32个比特位,这里明显超过了32,是错误的写法。可以看到,编译器会报错。
这里插入了一个小细节,下面接着上文讲为什么结果是8字节。
位段A中都时int类型,每次按需开辟4个字节。请看图:
a、b、c和起来占用17个比特位,4个字节的空间完全够用,此时还剩下15个比特位,d要用30个比特位,显然15个比特位放不下,所以又开辟了4个字节。加起来总共8个字节。
其实,这当中还隐藏了一些细节,例如下面的例子。
在一个字节内部,是从左向右分配还是从右向左分配,是标砖尚未定义的。还有,结合上面的,a、b、c使用了17个比特位,还剩15个比特位,这15个比特位是别舍弃还是利用,这也是标准未定义的 ,不同的编译器的实现可能有所不同,在VS2019下,就是舍弃了这15个比特位。可以看出,位段涉及很多不确定的因素,是不跨平台的,注重可移植的程序应该避免使用位段。
位段的应用
虽然上面把位段说的一无是处,但是,在特定的场景下,使用位段时高效的。例如是⽹络协议中,IP数据报的格式,这里就不过多赘述了。
位段使用的注意事项
位段的几个成员共用同一个字节,这样有些成员的起始位置并不是某个字节的起始位置,那么这些位置处是没有地址的。因为内存中,每个字节分配一个地址,一个字节内部的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);
return 0;
}
会报错误:不允许使用位域的地址。
正确示范:
struct A
{
int a : 2;
int b : 5;
int c : 10;
int d : 30;
};
int main()
{
struct A sa = { 0 };
int a = 0;
scanf("%d", &a);
sa.a = a;
return 0;
}
需要注意的是,a只有两个比特位,最大值不能超过3(两个比特位的二进制最大值为11,即3)。