C语言【进阶篇】之结构体 —— 从基础声明到复杂应用的进阶之路

目录

🚀前言

大家好!我是 EnigmaCoder。本文收录于我的专栏 C,感谢您的支持!

  • 在C语言编程体系里,结构体是整合不同类型数据的重要工具,它能够将多个相关数据组合为一个有机整体,显著提升数据处理的效率与便捷性。无论是小型代码项目,还是大型复杂系统开发,结构体都占据着关键地位。深入掌握结构体知识,不仅有助于提升编程技能,还能优化代码质量,使其更高效、易维护。接下来,让我们全面且深入地探讨C语言结构体的各个方面,从基础声明到内存对齐、传参方式,再到特殊的位段实现。

✍️结构体类型的声明

💯结构体定义

结构体是不同类型数据的集合体,这些组成数据被称为成员变量,每个成员的类型可以各不相同。定义结构体时,需要明确结构体标签(tag)和成员列表。例如,定义一个描述学生信息的结构体:

c 复制代码
// 定义名为Stu的结构体,用于存储学生相关信息
struct Stu {
    char name[20]; // 用于存储学生姓名,最多可容纳20个字符
    int age; // 存储学生年龄
    char sex[5]; // 存储学生性别,最多5个字符
    char id[20]; // 存储学生学号,最多20个字符
};

在这个结构体中,struct是定义结构体的关键字,Stu作为结构体标签方便后续引用,nameagesexid是不同类型的成员变量,分别描述学生的不同属性。

💯结构的特殊声明

匿名结构体在声明时不设置结构体标签,这种结构体若不重命名,通常仅能使用一次。因为编译器会将不同的匿名结构体声明视作不同类型,例如:

c 复制代码
// 定义一个匿名结构体,并创建变量x
struct {
    int a; // 成员a,类型为int
    char b; // 成员b,类型为char
    float c; // 成员c,类型为float
}x;

// 定义另一个匿名结构体,创建数组a和指针p
struct {
    int a; // 成员a,类型为int
    char b; // 成员b,类型为char
    float c; // 成员c,类型为float
}a[20], *p;
// p = &x; 该行代码非法,编译器将两个匿名结构体视为不同类型

上述代码中,虽然两个匿名结构体成员相同,但由于缺少标签,编译器将它们识别为不同类型,导致p = &x;赋值操作不被允许。

🦜结构的自引用

在结构体内部直接包含同类型结构体变量会导致结构体大小无限递归,这种做法不合理。正确的自引用方式是使用指针。以链表节点结构体定义为例:

c 复制代码
// 定义链表节点结构体Node
struct Node {
    int data; // 存储节点数据
    struct Node* next; // 指向下一个节点的指针
};

Node结构体中,next成员是指向struct Node类型的指针,通过它可构建链表结构。若使用typedef对匿名结构体重命名时,要避免在结构体内部提前使用重命名后的类型,如下代码是错误的:

c 复制代码
// 错误示例:在匿名结构体内部提前使用未定义的Node类型
typedef struct {
    int data; // 成员data,类型为int
    Node* next; // 此处使用Node类型错误,因为Node还未定义
}Node;

正确的做法是:

c 复制代码
// 正确定义结构体并使用typedef重命名
typedef struct Node {
    int data; // 成员data,类型为int
    struct Node* next; // 指向下一个节点的指针
}Node;

先定义带标签的结构体,再使用typedef重命名,可避免上述错误。

💻结构体内存对齐

💯对齐规则

  • 结构体的第一个成员在内存中的起始地址与结构体变量的起始地址重合,偏移量为0
  • 后续成员变量需对齐到特定数字(对齐数)的整数倍地址处。对齐数是编译器默认对齐数和该成员变量大小两者中的较小值。在VS编译器中,默认对齐数为8;而Linuxgcc编译器没有默认对齐数,对齐数就是成员自身大小。
  • 结构体的总大小必须是所有成员对齐数中的最大值的整数倍
  • 当结构体中嵌套其他结构体时,嵌套的结构体成员要对齐到其自身成员最大对齐数的整数倍位置,整个结构体的大小则是所有最大对齐数(包含嵌套结构体中成员的对齐数)的整数倍。

