总所周知,c语言中有许多内置类型,比如char,int,short,long等等,但有这些内置的类型远远是不够的,这时就需要自定义类型,自定义类型包含结构体,枚举,联合体等等,这节主要介绍结构体。

1. 结构体的声明

结构体的声明标准格式如下

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

例如描述一个学生的信息,这时可以采用结构体进行声明

struct Stu
{
 char name[20];//名字 
 int age;//年龄 
 char sex[5];//性别 
 char id[20];//学号 
}; //分号不能丢

2. 结构体变量的创建和初始化

2.1 结构体变量的创建

方法1:可以直接在结构体声明的末端(分号的前面)进行变量创建,即通过类型直接进行变量创建,这时创建的变量属于全局变量。

struct Point
{
 int x;
 int y;
}p1,p2,p3...; //全局变量

方法2:可以在main()函数之前进行变量创建,这时的变量属于全局变量。

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

struct Stu p1;//全局变量

int main()
{
return 0;
}

方法3:在main()函数里面创建变量,这时的变量属于局部变量。

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

int main()
{
struct Stu p2;//局部变量
return 0;
}

2.2 结构体变量的初始化

方法1:通过类型直接进行变量的初始化,称为“结构体嵌套初始化”。

struct Node
{
 int data;
 struct Point p;
 struct Node* next; 
}n1 = {10, {4,5}, NULL};//结构体嵌套初始化

方法2:按照默认顺序初始化

struct Stu 
{
 char name[15];//名字 
 int age; //年龄 
};
struct Stu s1 = {"zhangsan", 20};//默认顺序初始化 

方法3:按照指定顺序初始化

struct Stu 
{
 char name[15];//名字 
 int age; //年龄 
};
struct Stu s2 = {.age=20, .name="lisi"};//指定顺序初始化 
 

3. 结构成员访问操作符

3.1 结构体成员的直接访问

       结构体成员的直接访问是通过操作符“.”访问的,点操作符接受两个操作数。

#include <stdio.h>
struct Point
{
 int x;
 int y;
}p = {1,2};
int main()
{
 printf("x: %d y: %d\n", p.x, p.y);
 return 0;
}

使用方式:结构体变量.成员名

3.2 结构体成员的间接访问

       有时我们得到的不是一个结构体变量,而是得到了一个指向结构体的指针。这时需要将“->”作为访问操作符。

#include <stdio.h>
struct Point
{
 int x;
 int y;
};
int main()
{
 struct Point p = {3, 4};
 struct Point *ptr = &p;
 ptr->x = 10;
 ptr->y = 20;
 printf("x = %d y = %d\n", ptr->x, ptr->y);
 return 0;
}

使用方式:结构体指针->成员名

4. 匿名结构体类型

       这是一种结构体的特殊声明,也就是在进行结构体声明时,不包含结构体名称,而是直接在声明时创建变量,此时的变量是可以被使用的,但是只能使用一次

struct
{
  char c;
  int i;
  double d;
}s={'x',100,3.14};
int main()
{
printf("%c %d %lf\n",s.c,s.i,s.d);
return 0;
}

注:下面这种代码是不合法的

struct
{
  char c;
  int i;
  double d;
}s;
struct
{
  char c;
  int i;
  double d;
}*ptr;
int main()
{
*ptr=&s;
return 0;
}

       编译器会把上⾯的两个声明当成完全不同的两个类型,所以是⾮法的。

  5. 结构体的自引用

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

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

这种写法其实是不可行的,为什么呢?

       我们可以尝试着计算一下sizeof(struct Node)的大小,会发现因为结构体中再包含一个同类型的结构体变量,这样结构体变量的大小就会导致为无穷大,所以是不可行的。

正确的自引用如下:

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

       此时结构体中包含的是一个整型变量和一个该结构体类型的指针变量,对于指针类型的变量,其字节数要么是4,要么是8,所以sizeof(struct Node)是可以被计算出来的。

       在结构体⾃引⽤使⽤的过程中,夹杂了 typedef 对匿名结构体类型重命名,也容易引⼊问题,比如下面的代码。

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

       显然是错误的,因为Node是对前⾯的匿名结构体类型的重命名产⽣的,但是在匿名结构体内部提前使用Node类型来创建成员变量,这是不⾏的。

