C语言自定义类型——结构体详解

为什么要有结构体变量?

在C语言中,我们可以使用内置类型如char、short、int、long、float、double等来表示数据,但有时仅仅使用这些内置类型是不够的。举个例子,如果我们想描述一个学生,我们需要记录他的姓名、年龄、学号、身高、体重等信息;如果我们想描述一本书,我们需要记录它的作者、出版社、定价等信息。为了解决这个问题,C语言引入了结构体这种自定义的数据类型,为程序员提供了创造适合自己需求的类型的能力。

结构体允许我们将多个不同类型的数据组合在一起,形成一个更复杂的数据结构。通过结构体,我们可以将相关的数据视为一个整体进行管理,并且可以通过结构体变量来访问和操作这些数据。例如,我们可以创建一个名为"学生"的结构体类型,包含姓名、年龄、学号、身高、体重等成员变量。然后,我们可以声明一个学生类型的结构体变量,并为其成员赋值,如"张三,18岁,学号为001,身高175cm,体重65kg"。这样,我们就能够方便地存储和操作学生的信息了。

通过使用结构体,我们可以更好地组织和管理复杂的数据,使程序更加清晰和易于理解。结构体的引入使得C语言的数据类型更加灵活,程序员可以根据自己的需求定义适合的结构体类型,从而更好地满足实际编程的需要。

什么是结构体变量?

结构体是一种数据类型,它可以组合多个值作为成员变量。每个成员变量可以是不同类型的变量,例如标量(单个值)、数组、指针,甚至可以是其他结构体。

结构体可以看作是一个容器,用于存储相关数据的集合。每个成员变量可以有自己的数据类型和名称,代表不同的信息。例如,我们可以创建一个名为"学生"的结构体,其中包含姓名(字符串类型)、年龄(整数类型)、成绩(浮点数类型)等成员变量。这样,我们就可以将学生的姓名、年龄和成绩作为一个整体进行管理。

在结构体中,每个成员变量可以具有不同的数据类型,这使得结构体非常灵活。我们可以在结构体中使用标量类型来表示单个值,如整数或浮点数;我们也可以使用数组来表示一组值,如存储学生的多门课程成绩;我们甚至可以在结构体中使用指针,指向其他的数据或对象。

通过使用结构体,我们可以更好地组织和管理复杂的数据结构,使程序更加清晰和易于理解。结构体的灵活性使得我们可以根据实际需求定义自己的数据类型,以便更好地表示和处理问题中的数据。

结构体类型的申明

结构体申明的伪代码如下:

struct tag
{
    member-list
};variable-list

其中的member-list就是结构体的成员名

variable-list就是结构体的名字

tag是结构体的标签,类似于int,char,short这样的,是一种类型名。

比如我需要创建一个学生的结构体变量,包含他的学号,姓名,年龄,身高,体重,就可以像下面这样创建:

#define _CRT_SECURE_NO_WARNINGS 1

#include <stdio.h>

int main()
{
	struct Student
	{
		char name;
		char id;
		int age;
		float height;
		float weight;
	};
	return 0;
}

通过创建一个学生的结构体变量,我们可以方便地存储和操作学生的各种信息。这个结构体变量包含了多个成员变量,每个成员变量代表了学生的不同属性。这种方式使得我们可以更加方便地访问和操作学生的信息。

使用结构体变量,我们可以将学生的姓名、年龄、成绩等信息聚合在一起,以便统一管理。这样,我们就能够更加方便地获取和修改学生的各种属性。例如,我们可以通过结构体变量来获取学生的姓名,或者更新学生的成绩。

结构体的使用使得我们能够更加灵活地处理复杂的数据结构,提高了程序的可读性和可维护性。通过创建结构体变量,我们可以轻松地组织和操作学生的信息,使得程序的逻辑更加清晰和易于理解。

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

