C语言——结构体

一、结构体基础知识

       C语言允许用户自己建立由不同类型数据组成的组合型的数据结构,它称为结构体。struct是声明结构体类型时所必须使用的关键字,结构体类型的名字是由关键字struct和结构体名组合而成的(例如struct Student),它和系统提供的标准类型int、char等具有相似的作用,可以用来定义变量(例如struct Student stu1,stu2;)。

1.1 结构体的定义

       首先看下面两种写法:

图1.1 常见结构体写法(媳妇儿倾力制作^_^)

       图2写法只是建立了一个结构体类型,它相当于一个模型,并没有定义变量,其中无具体数据,系统对之也不分配内存单元;而图1写法如果在{ }外侧定义,p1和p2相当于是两个全局的结构体变量,变量已经定义且系统会分配内存单元。定义结构体变量有以下三种方法:

1.1.1 先声明结构体类型,再定义该类型的变量
struct Peo
{
	char name[20];
	char tel[12];
	char sex[5];
	int high;
};
int main()
{
	struct Peo p1 = { 0 };
	return 0;
}

       这种方式是声明类型和定义变量分离,在声明类型后可以随时定义变量,比较灵活。千万注意:结构体类型与结构体变量是不同的概念,不要混同。只能对变量赋值、存取或运算,而不能对一个类型赋值、存取或运算。在编译时对类型是不分配空间的,只对变量分配空间。

可以通过typedef重定义结构体的类型名,如上面例程中结构体类型可以重定义为:

typedef struct Peo

{

        char name[20];

        char tel[12];

        char sex[5];

        int high;
}Peo;

重定义后我们就可以直接用Peo来创建该类型的结构体变量了,写法十分简洁:比如Peo p;

1.1.2 在声明类型的同时定义变量
struct Peo
{
	char name[20];
	char tel[12];
	char sex[5];
	int high;
}p1,p2;
int main()
{
	;
	return 0;
}

       这种方法是声明类型和定义变量放在一起进行,能直接看到结构体的结构,比较直观,当然对结构体类型的声明和定义可以也在{ }内进行,如果在外面定义就是全局变量会占用内存,没有上述方法灵活。

       在写小程序时用此方法比较方便,但在写大程序时,往往要求对类型的声明和对变量的定义分别放在不同的地方,以使程序结构清晰便于维护,所以一般不多用这种方式。

1.1.3 不指定类型名而直接定义结构体类型变量
struct 
{
	char name[20];
	char tel[12];
	char sex[5];
	int high;
}p1,p2;
int main()
{
	;
	return 0;
}

       这种写法指定了一个无名的结构体类型,它没有名字(不出现结构体名)。显然不能再以此结构体类型去定义其他变量,即使是两个成员完全相同的结构体变量,它们之间也不会有任何联系,这种方式用的不多。 

1.2 结构体变量的初始化和引用

       在定义结构体变量时可以对它的成员初始化,初始化列表是用花括号括起来的一些常量,这些常量依次赋给结构体变量中的各成员,另外结构体可以嵌套结构体(不能嵌套本身,下面会详细介绍),初始化方法如下所示:

struct Peo
{
	char name[20];
	int age;
};

struct Stu
{
	struct Peo p;
	int num;
	float f;
};

int main()
{
	struct Peo p1 = { "zhangsan",20 };
	struct Stu s = { {"lisi",18} ,15,3.14 };

	return 0;
}

       C99标准允许对结构体某一成员进行初始化,编程时初始化容易出错,本人就曾经在对结构体中二维数组初始化时出错,编译不过百思不得其解,当时想着二维数组的初始化方法,于是想当然地写出了如下错误代码:

图1.2 结构体中二维数组初始化错误例程

       后来猜测wanghao.arr[3][4]进行赋值操作不是对整个二维数组赋值,而是对arr[3][4]一个元素赋值,代码修改后调试结果如下,猜测得到了验证,看来结构体中二维数组初始化需要用循环来实现了。

图1.3 结构体中二维数组初始化调试

       结构体变量的成员的访问有两个操作符,一个是点操作符(.):结构体变量.成员变量;另一个是指向运算符(->),结构体指针->成员变量。如果p指向一个结构体变量,(*p).成员名等价于p->成员名,注意*p两侧括不能省,因为“.”运算符的优先级很高。不能企图输出结构体变量名来达到输出结构体变量所有成员的值,只能对结构体变量的各个成员分别进行输入和输出。