所以对于这种情况不要使用匿名结构体类型:

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

注:当然,上面这种情况,在进行自引用时也不能省略“struct”!

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

这也是不可行的。理由和使用匿名结构体类型时省略“struct”是一样的。

6. 结构体内存对齐

6.1 对齐规则

(1)结构体的第⼀个成员对齐到和结构体变量起始位置偏移量为0的地址处。

(2)其他成员变量要对⻬到某个数字(对⻬数)的整数倍的地址处。

         对齐数=编译器默认的⼀个对齐数与该成员变量大小的较小值。

         - VS中默认的值是8

         -Linux中gcc没有默认对齐数,对齐数就是成员自身的大小

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

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

看个示例:

       看到这串代码第一反应可能是这样思考的:char类型变量占一个字节,int类型变量占四个字节,那么输出结果应该是6,实则不然,我们用上文的对齐规则进行分析。

  • 对应规则(1),结构体中第一个成员为char c1,为char类型变量,占1个字节,则在内存中从起始点偏移量为0处开始存储,对应图中绿色块;
  • 对应规则(2),第二个成员为int i,为整型变量,占4个字节,与VS中默认对齐数8相比较,取较小值4,即对齐数为4,则存储位置应该为4的整数倍位置,随后按顺序存储四个字节,对应图中蓝色块;
  • 对应规则(2),第三个成员为char c3,为char类型变量,占1个字节,与VS中默认对齐数8相比较,取对齐数1,则存储位置为1的整数倍位置,对应图中红色块;
  • 对应规则(3),此例中最大对齐数为4,目前所占字节数为9,并不是4的整数倍,所以需要继续向下占用字节,直到所占字节为12,满足4的整数倍,则停止存储;
  • 这个例子中并不包含结构体嵌套,所以不考虑规则(4);
  • 最终得出答案,sizeof(struct S)=12  

接下来,举个例子来理解规则(4)

       规则其实与上雷同,只不过在这多了一点规则(4),在嵌套的成员2:struct S3 s3进行存储时,由于结构体S3成员中最大对齐数为8,所以存储位置要位于8的整数倍处,按顺序存储16个字节数(橙色块)(这是结构体S3的字节数大小),成员3按照规则(2)存储即可,所有成员存储结束后,规则(4)规定整个结构体的字节数大小要为最大对齐数的整数倍,此例中最大对齐数为8,而32满足8的整数倍要求;因此最终输出结果为:32。

注:若结构体包含数组亦或是更多层次的结构体嵌套都按照上述对齐规则进行存储。

6.2 为什么存在内存对齐

       从上述两个示例中可以看出,在存储结构体的过程中,出现了内存空间存在浪费的情况,那为什么还需要内存对齐这种做法呢?

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

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

总体来说:结构体的内存对齐是拿空间来换取时间的做法!        

重点解释一下原因2:

首先给一个示范代码

struct S
{
  char c;
  int i;
}
int main()
{
struct S s = {0};
}

在读取结构体中的两个数据时,假设一次性读取4个字节,则可以看到以下区别

  • 当采用内存对齐时,想要读取到 int i 的数据时,只需要读取一次,节省了时间。
  • 当不采用内存对齐时,想要读取到 int i 的数据时,需要读取两次。

       那在设计结构体的时候,我们既要满⾜对齐,⼜要节省空间。

       如何做到: 让占⽤空间⼩的成员尽量集中在⼀起 

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

S1 和 S2 类型的成员⼀模⼀样,但是 S1 和 S2 所占空间的大小有了⼀些区别。

6.3 修改默认对齐数

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

7. 结构体传参

