C语言提供了丰富的基本数据类型,但在处理复杂对象时往往力不从心。例如,描述一个学生需要组合姓名(字符串)、年龄(整型)、成绩(浮点型)等多种属性。为此,C语言赋予了程序员自定义类型的能力,主要包括结构体、枚举和联合。掌握这些工具,能帮助你更好地组织数据,编写出更高效、更易维护的代码。
一、结构体
1. 结构体概念
结构体本质上是一个值的集合,这些值称为成员变量。每个成员可以是不同类型的变量。你可以把它想象成一个“容器”,能将不同类型的数据打包成一个逻辑整体。
1.1 结构体的声明
基本语法:
struct tag
{
member-list; // 成员列表
}variable-list; // 变量列表(可选)
示例:描述学生
struct Stu
{
char name[20]; // 名字
int age; // 年龄
char sex[5]; // 性别
char id[20]; // 学号
};
使用typedef简化(推荐):
typedef struct Stu
{
char name[20];
int age;
char sex[5];
char id[20];
}Stu; // Stu 是 struct Stu 的别名
int main()
{
Stu s1; // 直接使用别名定义变量
return 0;
}

1.2 结构成员的类型
结构体的成员可以是标量、数组、指针,甚至是其他结构体。
1.3 特殊的声明:匿名结构体
匿名结构体在声明时未提供标签,只能使用一次。
struct // 匿名结构体
{
int a;
char b;
}x;
// struct {int a; char b;} y;
// p = &x; // 错误:编译器认为这是两个不同的类型

1.4 结构的自引用
结构体内部包含一个指向自身类型的指针,常用于实现链表、树等数据结构。
错误方式:
struct Node
{
int data;
struct Node next; // 错误!会导致无限递归
};
正确方式:
struct Node
{
int data;
struct Node* next; // 使用指针
};

1.5 结构体变量的定义和初始化
struct Point
{
int x;
int y;
}p1 = {10, 15}; // 声明时定义并初始化
struct Point p2 = {20, 30}; // 单独定义并初始化
// C99支持指定初始化器
struct Stu s = {.age = 20, .name = "lisi"};

2. 结构体成员访问
2.1 结构体变量访问成员
使用点操作符.访问。
struct Stu s;
strcpy(s.name, "zhangsan");
s.age = 20;
printf("name = %s, age = %d\n", s.name, s.age);
2.2 结构体指针访问成员
使用箭头操作符->访问。
void print(struct Stu* ps)
{
printf("name = %s\n", ps->name); // 推荐方式
printf("name = %s\n", (*ps).name); // 等价方式
}

3. 结构体传参
结构体传参应优先传递地址,以避免复制大块数据带来的性能开销。
struct S { int data[1000]; int num; };
// 低效:传值,复制整个结构体
void print1(struct S s) { printf("%d\n", s.num); }
// 高效:传址,只传递指针
void print2(struct S* ps) { printf("%d\n", ps->num); }
int main()
{
struct S s = {{0}, 1000};
print2(&s); // 推荐
return 0;
}

4. 结构体内存对齐
计算结构体大小是常见考点,必须掌握对齐规则。
4.1 对齐规则
- 第一个成员在偏移量为0的地址处。
- 其他成员要对齐到对齐数(成员大小与编译器默认对齐数较小者)的整数倍地址。
- 结构体总大小为最大对齐数的整数倍。
- 嵌套的结构体对齐到自己成员的最大对齐数的整数倍处。
4.2 对齐规则示例
struct S1
{
char c1; // 对齐数=1, 地址0
int i; // 对齐数=4, 地址4-7 (1-3填充)
char c2; // 对齐数=1, 地址8
}; // 总大小=12 (对齐到最大对齐数4的倍数)
struct S2
{
char c1; // 地址0
char c2; // 地址1
int i; // 地址4-7 (2-3填充)
}; // 总大小=8