1.3 结构体传参和结构体指针

       指向结构体对象的指针变量p既可以指向结构体变量,也可指向结构体数组中的元素,p+1意味着p所增长的值为一个结构体变量所占用字节数。p不应用来指向结构体中数组中的某一成员,例如一个结构体数组stu[3],p = stu[1].name是不对的,类型不匹配,可以强制类型转换再赋值。另外注意(++p)->num和(p++)->num的不同。

       将一个结构体变量的值传递给另一个函数有2个方法:传值调用和传址调用。

       (1)用结构体变量作实参:用结构体变量作实参时,采取的是“值传递”的方式,将结构体变量所占的内存单元的内容全部按顺序传递给形参,形参也必须是同类型的结构体变量。在函数调用期间形参压栈也要占用内存单元,形参是实参的一份临时拷贝,这种传递方式在空间和时间上开销较大,如果结构体的规模很大时,参数压栈的系统开销比较大,会导致性能的下降。此外“值传递”方式在执行被调用函数期间改变形参的值,此改变不能返回主调函数,因此较少用这种方法。

       (2)用指向结构体变量(或数组元素)的指针作实参:将结构体变量(或数组元素)的地址传给形参,结构体变量的地址主要用作函数参数。这种方法比较常见,结构体传参的时候,要传结构体的地址,如果想要避免执行被调用函数时结构体内成员值被误修改,可以在形参前加const修饰。

       另外需注意结构体变量成员作参数时,用法和普通变量作实参是差不多的,应当注意实参和形参的类型保持一致即可,例如下图例程结构体变量中一个字符串数组名作实参时,形参既可以是数组也可以是指针。

图1.4 结构体传参例程

二、结构体内存对齐(结构体进阶)

2.1 结构体自引用

在结构体中包含一个类型为该结构本身的成员是否可以?

答案是不可以,编译会报错,因为sizeof(struct Node)将会无穷无尽。

        正确的自引用方式是:

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

       此结构体包含两部分:数据域和指针域,指针可以找到同类型的另外一个节点,这就是数据结构中链表的实现原理。

2.2 结构体内存对齐(面试笔试热门考点)

2.2.1 结构体内存对齐规则(对齐数、偏移量概念)

       结构体内存对齐涉及到的就是计算结构体大小问题,首先看一个计算结构体大小的例程,如下图:

图2.1 结构体内存对齐例程1

       为什么不是6?为什么成员类型一样只是换了个前后顺序大小就不一样了?可以明确的是计算结构体大小时简单地计算每个成员大小然后相加是不正确的!因为结构体存在内存对齐规则:

       (1)第一个成员在与结构体变量偏移量为0的地址处;

       (2)其他成员变量要对齐到某个数字(对齐数)的整数倍地址处。对齐数 = 编译器默认的一个对齐数与该成员大小的较小值

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

       (4)如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。

       知道规则后不难分析计算出上图例程结构体大小:

图2.2 结构体例程1分析

可以通过offsetof宏定义进一步观察验证结构体各成员存放的对齐地址偏移量:

#include<stddef.h>

offset(type,member)

参数type为结构体类型名,参数menber为结构体成员名,返回值为偏移量

        利用offsetof宏定义验证偏移量结果如下图所示: 

图2.3 结构体内存对齐偏移量

        下图是一个结构体嵌套结构体内存对齐例程:

图2.4 结构体内存对齐例程2

       按照对齐规则分析其对齐形式(偏移量)和总大小的结果如下图所示:

图2.5 结构体内存对齐例程2分析

       有个易错点是str1结构体最大对齐数是8而不是4,double d虽然是第一个成员偏移量为0,但其也有对齐数,即8。VS中例程打印结果如下图:

图2.6 结构体内存对齐例程2结果

       VS编译器默认对齐数为8,GCC编译器没有对齐数的概念,就是成员自身大小,也就是说GCC编译环境下只要结构体中没有比8字节长的数据类型,结构体占用空间大小、对齐方式和在VS编译环境下是相同的。

2.2.2 为什么存在内存对齐

两个原因:

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

       (2)性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。

       对于原因1不知所云暂且按下不表,原因2看b站教学视频上是这么说的,对于一个包含一个char型和一个int型变量的结构体,如下图所示,在内存对齐和不对齐情况下访问整型变量:在32位机器上(一次可以访问32位数据)内存对齐只需一次访问,而不对齐第一次访问只获取到3个字节,还需第二次访问。

图2.7 结构体内存对齐原因分析

       总而言之:结构体的内存对齐是拿空间来换取时间的做法。

2.2.3 预处理指令修改默认对齐数

        在设计结构体的时候,我们既要满足对齐,又要节省空间,如何做到?

       让占用空间小的成员尽量集中在一起,例如上图2.????例程struct Str1结构体类型和struct Str2结构体类型虽然成员类型一样只是顺序不同,但是分别对应一个变量所占空间大小有区别,前者浪费2个字节内存,后者浪费6个字节内存,当然如果顺序有严格要求则另当别论。

       #pragma这个预处理指令可以改变默认对齐数,使用方法参照下图例程:

图2.8 修改对齐数例程

       默认对齐数为1时就是不对齐紧凑存放,默认对齐数不能随意修改,一般修改为2^n。