通过以下练习加深理解:

c 复制代码
// 练习1:计算结构体S1的大小
struct S1 {
    char c1; // 第一个成员,占1字节
    int i; // 第二个成员,在VS中对齐数为4(默认8与4的较小值),需对齐到4的倍数地址
    char c2; // 第三个成员,对齐数为1,占1字节
};
// 在VS中,S1的大小为8字节(1 + 3(填充)+ 4 + 1)
printf("%d\n", sizeof(struct S1)); 

// 练习2:计算结构体S2的大小
struct S2 {
    char c1; // 第一个成员,占1字节
    char c2; // 第二个成员,对齐数为1,占1字节
    int i; // 第三个成员,对齐数为4,需对齐到4的倍数地址
};
// 在VS中,S2的大小为8字节(1 + 1 + 2(填充)+ 4)
printf("%d\n", sizeof(struct S2)); 

// 练习3:计算结构体S3的大小
struct S3 {
    double d; // 第一个成员,占8字节,对齐数为8
    char c; // 第二个成员,对齐数为1,占1字节
    int i; // 第三个成员,对齐数为4,需对齐到4的倍数地址
};
// 在VS中,S3的大小为16字节(8 + 1 + 3(填充)+ 4)
printf("%d\n", sizeof(struct S3)); 

// 练习4:结构体嵌套问题,计算结构体S4的大小
struct S4 {
    char c1; // 第一个成员,占1字节
    struct S3 s3; // 嵌套结构体成员,S3中最大对齐数为8,s3需对齐到8的倍数地址
    double d; // 第三个成员,对齐数为8
};
// 在VS中,S4的大小为32字节(1 + 7(填充)+ 16 + 8)
printf("%d\n", sizeof(struct S4)); 

在这些练习中,根据对齐规则分析每个结构体成员的存储位置和填充字节情况,从而准确计算出结构体的大小。

💯为什么存在内存对齐

内存对齐主要基于平台和性能两方面考虑:

  • 平台原因:并非所有硬件平台都能访问任意内存地址上的任意数据。部分硬件平台对数据的访问地址有限制,若访问未对齐的数据,可能引发硬件异常。例如,某些硬件要求特定类型数据必须存储在特定地址边界上,否则无法正常读取或写入数据。
  • 性能原因:数据结构(尤其是栈)在自然边界上对齐,能提升访问效率。访问未对齐内存时,处理器可能需要进行多次内存访问操作;而对齐的内存访问仅需一次。例如,若处理器每次从内存读取8个字节数据,数据地址必须是8的倍数,才能一次完成读写操作。若数据未对齐,可能需分两次访问不同的8字节内存块,降低了系统性能。

结构体内存对齐本质上是用空间换取时间的策略。在设计结构体时,将占用空间小的成员集中放置,有助于节省内存空间。例如:

c 复制代码
// 对比S1和S2结构体,成员相同但顺序不同
struct S1 {
    char c1; // 占1字节
    int i; // 对齐数为4,需对齐到4的倍数地址
    char c2; // 占1字节
};

struct S2 {
    char c1; // 占1字节
    char c2; // 占1字节
    int i; // 对齐数为4,需对齐到4的倍数地址
};
// S1在VS中大小为8字节,S2在VS中大小为8字节,但S2布局更节省空间

在这个例子中,S1S2结构体成员相同,但S2将两个char类型成员放在一起,使int成员对齐时无需额外填充字节,从而在一定程度上节省了内存。

💯修改默认对齐数

使用#pragma pack()预处理指令可改变编译器的默认对齐数。例如,#pragma pack(1)将默认对齐数设为1,之后使用#pragma pack()可取消设置,恢复默认对齐数。示例如下:

c 复制代码
#include <stdio.h>
// 将默认对齐数设置为1
#pragma pack(1) 
struct S {
    char c1; // 占1字节
    int i; // 占4字节
    char c2; // 占1字节
};
// 取消设置的对齐数,还原为默认
#pragma pack() 

int main() {
    // 输出结果为6,因为设置对齐数为1后,不再有填充字节
    printf("%d\n", sizeof(struct S)); 
    return 0;
}

在上述代码中,通过#pragma pack(1)设置对齐数为1,结构体成员紧密排列,无填充字节,所以struct S的大小为1 + 4 + 1 = 6字节。取消设置后,后续结构体定义将恢复默认对齐规则。

🐍结构体传参

传递结构体对象时,如果结构体规模较大,参数压栈会带来较大的系统开销,进而降低性能。因此,结构体传参时优先选择传递结构体地址。例如:

c 复制代码
// 定义结构体S
struct S {
    int data[1000]; // 包含1000个int类型元素的数组
    int num; // 一个int类型的成员
};

// 定义函数print1,参数为结构体S的对象
void print1(struct S s) {
    // 输出结构体成员num的值
    printf("%d\n", s.num); 
}

// 定义函数print2,参数为结构体S的指针
void print2(struct S* ps) {
    // 通过指针访问结构体成员num并输出其值
    printf("%d\n", ps->num); 
}

int main() {
    // 初始化结构体S的对象s
    struct S s = {{1,2,3,4}, 1000}; 
    // 调用print1函数,传递结构体对象
    print1(s); 
    // 调用print2函数,传递结构体地址
    print2(&s); 
    return 0;
}

在这段代码中,print1函数传递结构体对象,函数调用时会将整个结构体内容复制到函数栈帧,对于大型结构体,复制操作耗时耗空间。而print2函数传递结构体地址,仅需将一个指针值压栈,系统开销小,性能更优。

🐧结构体实现位段

🤔什么是位段

位段的声明与结构体类似,但有两个显著区别:一是位段成员类型通常为intunsigned intsigned int(C99标准支持更多类型);二是成员名后会紧跟一个冒号和一个数字,用于指定该成员占用的二进制位数。例如:

c 复制代码
// 定义一个名为A的位段类型
struct A {
    int _a:2; // 成员_a,占用2位
    int _b:5; // 成员_b,占用5位
    int _c:10; // 成员_c,占用10位
    int _d:30; // 成员_d,占用30位
};

struct A中,_a_b_c_d是位段成员,冒号后的数字表示它们各自占用的位数,通过这种方式可在有限的内存空间内紧凑存储多个小数据。

💯位段的内存分配

位段成员类型多样,内存空间按4字节(int类型)或1字节(char类型)的方式开辟。不过,位段存在诸多不确定因素,不具备良好的跨平台性。示例如下:

c 复制代码
// 定义一个位段结构体S
struct S {
    char a:3; // 成员a,占用3位
    char b:4; // 成员b,占用4位
    char c:5; // 成员c,占用5位
    char d:4; // 成员d,占用4位
};
// 初始化位段结构体S的对象s
struct S s = {0}; 
// 给位段成员赋值
s.a = 10; 
s.b = 12; 
s.c = 3; 
s.d = 4; 

在上述代码中,struct S是位段结构体,s是其对象。初始化时所有位段成员为0,后续分别赋值。由于位段按位存储,赋值时需注意数值范围不能超出位段允许的最大值。

💯位段的跨平台问题

位段在跨平台使用时存在诸多问题,主要体现在以下方面:

  1. int位段在不同平台上可能被解释为有符号数或无符号数,缺乏一致性,导致程序行为不可预测。
  2. 不同平台支持的位段最大位数不同,16位机器和32位机器的最大位数限制不同,若代码中指定的位数超出目标平台限制,会引发错误。
  3. 位段成员在内存中的分配方向(从左向右或从右向左)没有统一标准,不同平台实现方式不同,增加了程序的不确定性。
  4. 当结构体包含多个位段,且后一个位段成员无法完全容纳在前一个位段剩余空间时,是舍弃剩余位还是利用,不同平台处理方式不同。