优化成员顺序可以节省空间。
4.3 offsetof宏
用于计算结构体成员相对于起始位置的偏移量。
#include <stddef.h>
struct S1 { char c1; int i; char c2; };
printf("i的偏移量:%zu\n", offsetof(struct S1, i)); // 输出 4
4.4 为什么存在内存对齐?
主要是性能原因。访问未对齐的内存,处理器可能需要两次内存访问,而对齐的内存仅需一次。这是一种以空间换时间的策略,也涉及不同硬件平台的可移植性。
4.5 如何节省空间?
让占用空间小的成员尽量集中在一起。 对比S1和S2,仅调整了char成员的顺序,大小就从12字节变为8字节。
4.6 修改默认对齐数
使用#pragma预处理指令。
#pragma pack(1) // 设置对齐数为1
struct S { char c; double d; }; // 大小变为9
#pragma pack() // 恢复默认

二、位段
1. 位段基本概念
位段的声明类似结构体,但成员必须是整型家族,且成员名后需指明所占的比特位数。设计初衷是节省空间。
struct A
{
int _a:2; // 占2个bit
int _b:5; // 占5个bit
int _c:10;// 占10个bit
int _d:30;// 占30个bit
};
printf("%zu\n", sizeof(struct A)); // 输出 8 (2个int)
2. 位段的内存分配
位段的空间按需以int或char为单位开辟,其内存分配细节(如分配方向、剩余位利用)由编译器决定,因此不跨平台。
struct S
{
char a:3;
char b:4;
char c:5;
char d:4;
};
// 在VS下,可能占用3个字节
3. 位段的跨平台问题
int位段被视为有符号还是无符号不确定。
- 位段最大位数由机器字长决定(16/32/64位)。
- 内存分配方向(从左向右或从右向左)标准未定义。
- 空间不足时是舍弃剩余位还是利用不确定。
4. 位段的应用
适用于对空间要求苛刻且无需跨平台的场景,如网络协议头部定义、硬件寄存器映射。
// 简化的IP头部结构
struct IPHeader {
unsigned int version:4;
unsigned int header_len:4;
unsigned int total_len:16;
// ...
};

三、枚举
枚举将可能的取值一一列举,提高代码可读性。
1. 枚举类型的定义
enum Color // 颜色
{
RED, // 默认为0
GREEN, // 默认为1
BLUE // 默认为2
};
enum Day { Mon=1, Tues, Wed, Thur=10, Fri }; // 可自定义值
2. 枚举的优点
相比#define:
- 增加可读性和可维护性:
RED比1更有意义。
- 有类型检查,更严谨。
- 防止命名污染。
- 便于调试。
- 一次定义多个常量,方便。
3. 枚举的使用
enum Color clr = GREEN; // 推荐使用枚举常量赋值
// clr = 5; // C语言中合法,但不推荐

四、联合(共用体)
1. 联合类型的定义
联合的成员共享同一块内存空间,因此联合大小至少是最大成员的大小。
union Un
{
char c;
int i;
};
printf("%zu\n", sizeof(union Un)); // 输出 4
printf("%p\n%p\n", &(un.i), &(un.c)); // 两个地址相同
2. 联合的特点
所有成员从同一地址开始,修改一个成员会影响其他成员。
2.1 利用联合判断大小端
此特性常用于网络编程中判断主机字节序。
int check_endian()
{
union Un { char c; int i; } u;
u.i = 1; // 十六进制 0x00000001
return u.c == 1; // 若低地址存1则为小端
}

3. 联合大小的计算
- 大小至少是最大成员的大小。
- 当最大成员大小不是最大对齐数的整数倍时,要向上对齐。
union Un1 { char c[5]; int i; }; // 大小=8 (5对齐到4的倍数?不,最大对齐数是4,但总大小需是4的倍数,且能容纳c[5],所以是8)
union Un2 { short c[7]; int i; }; // 大小=16 (14对齐到4的倍数)
总结
自定义类型是C语言构建复杂数据模型的基石。
- 结构体:组织异类数据。注意内存对齐以优化空间和性能,传参时传地址。
- 位段:极致节省空间,但牺牲了可移植性,慎用。
- 枚举:提高代码可读性,替代魔数。
- 联合:多种解释共享同一内存,可用于类型转换、节省空间或判断字节序。
掌握这些类型的特性和适用场景,能够让你在系统编程、嵌入式开发等领域更加得心应手。理解内存布局是关键,多实践、多思考才能真正融会贯通。