#include <stdio.h>
struct Stu
{
 char name[20];//名字
 int age;//年龄
 char sex[5];//性别
 char id[20];//学号
};
int main()
{
 //按照结构体成员的顺序初始化
 struct Stu s = { "张三", 20, "男", "20230818001" };
 printf("name: %s\n", s.name);
 printf("age : %d\n", s.age);
 printf("sex : %s\n", s.sex);
 printf("id : %s\n", s.id);
 
 //按照指定的顺序初始化
 struct Stu s2 = { .age = 18, .name = "lisi", .id = "20230818002", .sex = "⼥
 printf("name: %s\n", s2.name);
 printf("age : %d\n", s2.age);
 printf("sex : %s\n", s2.sex);
 printf("id : %s\n", s2.id);
 return 0;
}

上述代码展示了结构体变量的两种初始化方式。在第一种方式中,如果不标注具体的成员名,那么初始化的顺序应该按照结构体定义中成员的顺序进行,且类型和顺序不能出错。而在第二种方式中,通过明确指定成员名进行初始化,我们可以任意更改成员的顺序。使用"."操作符可以方便地访问对应的成员变量。

这样的初始化方式使得我们可以根据实际需求来选择最适合的方式进行结构体变量的初始化。无论是按照顺序初始化还是通过成员名初始化,我们都能够方便地为结构体变量赋值和访问成员变量。

通过合理的初始化,我们可以确保结构体变量在创建后具有正确的初始值,从而提高程序的可靠性和可读性。同时,使用"."操作符可以轻松地访问结构体中的不同成员变量,使得代码更加清晰和易于理解。

结构体成员访问操作符

早前在对结构体进行初始化的过程中,我们已接触并使用了结构体成员访问的基础操作符——“.”,通过“结构体实例.成员名”这种形式,可以便捷地访问结构体内部某一特定类型的成员变量。

但当我们将结构体以指针形式传递给函数时,则需采用另一种访问结构体成员的操作符,即“->”。其具体应用方式为:


结构体指针变量->成员名
 

此操作符同样用于访问指向结构体的指针所指向的结构体成员,它是“.”操作符在指针环境下的扩展和替代,极大地丰富了结构体在函数调用及内存操作中的灵活性和便利性。

#define _CRT_SECURE_NO_WARNINGS 1

#include <stdio.h>

struct S 
{
	int data[1000];
	int num;
};

struct S s = { {1,2,3,4},100 };

void print2(struct S* s)
{
	printf("%d\n", s->num);
}

int main()
{
	
	print1(s);
	print2(&s);
	return 0;
}

这个代码中的print2函数就是一个结构体指针,在对其进行访问的时候需要用到->操作符 

结构体的特殊申明

在申明结构体的时候,可以进行不完全申明,例如:

#include <stdio.h>

int main()
{
	struct 
	{
		char name;
		char id;
		int age;
		float height;
		float weight;
	}x;
	return 0;
}

在前面的描述中,我们可能遗漏了对结构体的tag(标签)进行申明。结构体的tag是可选的,它允许我们为结构体类型起一个名称,以便后续使用。

结构体的不完全申明是指在声明结构体变量时,只提供结构体的tag而不提供具体的成员变量。这样的声明允许我们在后续的代码中引用这个结构体类型,但无法直接访问或操作其成员变量。

不完全申明的结构体通常用于定义结构体指针或函数的参数或返回类型。通过这种方式,我们可以在不暴露结构体的具体实现细节的情况下,使用结构体的指针或引用。

例如,我们可以声明一个不完全申明的结构体作为函数的参数,然后在函数的实现中通过结构体指针来访问和操作结构体的成员变量。这种方式有助于实现代码的模块化和封装。

需要注意的是,不完全申明的结构体只能在后续的代码中使用,而不能直接创建该结构体类型的变量。如果我们想要直接创建结构体变量并访问其成员变量,我们需要提供完整的结构体申明。

总之,结构体的不完全申明允许我们在后续的代码中引用结构体类型,但无法直接访问其成员变量,常用于定义结构体指针或函数的参数或返回类型。

struct
{
 int a;
 char b;
 float c;
}a[20], * p;

p=&x;

这段代码无法正常运行。原因在于,编译器会将这两种结构体识别为不同的数据类型,因此,由于两侧的类型不匹配,它们之间无法执行赋值运算。

结构体的自引用

结构体自引用错误示范

如果我们想在结构体里面再包含一个结构体,可以怎么做呢?

我们看看如下代码行不行:

struct Node
{
	int a;
	struct Node next;
};

这种方式并不支持结构体的自引用实现。

实际上,编译器会对此给出错误提示,指出不完整类型无法使用。

这样做会导致结构体的大小被视为无限大,从而无法正确计算和分配内存空间。

结构体自引用的正确示范

如果想对结构体进行自引用,正确的方法应该是这样的:

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

在C语言中,结构体可以通过指针成员实现自引用,如上述`struct Node`所示,其中包含一个指向自身类型的指针成员`next`。这是因为:

1. 循环引用问题:如果直接在结构体内嵌套定义完整的自引用结构体,将会导致无限递归,使得编译器无法计算结构体的大小。

2. 动态链接:通过指针成员来引用其他同类结构体实例,可以在运行时根据需要动态地创建和链接多个节点,形成链表、树等复杂数据结构,而无需预先知道其整体大小。

3. 节省空间:使用指针而非直接嵌套整个结构体,可以极大地减少存储空间的需求。每个结构体实例仅需存储必要的信息以及下一个实例的地址,而不是存储所有后续实例的所有信息。

结构体内存对齐

结构体的大小如何计算?

我们来看看结构体的内存大小是如何计算的,看如下代码:

struct Stu 
{
	int a;
	char c;
	short b;
}s;
printf("%zd", sizeof(s));

理论上讲,该结构体变量的大小应由其各成员大小相加得出,即4字节(int型a)加上1字节(char型b)再加上2字节(short型c),总计应当为7字节。然而实际情况并非如此,实际占用的空间大小被编译器处理为了8字节。

之所以出现这种情况,是因为计算机系统中的内存对齐机制在起作用。内存对齐是为了优化CPU访问内存的效率,通常要求结构体的每个成员在内存中的起始位置必须是对齐到特定字节数(通常是其自身的大小或更大)的倍数。在此例中,由于处理器对齐的要求,结构体可能会在其末尾添加额外的填充字节,确保其整体大小是特定字节数的倍数,故最终大小为8字节。

下面我们就来好好学习结构体的内存对齐吧,这是应该非常重要的知识,是必须掌握的。

如何内存对齐?

对齐规则

首先,理解结构体的内存对齐规则至关重要:

1. 结构体的首个成员总是从结构体变量起始地址开始,其偏移量为0,确保与结构体的起始位置精确对齐。

2. 结构体内的后续成员变量,其存放地址需要按照特定的对齐规则进行调整,即对齐到特定数值(称为对齐数)的整数倍地址上。对齐数的计算方法是取编译器默认对齐数与当前成员变量大小的较小值。

   - 在Visual Studio(VS)环境中,默认的对齐数为8字节。
   - 而在Linux环境下,使用gcc编译器时,没有预设的全局默认对齐数,此时的对齐数通常就是成员变量本身的大小。

3. 结构体的总大小将会是一个特定的整数倍,这个整数倍是由结构体内部所有成员变量的对齐数中最大的那个决定的。这意味着结构体的实际大小不仅要容纳所有成员变量,还要确保整体尺寸符合最大对齐数的要求。

4. 当结构体中嵌套了其他结构体时,嵌套的结构体会依据其内部成员的最大对齐数来进行对齐,确保其起始地址位于相应对齐倍数的位置上。在这种情况下,外层结构体的整体大小不仅要考虑其直接成员的最大对齐数,还包括嵌套结构体及其内部成员的对齐需求。因此,整个结构体的最终大小将是包括内外所有成员在内的所有最大对齐数的整数倍。

内存对齐的意义?

内存对齐的存在主要基于以下两点核心理由:

1. 平台兼容性和硬件限制:
   不同硬件平台有不同的内存访问特性,部分平台只允许在特定地址边界上访问特定大小的数据,若违反这种要求,硬件可能会触发异常或导致数据读取不准确。为了确保程序能够在多种不同的处理器架构上正确执行,内存对齐成为必要,它确保了数据在内存中的布局满足各个目标平台的硬件访问约束。

2. 性能优化:
   对于处理器而言,内存访问的效率往往与其内部的缓存机制和数据总线宽度有关。为了最大化内存访问速度,处理器倾向于每次操作加载或存储固定大小的数据块,如8字节或更多。若数据未对齐至其自然边界(例如double类型数据应在8字节边界上对齐),处理器可能不得不执行两次内存访问来完整获取或更新该数据。通过内存对齐,可以确保单次内存操作就能完成数据读写,显著提高了数据访问的性能。

内存对齐是一种空间换时间的设计策略,在牺牲一定内存空间的基础上,换取了程序运行时更高的执行效率和跨平台兼容性。通过合理安排数据在内存中的布局,确保了处理器高效、准确地访问内存中的数据结构。

结构体如何进行内存对齐

#define _CRT_SECURE_NO_WARNINGS 1

#include <stdio.h>

int main()
{
	struct S1
	{
		char c1;
		int i;
		char c2;
	};
	printf("%zd", sizeof(struct S1));
	return 0;
}

我们来看看这个例子,我们应该如何计算他的内存大小呢?

 

根据对齐规则,首先从地址的起始位置开始,偏移量为0,我们把char c1放入内存中去,大小为一个字节 

其次,我们知道下个类型是int,大小是四个字节,那么根据对齐规则,VS中默认对齐数是8,我们就应该取4为对齐数,也就是说我们需要找到一个地址偏移量是4的倍数才能存放这个int类型的变量

之后我们看到第三个类型是char c2,一个字节,与默认对齐数8相比,我们取1个字节为对齐数,也就是地址偏移量是1的倍数就可以存放这个变量了

最后,我们把刚刚所有取出的对齐数拿出来看,1,4,1,我们取最大的4为对齐数,最终的偏移量应该为这个对齐数的整数倍

偏移量12为4的倍数,那么最终的结构体大小也就是12,运行这个程序,我们发现的确如此。

 

结构体内存对齐练习 

接下来我们再来几个练习,看看是不是真正掌握了结构体的内存对齐的知识。

struct S2
{
 char c1;
 char c2;
 int i;
};
printf("%d\n", sizeof(struct S2));
struct S3
{
 double d;
 char c;
 int i;
};
printf("%d\n", sizeof(struct S3));

struct S4
{
 char c1;
 struct S3 s3;
 double d;
};
printf("%d\n", sizeof(struct S4));

我们可以发现,结构体具有一定空间上的浪费,那么我们如何在既满足结构体对齐的原则下又满足小内存的目标呢?

答案就是让空间小的成员尽量放在一起,例如刚刚的结构体S1,我们可以这样改写:

int main()
{
	struct S1
	{
		char c1;
		char c2;
		int i;
	};
	printf("%zd", sizeof(struct S1));
	return 0;
}

在大型项目开发过程中,尤其面对内存管理与资源优化的关键环节,我们可以考虑将两个较小的char类型变量紧凑地排列在同一个结构体内,如此一来,该结构体所占用的内存空间就能精简至8个字节。此举看似细微,实则意义重大,因为在大规模的数据处理场景下,每一处对空间的有效节约都可能累积成显著的性能提升和资源利用效率的优化。

修改默认对齐数

在VS中,默认对齐数是8,那么我们可不可以修改这个默认对齐数呢?当然可以,修改方式如下:

#pragma pack(1)//设置默认对⻬数为1
struct S
{
	char c1;
	int i;
	char c2;
};
#pragma pack()//取消设置的对⻬数,还原为默认
int main()
{
	//输出的结果是什么?
	printf("%d\n", sizeof(struct S));
	return 0;
}

此时这个结构体的内存大小就变成了6,还是必刚刚的12要小很多

值得注意的是,Linux的gcc是不支持修改默认对齐数的 

结构体传参

#define _CRT_SECURE_NO_WARNINGS 1

#include <stdio.h>

struct S 
{
	int data[1000];
	int num;
};

struct S s = { {1,2,3,4},100 };

void print1(struct S s)
{
	printf("%d\n", s.num);
}

void print2(struct S* s)
{
	printf("%d\n", s->num);
}

int main()
{
	
	print1(s);
	print2(&s);
	return 0;
}

上述代码中有print1和print2两个函数用来打印结构体中的值,不过我们建议用print2的方法打印结构体变量的值

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

结构体实现位段

什么是位段

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

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

2. 位段的成员名后边有⼀个冒号和⼀个数字。 比如:

struct A
{
	int a : 1;
	int b : 2;
	int c : 8;
};

这里的结构体A就是一个位段 

位段的空间是如何开辟的?

位段(Bitfield)是一种数据类型,在C语言中使用。它允许我们在一个字节(8位)或一个更大的整型中访问和操作单个位或一组位。

位段的空间开辟是由编译器自动处理的。编译器会根据位段的声明来分配足够的内存空间来存储这些位。编译器通常会将位段紧凑地存储在一个字节(8位)或更大的整型变量中,并按照声明的顺序进行排列。

位段的声明包括字段的名称和字段的位宽。位宽指定了每个字段在位段中占用的位数。例如:


struct {
    unsigned int field1 : 3;
    unsigned int field2 : 1;
    unsigned int field3 : 4;
} myStruct;

在上面的例子中,`field1`占用3位,`field2`占用1位,`field3`占用4位。编译器会根据位宽自动为每个字段分配足够的位数,并将它们连续地存储在一个字节或更大的整型变量中。

注意,位段的空间开辟非常依赖于编译器的实现和机器的架构。在不同的编译器和平台上,位段可能有不同的存储方式和对齐规则。因此,在使用位段时,需要注意平台的兼容性和可移植性。

为什么需要位段

位段的跨平台问题

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

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

3.位段中的成员是从左往右还是从右往左分配是不确定的

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

位段的使用

下图是网络协议中,IP数据报的格式,我们可以看到其中很多的属性只需要几个bit位就能描述,这里使用位段,能够实现想要的效果,也节省了空间,这样网络传输的数据报大小也会比较小一些,对网络的畅通是有帮助的。

位段使用的注意事项

位段的几个成员共同有一个字节,这样一些成员的起始位置并不是某个字节的起始位置,那么这些位置处是没有地址的。内存中每个字节分配一个地址,一个字节内部的bit位是没有字节的。

所以不能对位段的成员使用&操作符,这样就不能使用scanf函数直接给位段的成员输入值,只能是先输入放在一个变量当中,然后赋值给位段成员。gjug'ju

  • 26
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

阿梦Anmory

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值