C语言——结构体

发布于:2024-05-18 ⋅ 阅读:(70) ⋅ 点赞:(0)

引言

C语言已经提供了内置类型,例如:short、char、int、long、float、double等,显然,只有这些内置类型肯定是不够的。当我们想要记录一个学生的名字、年龄、成绩等信息,这个时候,单一的数据类型肯定是不够的。C语言为了解决这类问题,增加了一类数据类型,让我们根据自己的需求来自行创造合适的类型,也就是我们今天要学习的内容——结构体

结构体

什么是结构体

结构体是C语言中一种重要的复合数据类型,它允许我们将多个不同类型的数据项组合成一个单一的数据结构。通过结构体,我们可以将相关的数据聚合在一起,形成一个逻辑上的整体

结构体的使用

1.结构体的声明

结构体是一些值的集合,这些值被称为成员变量。结构体中的每一个成员可以是不同类型的变量,如:标量、数组、指针,甚至可以是其他结构体

struct tag {
    type1 member_name1; 
    type2 member_name2;   
    type3 member_name3; 
    ......
}variable_name;

tag是结构体标签,定义你需要的结构体名,如book,student等

type1 member_name是标准的变量定义,根据需求设定合适的变量类型和变量名

variable_name是结构变量,定义在结构的末尾,最后一个分号之前,我们可以指定一个或多个结构变量,也可以省略

【注】在定义结构体的时候,大括号最后一定要记得加分号:‘;’

2.结构体的定义方式

既然结构体是一种数据类型,那它就像C语言基础的内置类型一样,可以去定义变量,下面就举个简单的例子:

(1)先声明结构体类型,再声明变量
struct student 
{
	char name[20];
	int id;
	char sex;
	int age;
	int score;
};
struct Student stu;    //定义一个结构体变量
(2)直接声明结构体变量
struct Student 
{
	char name[20];  
	int id; 
	char sex; 
	int age; 
	int score;  
}stu;  
(3)嵌套结构体

在嵌套结构体中,一个结构体(外部结构体)可以包含另一个结构体(内部或嵌套结构体)作为其成员。这种结构体通常应用于一些比较高级的数据结构,例如链表

struct Student
{
	int id;
	char sex;
	int age;
	int score;
};

struct people
{
	char name[20];
	struct Student stu;
};

int main()
{
	struct people stu = { "zhangsan",114514,'M' ,18,60 };
	return 0;
}

我们可以通过监视来观察一下

(4)匿名结构体

在声明结构体的时候,可以不完全的声明

在C语言中,当我们在定义结构体变量时直接给出了结构体的成员,而没有为结构体类型本身指定一个名字,那么这个结构体就被称为匿名结构体

举个例子:

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

我们定义了一个结构体,并且直接创建了一个该类型的变量x。但是,由于我们并没有为这个结构体类型命名,所以它是一个匿名结构体。这意味着除了x之外,我们无法创建更多该类型的变量,除非我们再次定义整个结构体

因此,匿名结构体类型,如果我们没有对结构体类型重命名的话,基本上只能用一次,匿名结构体在定义它的作用域内是可见的,但在外部是不可见的

注:通常情况下,我们不使用匿名结构体

(5) typedef简化结构体

有的时候结构体的名称比较长,不利于我们阅读代码,这个时候我们可以用typedef对其进行简化

typedef struct Student
{
	char name[20];
    int id;
	char sex;
	int age;
	int score;
}stu;

这样子我们就可以用stu代替struct student

我们需要注意:如果在结构体内部引用了该结构体的类型(例如,在链表中),你需要首先声明该结构体,然后再使用 typedef 定义别名,编译器需要知道结构体的存在才能处理其中的成员

3.结构体的创建和初始化

简要介绍了结构体的定义,接下来我们来学习结构体的创建和初始化

struct Student
{
	int id;
	char sex;
	int age;
	int score;
};

struct people
{
	char name[20];
	struct Student stu;
};

int main()
{
	struct people stu = { "zhangsan",114514,'M' ,18,60 };
	return 0;
}
(1)直接访问

结构体成员的访问可以通过点操作符(.)进行访问操作

如下所示:

struct Point
{
	int x;
	int y;
	int z;
};
int main()
{
	struct Point p = { 1,2,3 };
	printf("x: %d y: %d z: %d\n", p.x, p.y, p.z);
	return 0;
}

输出结果为:

x: 1 y: 2 z: 3

(2)间接访问

除了通过(.)操作符直接访问,我们也可以通过结构体地址,利用(->)操作符间接访问