2.3 位段

       位段是通过结构体来实现的一种以位(bit位)为单位的数据存储结构,它可以把数据以位的形式紧凑的储存,并允许程序员对此结构的位进行操作,位段的使用是为了节省内存空间。位段是在结构体基础上实现的,位段的声明和结构体类似,有两个不同:一是位段的成员必须是int,unsigned int,signed int或char(整型家族)类型;二是位段的成员后边有一个冒号和一个数字,数字表示该成员占用bit位的个数,显然这个数字不能超过类型最大bit位数。比如下面A就是一个位段类型:

struct A
{
	int _a : 2;
	int _b : 5;
	int _c : 10;
	int _d : 30;
};
2.3.1 位段的内存分配(VS2022环境)

位段内存分配方式:

       (1)一般情况下位段的成员是同一类型的,不会夹杂不同类型的成员,因为位段本身就是一个非常不稳定的东西,如果成员类型不同的话,就会使得位段变得非常复杂充满了不确定性。

       (2)位段的空间是按照需要,以一次4个字节(成员为int)或1个字节(成员为char)的方式来开辟的。

       (3)位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该尽量避免使用位段。

       下面以一个例程来理解上面第二点位段空间开辟方式, 如下所示:

#include<stdio.h>

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、b、c、d共占用47个bit位,是否分配6个字节呢?程序打印结果如下图:

图2.9 位段内存分配例程1运行结果

       可以看出系统最终分配了8个字节内存空间,如果不用位段而是结构体将会占用16个字节的空间,可以看出节省了一倍的空间,虽然也浪费了17个bit位。那么对于struct A位段系统是如何开辟空间的呢?

       结合上述位段内存分配方式第二点:因为是int型,首先开辟4个字节空间也就是32bit位,a、b和c占用之后还剩15个bit位,不够d使用,所以再开辟4字节空间,到此就完全放的下了,一共8字节空间。但是问题来了,成员d是先用完第一个4字节剩下的15个bit位,还是直接在新开辟的第二个4字节使用30个bit位呢?再通过下一个例子揭露真相:

#include<stdio.h>

struct S
{
	char a : 3;
	char b : 4;
	char c : 5;
	char d : 4;
};

int main()
{
	struct S s = { 0 };
	s.a = 10;
	s.b = 12;
	s.c = 3;
	s.d = 4;
	printf("%d\n", sizeof(struct S));

	return 0;
}

       还是结合内存分配方式第二点分析:因为是char型,先开辟1个字节空间,a、b占用7个bit位,还剩1个bit位不够c用所以再开辟1个字节空间,如果c先用第一个1字节剩余的1个bit位,那么第二个1字节空间就刚好可以被c、d占用。那么S的大小就会是2个字节,程序打印结果如下:

图2.10 位段内存分配例程2运行结果

       可以看出最终开辟空间大小是3个字节,不难猜出在VS编译器中,当开辟出来的空间不够位段成员使用的时候,剩余的空间会被浪费掉,然后新开辟一个空间存放后续成员。

       再者就是成员变量在内存中是如何存放的,因为可能涉及到一个字节存放两个成员变量,那么是从低位到高位存放还是从高位到低位存放?例如上面例程中a变量是存放在第一个字节的高三位还是低三位?假设存放在低三位,那么存放结果应该如下图,即s占用三个字节,分别是0x62,0x03和0x04:

图2.11 位段成员内存存储分析

       打开内存调试窗口,调试结果如下图所示,可以得出结论VS2022编译环境下位段成员在当前所开辟的空间中是从低位到高位存储。

图2.12 位段成员内存存储调试结果
2.3.2 位段的跨平台问题

       (1)int位段成员被当成有符号数还是无符号数是不确定的。

       (2)位段中最大位的数目是不确定的。(在16位机器上int型最大为16,而在32为机器上int型最大为32,如过位数是27,那么在16位机器上就会出问题)

       (3)位段的成员在内存中从左向右分配,还是从右向左分配C语言标准尚未定义,不同编译器环境可能有所差异。

       (4)当一个结构体包含两个位段成员,第二个位段成员比较大,无法容纳于第一个位段成员占用空间后剩余的位时, 是舍弃剩余的位还是利用,是不确定的。

       位段这个语法设计是通用的,只是不同平台位段的内存分配可能有所差异,因此使用位段时需要针对不同平台设计不同代码。

2.3.3 位段的应用

       位段的一个应用场景就是网络底层代码中的IP数据包头部信息格式,IP数据包格式如下图:

图2.13 位段在IP数据包中的应用

       可以看出封装的头部信息正是以位的形式紧凑存储在5个int型数据即20个字节中,显而易见,此处应用位段的一个好处就是压缩了头部信息数据长度,如果不使用位段而使用结构体来进行封装,每个信息(例如版本、首部长度、区分服务、总长度等)都用一个int来存储,可想而知IP数据包光一个头部信息就会占用很多个字节,毫无疑问会降低网络传输数据效率。

  • 39
    点赞
  • 50
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值