struct S
{
	int arr[1000];
	int i;
	double n;
};
//直接传递
void print1(struct S tmp)
{
	int i = 0;
	for (i = 0; i < 5; i++)
	{
		printf("%d ",tmp.arr[i]);
	}
	printf("%d ", tmp.i);
	printf("%lf\n", tmp.n);
}
//结构体地址传参
void print2(const struct S* ps)
{
	int i = 0;
	for(i=0;i<5;i++)
	{
		printf("%d ", ps->arr[i]);
	}
	printf("%d ", ps->i);
	printf("%lf\n", ps->n);
}
int main()
{
	struct S s = { {1,2,3,4,5},100,3.14 };
	print1(s);
	print2(&s);
}

    print1函数和print2函数相比较,print2函数的效率会更高。

原因: 函数传参的时候,参数是需要压栈,会有时间和空间上的系统开销。 如果传递⼀个结构体对象的时候,结构体过⼤,参数压栈的的系统开销⽐较⼤,所以会导致性能的下降。

所以对结构体进行传参时,需要传结构体的地址。

8. 结构体实现位段

8.1 什么是位段?

       位段的声明和结构是类似的,有两个不同:

  • 位段的成员必须是 int、unsigned int 或signed int ,在C99中位段成员的类型也可以 选择其他类型。
  • 位段的成员名后边有⼀个冒号和⼀个数字。

下面的代码就是一个位段的例子:

//位段式的结构
struct A
{
 int _a:2;
 int _b:5;
 int _c:10;
 int _d:30;
};

       为了便于理解,可以想象一种场景:假设a中只需要存放0,1,2,3这四个数值的其中一个,而这四个数值的二进制表现分别为:00,01,10,说明只需要两个bite位即可存储这四个数值,而一个整型变量所占字节为4,共32个bite位,这时就存在bite位浪费的情况,所以位段就是为了避免这种浪费而创造出来的,冒号后面的数字即代表所占的bite位数。

总结:位段是专门设计出来用于节省内存的。

那么上面的位段到底占了多少内存呢?

从输出结果可以看出:相比于没有使用位段的结构体,使用位段明显节省了很大的内存空间。

8.2 位段的内存分配

1. 位段的成员可以是 int unsigned int signed int 或者是 char 等类型。

2. 位段的空间上是按照需要以4个字节( int )或者1个字节( char )的⽅式来开辟的。

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

先来看个示例

为什么这里输出结果为3个字节呢?

用通俗易懂的图解来解答这个问题:

注:此过程存在一些分歧:

  • 申请到的一块内存中,从左向右使用还是从右向左使用是不确定的,在VS中,采用的是从右向左。
  • 剩余的空间不足下一个成员使用的时候,是直接浪费还是直接使用呢?在VS中选择直接浪费。

综合图解以及分歧解释来看,输出结果是3个字节没错。

那么存储的数值为多少呢?

转换为16进制为0x620304;我们通过调试来进行验证:

       调试结果与我们的计算结果相同!这样我们就也能理解为什么第8章的第一个例子的输出结果为8了。

8.3 位段的跨平台问题

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

2. 位段中最大位的数目不能确定。(16位机器最大16,32位机器最⼤32,写成27,在16位机器会 出问题。

3. 位段中的成员在内存中从左向右分配,还是从右向左分配,标准尚未定义。

4. 当⼀个结构包含两个位段,第⼆个位段成员比较大,无法容纳于第⼀个位段剩余的位时,是舍弃 剩余的位还是利⽤,这是不确定的。

总结: 跟结构相比,位段可以达到同样的效果,并且可以很好的节省空间,但是有跨平台的问题存在。

8.3 使用位段的注意事项

       位段的几个成员共用同⼀个字节,这样有些成员的起始位置并不是某个字节的起始位置,那么这些位置处是没有地址的。内存中每个字节分配⼀个地址,⼀个字节内部的bit位是没有地址的。所以不能对位段的成员使用&操作符,这样就不能使⽤scanf直接给位段的成员输⼊值,只能是先输入放在⼀个变量中,然后赋值给位段的成员。

完结。

 

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