struct Point
{
	int x;
	int y;
	int z;
};
int main()
{
	struct Point p = { 1, 3, 4 };
	struct Point* ptr = &p;//结构体指针
	ptr->x = 5;
	ptr->y = 2;
	ptr->z = 1;
	printf("x = %d y = %d z= %d\n", ptr->x, ptr->y, ptr->z);
	return 0;
}

输出结果为:

x = 5 y = 2 z= 1

结构体内存对齐

介绍完结构体的基本内容,接下来我们来学习一下结构体大小的计算,即结构体内存对齐

1.结构体的内存对齐规则

我们先来看一段代码:

#include <stdio.h>
struct example
{
	int a;    //4个字节
	char b;   //1个字节
	float c;  //4个字节
}s;
int main()
{
	size_t sz = sizeof(s);
	printf("大小为%zu\n", sz);
	return 0;
}

输出结果为:

大小为12

如果结构体的大小只是简单地将各变量类型的字节数相加,输出结果应该为9才对,但是实际的输出结果为12,这是为什么?

这就涉及到今天我们需要学习的结构体内存对齐规则

C语言在分配结构体内存时遵循以下几条规则:

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

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

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

我使用的编译器为 VS2022,默认值为8

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

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

我们来通过图表的形式来看看上面那段代码中结构体是如何存放的:

2.为什么存在内存对齐规则

平台原因(移植原因):

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

性能原因:

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

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

那么,在我们设计结构体的时候,我们应当尽量满足对齐,又要节省空间

为了实现这一需求,我们尽量将占用空间小的结构体成员放在一块

3.宏——offsetof

offsetof是一个在 C 语言标准库 <stddef.h> 中定义的宏,它用于计算结构体(struct)或联合体(union)中某个成员相对于结构体或联合体首地址的偏移量(以字节为单位)。offsetof 宏通常用于底层编程、内存操作或者与特定硬件接口的代码

原型:offsetof (type,member)

这里的 type 是结构体或联合体的类型,而 member-designator 是该类型中某个成员的名称

#include <stdio.h>
#include <stddef.h>  

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

int main() {
    printf("Offset of c1: %zu\n", offsetof(struct example, c1));  // 输出: 0  
    printf("Offset of i : %zu\n", offsetof(struct example, i));   // 输出可能是 4(取决于平台和编译器)  
    printf("Offset of c2: %zu\n", offsetof(struct example, c2));  // 输出可能是 8(取决于平台和编译器)  
    return 0;
}

输出结果为:

Offset of c1: 0
Offset of i : 4
Offset of c2: 8

我们来看个图片,或许更容易理解一点

1.c1是char类型,占一个字节,根据内存对齐规则1,c1放在偏移量为0的位置

2.i为int类型,占四个字节,根据内存对齐规则2,默认对齐数为4,对齐到4的整数倍的位置,中间浪费3个字节,偏移量为4

3.c2是char类型,默认对齐数为1的整数倍,偏移量为8

4.最大对齐数为4,结构体大小为4的整数倍,因此这个结构体的大小为12

4.示例

(1)示例1
struct example1
{
	char c1;
	int i;
	char c2;
}s1;

struct example2
{
	char c1;
	char c2;
	int i;
}s2;

int main()
{
	printf("%zu\n", sizeof(s1));
	printf("%zu\n", sizeof(s2));
	return 0;
}

输出结果为:

12
8

解析:

1.c1是char类型,占一个字节,根据内存对齐规则1,c1放在偏移量为0的位置

2.i为int类型,占四个字节,根据内存对齐规则2,默认对齐数为4,对齐到4的整数倍的位置,中间浪费3个字节,偏移量为4

3.c2是char类型,默认对齐数为1的整数倍,偏移量为8

4.最大对齐数为4,结构体大小为4的整数倍,因此这个结构体的大小为12

1.c1是char类型,占一个字节,根据内存对齐规则1,c1放在偏移量为0的位置

2.c2是char类型,默认对齐数为1的整数倍,偏移量为1

3.i为int类型,占四个字节,根据内存对齐规则2,默认对齐数为4,对齐到4的整数倍的位置,中间浪费3个字节,偏移量为4

4.最大对齐数为4,结构体大小为4的整数倍,因此这个结构体的大小为8

(2)示例2
struct example3
{
	double d;
	char c;
	int i;
}s3;

int main()
{
	printf("%zu\n", sizeof(s3));
	return 0;
}

输出结果为:

16

解析:

1.d是double类型,占八个字节,根据内存对齐规则1,d放在偏移量为0的位置

2.c是char类型,默认对齐数为1的整数倍,偏移量为1