鉴于这些跨平台问题,注重可移植性的程序应谨慎使用位段。

💯位段的应用

在网络协议的IP数据报格式中,许多属性仅需几个二进制位就能描述,此时使用位段既能实现功能需求,又能节省内存空间,减少网络传输的数据量,提高网络传输效率。例如:

c 复制代码
// 模拟IP数据报部分位段
struct IPHeader {
    unsigned int version:4; // 4位版本号
    unsigned int tos:8; // 8位服务类型
    unsigned int total_length:16; // 16位总长度
    // 其他位段成员可继续添加
};

IPHeader结构体中,利用位段定义IP数据报的部分属性,version用4位表示版本号,tos用8位表示服务类型,total_length用16位表示总长度,有效节省内存,方便网络数据处理。

💯位段使用的注意事项

位段的部分成员起始位置可能并非字节的起始位置,这部分位置没有内存地址。由于内存按字节分配地址,字节内部的二进制位没有独立地址,因此不能对位段成员使用取地址操作符&,也就无法使用scanf直接给位段成员输入值。正确做法是先将输入值存储到普通变量中,再赋值给位段成员。例如:

c 复制代码
// 定义位段结构体A
struct A {
    int _a:2; // 成员_a,占用2位
    int _b:5; // 成员_b,占用5位
    int _c:10; // 成员_c,占用10位
    int _d:30; // 成员_d,占用30位
};

int main() {
    // 初始化位段结构体A的对象sa
    struct A sa = {0}; 
    // scanf("%d", &sa._b); 该行代码错误,不能对位段成员使用&操作符
    // 正确的示范
    int b = 0; 
    // 先将输入值存储到变量b
    scanf("%d", &b); 
    // 再将b的值赋给位段成员_b
    sa._b = b; 
    return 0;
}

🌟总结

C语言结构体涵盖了丰富的知识,从基础的类型声明、变量初始化,到内存对齐优化、传参方式选择,再到特殊的位段应用,每个环节都有其独特要点和应用场景。在实际编程中,应根据具体需求合理运用这些特性。比如在处理大量数据时,精心设计结构体布局,利用内存对齐提高性能;在频繁调用函数传递结构体时,选择传地址方式减少开销。同时,要充分考虑位段的跨平台问题,在对可移植性要求高的项目中谨慎使用。通过深入理解和灵活运用结构体知识,能编写出更高效、可靠的代码,在C语言编程道路上不断进阶。

相关推荐
EnigmaCoder13 分钟前
蓝桥杯刷题周计划(第二周)
学习·算法·蓝桥杯
森焱森16 分钟前
AArch64架构及其编译器
linux·c语言·单片机·架构
程高兴28 分钟前
中性点不接地系统单相接地故障Matlab仿真
开发语言·matlab
AI很强33 分钟前
matlab常见的配图代码实现1
开发语言·算法·matlab
鲤籽鲲1 小时前
C# Enumerable类 之 数据排序
开发语言·c#·c# 知识捡漏
*.✧屠苏隐遥(ノ◕ヮ◕)ノ*.✧1 小时前
C语言_数据结构总结6:链式栈
c语言·开发语言·数据结构·算法·链表·visualstudio·visual studio
IT猿手1 小时前
2025最新群智能优化算法:云漂移优化(Cloud Drift Optimization,CDO)算法求解23个经典函数测试集,MATLAB
开发语言·数据库·算法·数学建模·matlab·机器人
至暗时刻darkest1 小时前
go mod文件 项目版本管理
开发语言·后端·golang
银河小铁骑plus1 小时前
Go学习笔记:基础语法6
笔记·学习·golang
sakoba2 小时前
spring IOC(实现原理)
java·开发语言