前言
C语言是一门广泛应用于系统编程和嵌入式领域的编程语言,其灵活的数据结构是其强大之处之一。在本篇博客中,我们将深入探讨C语言中的结构体、联合体和枚举类型,了解它们的声明、特性以及在实际编程中的应用。
I.结构体
1.结构体类型的声明
在C语言中,结构体是一种用户自定义的数据类型,用于组合不同类型的数据成员。结构体的声明定义了这个数据类型的结构,包括成员的名称和类型。
1.1结构的声明
结构的声明形式如下:
struct Person
{
char name[50];
int age;
float height;
};
上述例子中,我们定义了一个名为 Person
的结构体,它包含了姓名、年龄和身高三个成员。
1.2结构体变量的创建和初始化
结构体声明后,我们可以创建结构体变量并对其进行初始化:
struct Person person1; // 创建结构体变量
person1.age = 25; // 初始化成员
strcpy(person1.name, "John Doe");
person1.height = 175.5;
这样,我们就成功创建了一个名为 person1
的结构体变量,并初始化了其中的成员。
1.3结构体特殊声明(匿名结构体)
匿名结构体是一种在声明结构体变量时省略结构体名的方式,直接定义结构体的实例。这样的声明方式通常用于只需要在一个地方使用的简单结构体。以下是匿名结构体的详细介绍:
匿名结构体的声明方式如下:
struct
{
int x;
int y;
} point;
上述例子中,我们创建了一个匿名结构体变量 point
,该结构体包含了 x
和 y
两个成员。
匿名结构体成员的访问和赋值方式与普通结构体一样:
point.x = 10;
point.y = 20;
printf("Point coordinates: (%d, %d)\n", point.x, point.y);
通过点操作符(.
),我们可以访问并修改匿名结构体中的成员。
并且,匿名结构体可以作为函数参数,用于传递简单的数据结构:
void printCoordinates(struct {
int x;
int y;
} p) //不要看走眼哦!结构体变量是函数的参数
{
printf("Point coordinates: (%d, %d)\n", p.x, p.y);
}
// 调用函数并传递匿名结构体变量
printCoordinates(point);
通过这种方式,我们可以在函数中直接使用匿名结构体,而无需为其定义具体的类型。
匿名结构体通常用于临时的、不需要在多个地方重复使用的数据结构。它简化了代码结构,特别适用于一次性的、局部的数据组织。但是,需要注意的是,因为声明匿名结构体时省略了结构体标签,因此如果没有对结构体重命名的话(使用typedef),只能用一次(即不能用这个结构体类型创建新变量)。
1.4结构体的自引用
结构体的自引用指的是结构体内部包含指向相同类型结构体的指针,从而形成了结构体自身的引用关系。这种特性常用于构建包含递归关系的数据结构,例如链表、树等。
考虑一个简单的链表节点的例子:
struct Node
{
int data;
struct Node* next; // 指向相同类型的指针,形成自引用
};
在这个例子中,Node
结构体包含一个整型数据成员 data
和一个指向相同类型结构体的指针成员 next
。这样,每个节点都能够指向下一个节点,形成了链表的结构。
关于链表的知识,我将在以后的博客中进行讲解。大家可以参照其他博主的讲解或自行查阅。
通过结构体的自引用,C语言可以方便地实现包含自身引用的数据结构,为复杂问题提供了一种清晰的解决方案。
2.结构体内存对齐
在C语言中,结构体内存对齐是为了优化内存存储,提高程序运行效率。在这一节,我们将详细介绍结构体内存对齐的规则、为什么需要对齐以及如何修改默认对齐数。
2.1对齐规则
1.结构体的第一个成员对齐到和结构体变量起始位置偏移量为0的地址处;
2.其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
注意:对齐数=min{编译器默认对齐数,该成员变量大小},即两者的较小值。VS2022的默认对齐数是8,而Linux中gcc没有默认对齐数,即对齐数是成员变量自身大小。
3.结构体总大小为最大对齐数(结构体中每个成员变量都有一个对齐数,所有对齐数中最大的)整数倍。
#include <stdio.h>
int main()
{
struct E1
{
char c1;
int i;
char c2;
};
struct E2
{
char c1;
char c2;
int i;
};
struct E3
{
double d;
char c;
int i;
};
struct E4
{
char c1;
struct E3 e3;
double d;
};
printf("%zd\n", sizeof(struct E1)); // 12
printf("%zd\n", sizeof(struct E2)); // 8
printf("%zd\n", sizeof(struct E3)); // 16
printf("%zd\n", sizeof(struct E4)); // 32
return 0;
}
我们首先对E1进行解释:
c1
是char
类型,占用 1 字节。i
是int
类型,通常占用 4 字节。c2
是char
类型,占用 1 字节。
由于默认情况下,结构体成员按照其本身大小对齐,但整个结构体的大小要按照最大的成员进行对齐,所以这里 E1
结构体的大小是按照最大成员 int
的大小进行对齐,为 4 字节。因此,E1
结构体的大小为 1 (c1) + 4 (i) + 1 (c2) = 6 字节。由于默认对齐数是4字节,因此实际占用内存为 12 字节。
d
是double
类型,通常占用 8 字节。c
是char
类型,占用 1 字节。i
是int
类型,通常占用 4 字节。
整个结构体按照最大成员 double
的大小进行对齐,因此 E3
结构体的大小为 8 (d) + 1 (c) + 4 (i) = 13 字节。由于默认对齐数是8字节,因此实际占用内存为 16 字节。
那么E4呢?
c1
是char
类型,占用 1 字节。e3
是包含double
、char
和int
的结构体,根据上述规则,大小为 16 字节。d
是double
类型,通常占用 8 字节。
整个结构体按照最大成员 double
的大小进行对齐,因此 E4
结构体的大小为 1 (c1) + 16 (e3) + 8 (d) = 25 字节。由于默认对齐数是8字节,因此实际占用内存为 32 字节。
请看图:
是不是一目了然呢?是不是茅塞顿开了呢?
2.2为什么要对齐
结构体内存对齐的目的主要有两方面:
-
提高访问速度: 符合对齐要求的数据可以更快地被CPU访问。当数据按照对齐规则存储时,CPU可以更高效地读取整个数据块,而不需要多次读取多个不对齐的部分。
-
满足硬件要求: 某些硬件平台对于数据的对齐有特殊要求。不符合硬件对齐要求的结构体可能导致性能下降或错误。
2.3修改默认对齐数
在C语言中,可以使用 #pragma pack
指令来修改默认的对齐数。这个指令的使用方式如下:
#pragma pack(n)
其中,n
是对齐数,通常为1、2、4、8等。这个指令告诉编译器按照指定的对齐数对齐结构体。但需要注意,修改对齐数可能导致不同平台上的兼容性问题,因此谨慎使用。
#pragma pack(1) // 将对齐数设置为1字节
struct Example {
char a; // 1字节
int b; // 4字节
double c; // 8字节
};
#pragma pack() // 恢复默认对齐数
此时,sizeof(struct Example)的值为13.
在这个例子中,由于设置了对齐数为1字节,结构体中的各成员将按照1字节对齐,不再考虑默认的对齐规则。使用 #pragma pack
需要慎重,确保了解修改对齐数可能带来的影响。
3.结构体传参
在C语言中,将结构体作为函数参数传递是一种常见的操作。主要有两种方式:传递结构体本身和传递结构体的地址。让我们深入研究这两种方式,并讨论它们的优劣之处。
3.1 传递结构体本身
#include <stdio.h>
struct Person
{
char name[50];
int age;
float height;
};
void printPerson(struct Person p)
{
printf("Name: %s\n", p.name);
printf("Age: %d\n", p.age);
printf("Height: %.2f\n", p.height);
}
int main()
{
struct Person john = {"John Doe", 25, 175.5};
printPerson(john);
return 0;
}
在这个例子中,printPerson
函数接受一个 struct Person
类型的参数。当调用函数时,会将结构体的内容复制到函数的局部变量中。这种方式的优点是函数内对结构体的修改不会影响原始结构体。
3.2 传递结构体地址
#include <stdio.h>
struct Person
{
char name[50];
int age;
float height;
};
void printPerson(struct Person *p)
{
printf("Name: %s\n", p->name);
printf("Age: %d\n", p->age);
printf("Height: %.2f\n", p->height);
}
int main()
{
struct Person john = {"John Doe", 25, 175.5};
printPerson(&john);
return 0;
}
3.3 选择传参方式的考虑因素
选择传参方式通常取决于以下几个因素:
-
结构体大小: 如果结构体较小,直接传递结构体本身可能更加方便和简洁。但对于大型结构体,传递地址通常更为高效,避免了复制的开销。
-
修改需求: 如果函数需要修改原始结构体的内容,使用传递地址的方式更为合适,因为这允许在函数内对原始结构体进行更改。
-
一致性: 在整个代码库中保持一致的传参方式有助于提高代码的可读性和维护性。选择一种方式并在整个项目中保持一致性可能是个好主意。
在实际应用中,选择哪种方式通常取决于特定情境和需求。如果结构体较小且不需要在函数内修改,传递结构体本身可能更为合适。对于大型结构体或需要在函数内修改的情况,传递结构体地址更为高效。在选择时需要综合考虑代码的可读性和性能。
4.结构体实现位段
位段(Bit Fields)是C语言中的一种特性,允许在一个字节(或更多字节)中按位分配不同的字段。这使得在特定位数上存储多个数据成员成为可能。在这一节,我们将详细讨论位段的定义、内存分配、跨平台问题、应用场景以及使用时需要注意的事项。
4.1位段是什么
位段是用来表示一个整数中的几个二进制位的C语言特性。通过位段,我们可以将一个整数分割为多个字段,每个字段可以占用不同数量的二进制位。
注意:位段的成员必须是int,unsigned int或者signed int,而在C99标准中位段成员也可以选择其他类型。此外,与结构体成员声明不同的是:位段成员名后面有一个冒号和一个数字。
我们看下面的一个例子:
struct A
{
int _a : 2;
int _b : 5;
int _c : 10;
int _d : 30;
};
那么它占用的内存是多少字节呢?
4.2位段的内存分配
首先请注意:位段涉及不确定因素较多,并且不跨平台即在不同平台上实现位段的具体方式可能有所差异,因此以下举例为可能的一般方式,并不通用。
4.2.1 位段的定义和使用
struct Flags {
unsigned int flag1 : 1; // 占用1位
unsigned int flag2 : 2; // 占用2位
unsigned int flag3 : 3; // 占用3位
};
在上述例子中,Flags
结构体定义了三个位段 flag1
、flag2
和 flag3
,它们分别占用了1位、2位和3位。
4.2.2 内存分配的规则
-
按顺序分配: 位段的内存分配通常按照在结构体中的声明顺序进行,即从左到右,从上到下。
-
位段大小: 每个位段的大小由其定义时指定的位数决定。例如,
flag1
是1位,flag2
是2位,flag3
是3位。 -
按字节对齐: 位段的分配通常按照字节对齐的规则进行。如果某个位段不能在当前字节中完全容纳,会跨越到下一个字节,形成字节对齐。
4.2.3 内存示例
考虑上述定义的 Flags
结构体,假设字节大小为8位(1字节),则内存分配可能如下所示:
Byte 1:
[flag1]
Byte 2:
[flag2 | flag2]
Byte 3:
[flag3 | flag3 | flag3]
在这个示例中,flag1
占用了第一个字节的第一位,flag2
跨越了第二个字节的前两位,而 flag3
跨越了第三个字节的前三位。
以VS2022为例:
4.2.4 字节对齐的注意事项
-
字节对齐规则: 位段的字节对齐通常受到编译器和平台的影响。默认情况下,编译器会尽量使结构体按照平台的自然对齐方式进行排列,以提高访问速度。
-
字节对齐的填充: 为了满足字节对齐的要求,编译器可能会在位段之间插入一些填充比特。这样可以确保每个位段都按照正确的边界对齐。
-
字节对齐的影响: 字节对齐的变化可能导致结构体整体的大小增加,需要注意结构体的大小可能不是位段大小的简单相加。
总体而言,位段的内存分配受到编译器和平台的影响,开发者在使用位段时需要注意字节对齐的规则,并根据实际需要和平台要求进行合理的设计。在涉及到跨平台开发时,尤其需要小心处理位段的内存分配以确保代码的正确性和可移植性。
4.3位段的跨平台问题
在使用位段时,由于不同平台上的字节顺序(大小端)、对齐规则和符号性质的不同,可能会引发一些跨平台问题。以下是一些可能涉及的问题:
1. int
位段的符号性质
C标准没有规定int
位段的符号性质,因此它可能被当成有符号数或无符号数,这取决于编译器和平台。这意味着如果代码在一个平台上正常工作,但在另一个平台上可能会有不同的行为。
2. 位段中最大位的数量不确定
由于C标准对于位段的实现并没有详细规定,因此位段中的最大位数也是不确定的。在不同平台上,可能会有不同的最大位数限制。比如,如果在一个32位机器上定义了32位的位段,但在16位机器上编译,可能会导致问题。
3. 位段中的成员分配顺序不确定
C标准没有明确规定位段中的成员在内存中的分配顺序,是从左向右分配还是从右向左分配。因此,如果在代码中依赖于特定的位段成员顺序,可能会导致在不同平台上的不一致性。
struct Example {
unsigned int a : 4;
unsigned int b : 4;
};
4. 位段的溢出问题
当一个结构包含两个位段,第二个位段成员较大,不能容纳于第一个位段剩余的位时,C标准没有规定是舍弃剩余的位还是利用这些剩余的位。因此,这种情况下的行为是不确定的。
struct Example {
unsigned int a : 4;
unsigned int b : 8; // 可能导致不确定行为
};
4.4位段的应用
在网络编程中,IP数据报是网络通信中的基本数据单元,其中包含了与网络相关的各种信息。使用位段可以帮助更有效地存储和解析IP数据报头部中的各个字段。
考虑IPv4数据报头,它包含了多个字段,如版本号、首部长度、服务类型、总长度等。使用位段可以更清晰地表示这些字段。
#include <stdint.h>
struct IPv4Header {
uint8_t version : 4; // IPv4版本号
uint8_t headerLength : 4; // IPv4首部长度
uint8_t dscp : 6; // 服务类型中的区分服务代码点
uint8_t ecn : 2; // 服务类型中的显式拥塞通告
uint16_t totalLength; // 总长度
// 其他字段...
};
在这个例子中,IPv4Header
结构体使用了位段来表示IPv4头部的各个字段。这有助于更清晰地理解和使用IPv4头部信息。
使用位段的优势包括:
-
空间节省: IPv4头部包含多个字段,使用位段可以更有效地利用每个字节,减小数据报头部的大小。
-
易读性: 通过使用位段,可以直观地看到每个字段占用的二进制位数,提高了结构的可读性。
-
方便解析: 位段的使用使得解析数据报头变得更加方便,可以直接访问特定字段而无需手动进行位运算。
然而,需要注意的是,由于跨平台问题,应确保结构体的定义在不同平台上的一致性。此外,位段在一些特殊情况下可能会引发一些不确定性,因此在设计时需要谨慎考虑。
4.5位段使用的注意事项
使用位段时,有一些注意事项需要开发者考虑,以确保代码的正确性和可移植性。以下是一些常见的位段使用注意事项:
1. 不能对位段成员取地址
由于位段成员的位数和起始位置是不确定的,C标准规定不能对位段成员取地址。因此,尝试使用取地址运算符 &
对位段成员取地址会导致编译错误。
struct Example
{
unsigned int flag : 1;
};
int main()
{
struct Example example;
unsigned int *ptr = &example.flag; // 错误,不能对位段成员取地址
return 0;
}
2. 不能直接用 scanf
对位段赋值
使用 scanf
直接对位段进行赋值可能会导致不确定的行为,因为 scanf
通常期望一个地址,而位段成员不能被取地址。应该使用中间变量来接受输入,然后将其赋值给位段成员。
struct Example
{
unsigned int flag : 1;
};
int main()
{
struct Example example;
int input;
scanf("%d", &input);
example.flag = input; // 正确,将输入值赋给位段成员
return 0;
}
II.联合体
5.联合体类型的声明
在C语言中,联合体(Union)是一种特殊的数据类型,允许在同一内存位置存储不同类型的数据。联合体的声明形式如下:
union UnionName
{
// 成员列表
};
其中,UnionName
是联合体的名称,而成员列表定义了联合体中的各个成员。
我们看以下代码:
#include <stdio.h>
//联合类型的声明
union Un
{
char c;
int i;
};
int main()
{
//联合变量的定义
union Un un = { 0 };
//计算union变量的大小
printf("union Un 占用%d字节内存\n", sizeof(un));
return 0;
}
Un变量占多少内存呢?
6.联合体的特点
联合体具有以下特点:
-
共享内存空间: 联合体的所有成员共享相同的内存空间。因此,联合体的大小取决于最大成员的大小。
-
只能同时存储一个成员: 联合体同一时间只能存储一个成员的值。修改一个成员会影响其他成员的值。
-
节省内存: 联合体的设计允许多个成员共享同一块内存,因此在某个时刻只使用其中一个成员的值,节省了内存空间。
我们不妨看以下两段代码:
//代码1
#include <stdio.h>
//联合类型的声明
union Un
{
char c;
int i;
};
int main()
{
//联合变量的定义
union Un un = { 0 };
// 下⾯输出的结果是一样的吗?
printf("%p\n", &(un.i));
printf("%p\n", &(un.c));
printf("%p\n", &un);
return 0;
}
以上运行结果说明联合体中所有元素地址均相同,侧面反映了联合体的所有成员共享相同的内存空间。
//代码2
#include<stdio.h>
//联合类型的声明
union Un
{
char c;
int i;
};
int main()
{
//联合变量的定义
union Un un = { 0 };
un.i = 0x11223344;
un.c = 0x55;
printf("%x\n", un.i);
return 0;
}
以上结果说明 un.c=0x55; 将 i 的第4个字节内容改为55(16进制)了。
分析过后,我们便可以得到联合体un的内存布局:
7.成员相同的联合体与结构体对比
对比结构体和联合体,它们都可以包含多个成员,但有一些关键区别:
-
结构体: 每个成员都有独立的内存空间,结构体的大小等于所有成员大小的总和,成员之间不共享内存。
-
联合体: 所有成员共享同一块内存空间,联合体的大小等于最大成员的大小。
struct S
{
char c;
int i;
};
struct S s = {0};
union Un
{
char c;
int i;
};
union Un un = {0};
它们的内存布局如下:
8.联合体大小的计算
计算联合体大小是确保内存分配正确的关键步骤。联合体的大小取决于联合体中最大成员的大小,但需要考虑平台的字节对齐规则。以下是计算联合体大小的详细过程:
8.1 成员的大小
首先,计算联合体中每个成员的大小。成员的大小是由其数据类型决定的,例如,int
通常是4字节,double
通常是8字节。
union ExampleUnion {
int a; // 4字节
double b; // 8字节
char c; // 1字节
};
8.2 最大成员的大小
找到联合体中所有成员的大小中的最大值。这个最大值将决定联合体的总大小。
在上面的例子中,double
类型的成员 b
是最大的,大小为8字节。
8.3 考虑字节对齐
计算联合体大小时,需要考虑平台的字节对齐规则。字节对齐是为了优化访问内存的速度,确保数据存储在内存中的地址是对齐的。
通常,成员的地址应该是其大小的整数倍。如果成员的大小超过平台的自然对齐边界,编译器会在成员之后添加填充字节。
8.4 计算总大小
将最大成员的大小与字节对齐规则结合,计算联合体的总大小。总大小通常是最大成员大小的倍数,以确保成员的对齐。
在上面的例子中,假设平台的字节对齐规则是8字节对齐,联合体的大小为8字节。
union ExampleUnion {
int a; // 4字节
double b; // 8字节
char c; // 1字节
};
// 假设平台字节对齐规则是8字节
// 联合体的总大小是8字节
注意事项:
- 考虑不同平台的字节对齐规则,因为它们可能有所不同。
- 成员的顺序可能影响填充字节的位置和数量。
- 对于复杂的联合体,可能需要逐个成员进行计算。
III.枚举
9.枚举类型的声明
在C语言中,枚举类型(Enum)是一种用户定义的数据类型,用于表示具名整数常量的集合。枚举类型的声明形式如下:
enum EnumName
{
// 枚举常量列表
};
其中,EnumName
是枚举类型的名称,而枚举常量列表是用于定义枚举类型的具体常量的地方。
示例:
enum Weekday {
SUNDAY, // 0
MONDAY, // 1
TUESDAY, // 2
WEDNESDAY, // 3
THURSDAY, // 4
FRIDAY, // 5
SATURDAY // 6
};
在这个例子中,Weekday
是枚举类型的名称,而 SUNDAY
、MONDAY
等是具体的枚举常量。
10.枚举类型的优点
10.1 增强代码可读性
枚举类型提供了对整数常量的命名,使代码更具有可读性。通过使用有意义的标识符,开发者可以更容易理解代码中的常量表示的含义。
enum Status {
SUCCESS,
FAILURE
};
// 使用
enum Status result = SUCCESS;
10.2 防止使用无效值
枚举类型限制了变量的取值范围,只允许使用枚举常量。这有助于避免使用无效的整数值,提高了代码的健壮性。
enum Color {
RED,
GREEN,
BLUE
};
// 正确使用
enum Color myColor = RED;
// 避免使用无效值
// int invalidValue = 42; // 编译错误
10.3 易于维护和修改
如果需要修改整数常量的取值,只需在枚举类型的声明中进行修改,而不必在整个代码库中查找和替换所有使用该常量的地方。
enum Month {
JANUARY = 1,
FEBRUARY,
MARCH,
// ...
};
10.4相对于#define
宏定义的好处
- 使用枚举类型提供了类型安全性,因为枚举常量在编译时被明确定义为整数。相比之下,
#define
宏定义只是简单地进行文本替换,可能导致类型错误。 - 枚举类型不容易发生重定义错误,而
#define
宏定义可能会由于名称冲突而导致编译错误。 - 在调试过程中,枚举类型能够提供更有意义的变量名称,使得调试信息更加清晰,帮助开发者更容易理解代码。
- 使用枚举类型可以更容易地扩展代码,添加新的常量而不影响现有的代码。相比之下,
#define
宏定义可能会引起命名空间的问题,增加了代码维护的复杂性。
11.枚举类型的使用
11.1 使用枚举常量
在代码中使用枚举常量,提高了代码的可读性。
enum Month {
JANUARY,
FEBRUARY,
// ...
};
enum Month currentMonth = JANUARY;
// 使用枚举常量
if (currentMonth == JANUARY) {
// Do something
}
11.2 指定枚举常量的值
可以为枚举常量显式指定值,从而控制其对应的整数值。
enum Weekday {
SUNDAY = 1,
MONDAY,//2
TUESDAY,//3
// ...
};
11.3 Switch语句中的应用
枚举类型常常与 switch
语句一起使用,使代码更加清晰和易于理解。
enum State {
IDLE,
RUNNING,
STOPPED
};
enum State currentState = RUNNING;
switch (currentState) {
case IDLE:
// Do something when idle
break;
case RUNNING:
// Do something when running
break;
case STOPPED:
// Do something when stopped
break;
default:
// Handle default case
break;
}
结语
通过本篇博客,我相信大家将对C语言中的结构体、联合体和枚举类型有更深入的了解。这些高级数据结构为程序员提供了更多灵活性和表达能力,帮助你更好地设计和组织代码。希望这些知识能够在大家的C语言编程之旅中发挥作用,使大家的代码更加高效、可维护。