3.i为int类型,占四个字节,根据内存对齐规则2,默认对齐数为4,对齐到4的整数倍的位置,中间浪费3个字节,偏移量为4

4.最大对齐数为8,结构体大小为8的整数倍,因此这个结构体的大小为16

(3)示例3
struct example3
{
	double d;
	char c;
	int i;
}s3;

struct example4
{
	struct example3 s3;
	char c1;
	double d;
}s4;

int main()
{
	printf("%zu\n", sizeof(s4));
	return 0;
}

输出结果为:

32

解析:

  1. 结构体S3的大小为16,放在偏移量为0的位置
  2. c1的默认对齐数为1,放在1的整数倍16的位置,大小为1
  3. d的默认对齐数为8,放在1的整数倍24的位置,大小为8
  4. 最大对齐数为8,结构体大小为8的整数倍,大小为32

5.修改默认对齐数

#pragma这个预处理指令,可以改变编译器的默认对齐数

#pragma pack(1)	//设置默认对齐数为1
struct example5
{
	char c1;
	int i;
	char c2;
}s5;

int main()
{
	printf("%zu\n", sizeof(s5));
	return 0;
}

输出结果为:

6

由于我们设置默认对齐数为1,因此结构体成员在内存中是连续储存的,这时结构体大小等于每个结构体成员大小之和

结构体传参

函数传参分为两种,一种是直接传参:直接传变量;一种是间接传参:通过传变量地址间接访问

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

上面的print1和print2函数选择哪一个会比较好?

答案是:选择print2会比较好

原因是:传参时形参是实参的临时拷贝,也就是说系统会将参数复制一份,当一个参数的数量极大时会造成不必要的内存分配,而传址调用系统只用分配四个字节或八个字节,大大节约内存

位段

什么是位段

位段(或称“位域”)是C语言特有的数据结构,它允许开发者以位为单位来指定结构体成员所占的内存长度。利用位段,我们可以用较少的位数来存储数据,这在需要节省存储空间或处理特定控制信息时非常有用

1.位段的成员必须是int、unsigned int或者signed int,在C99中位段成员的类型也可以是选择其他类型

2.位段的成员名后面有一个冒号和一个数字

例如:

struct A
{
	int _a : 1;
	int _b : 2;
	int _c : 3;
	int _d : 4;
};

位段的几个成员有可能会共用一个字节,这样会导致有些成员的起始位置并不是在某个字节的起始位置,那么这些位置是没有地址的。内存中每个字节分配一个地址,一个字节内部的比特位是没有地址的

因此我们不能对位段的成员使用&操作符,这样就不能使用scanf直接给位段成员赋值,只能是先输入放在一个变量中,再赋值给位段的成员

struct A
{
	int _a : 1;
	int _b : 2;
	int _c : 3;
	int _d : 4;
};

int main()
{
	struct A sa = { 0 };
	scanf("%d", &sa._b);	//错误的示范

	int b = 0;
	scanf("%d", &b);
	sa._b = b;
	return 0;
}

位段的内存分配

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

2.位段的空间是按照需要以4个字节(int)或者是1个字节(char)的方式进行开辟的

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

我们来看个例子:

struct A
{
	int _a : 2;
	int _b : 5;
	int _c : 10;
	int _d : 30;
};
int main()
{
	printf("大小为%zd\n", sizeof(struct A));//输出什么?
	return 0;
}

输出结果为:

8

2+5+10+30=47,而输出结果为8,即为64个比特位,可以看到,位段的节省空间的能力还是有限的

接下来我们来探讨一下位段的分配:

当一个结构体包含两个位段,第二个位段比较大,无法容纳于第一个位段剩余的位时, 是舍弃剩余的位还是利用呢?

我们来试着用一个具体的示例去演示一下:

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;
	return 0;
}

我们假设:位段分配的内存中的比特位是从右向左使用的,分配剩余的比特位不够使用时,浪费掉剩余内存
则:

1.我们先定义位段,如图所示:

2.执行程序,将设定的值存入

3.将值转换:

4.最后我们来验证一下我们的猜测:

通过验证我们可以知道在VS2022环境下我们的猜想:位段分配的内存中的比特位是从右向左使用的,分配剩余的比特位不够使用时,浪费掉剩余内存,是正确的

位段的跨平台问题

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

2.位段中最大位的数目是不确定的

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

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

总结:

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

结束语

这篇文章简单的介绍了一下结构体和位段,希望看到这篇文章的友友们能点赞收藏加关注

十分感谢!!!


网站公告

今日签到

点亮在社区的每一天
去签到