C 语言自定义类型:结构体、位段、枚举与联合体
C 语言的基本类型能够描述一个数字、一个字符或一个地址,但真实程序处理的往往是更完整的对象:学生有姓名和年龄,链表节点有数据和下一个节点,文件状态有一组有限的选项,同一块内存又可能在不同场景下表示不同类型的值。
结构体、位段、枚举和联合体正是用来描述这些场景的工具。它们都属于自定义类型,但解决的问题不同:
- 结构体把多个成员组合成一个整体。
- 位段把整数中的若干比特分配给不同字段。
- 枚举给有限的离散取值起上有意义的名字。
- 联合体让多个成员共享同一块内存。
理解这四类类型时,要同时关注声明语法、内存布局、生命周期和可移植性。
一、结构体:把相关数据组织起来
结构体是一组成员的集合,每个成员可以使用不同的类型:
c
struct Student {
char name[20];
int age;
char gender[8];
char id[20];
};
上面的声明创建了一个名为 struct Student 的类型。它描述了学生对象的组成方式,但还没有创建具体变量:
c
struct Student student;
结构体声明结尾的分号不能省略。也可以在声明类型的同时定义变量:
c
struct Point {
int x;
int y;
} point1;
struct Point point2;
point1 与 point2 都是 struct Point 类型的对象,只是定义时机不同。
使用 typedef 简化类型名称
如果不希望每次都写 struct Student,可以定义类型别名:
c
typedef struct Student {
char name[20];
int age;
} Student;
Student student;
这里的 Student 是别名,struct Student 仍然是结构体标签对应的原始类型名称。类型别名能让函数参数和变量声明更加简洁,但不会改变内存布局。
匿名结构体
结构体标签可以省略:
c
struct {
int x;
int y;
} point;
这种写法适合只使用一次的局部数据类型。但每次单独写出的匿名结构体都是不同的类型,即使成员完全相同,也不能因此认为它们可以相互赋值。如果多个对象需要共享同一种结构体类型,应使用结构体标签或 typedef。
二、结构体初始化和嵌套
结构体变量可以在定义时按成员顺序初始化:
c
struct Point point = {10, 20};
也可以使用指定成员初始化:
c
struct Point point = {
.x = 10,
.y = 20
};
结构体成员还可以是数组、指针或另一个结构体:
c
struct Point {
int x;
int y;
};
struct Node {
int data;
struct Point position;
struct Node *next;
};
struct Node node = {
.data = 10,
.position = {.x = 4, .y = 5},
.next = NULL
};
访问结构体变量的成员使用点运算符,访问结构体指针指向的成员使用箭头运算符:
c
node.data = 20;
struct Node *ptr = &node;
ptr->data = 30;
ptr->data 等价于 (*ptr).data。因为点运算符的优先级高于一元 *,手写展开时必须保留括号。
三、结构体自引用与链式数据结构
结构体不能直接包含一个自身类型的成员,否则对象大小会无限递归:
c
/* 错误:对象大小无法确定 */
struct Node {
int data;
struct Node next;
};
但结构体可以包含指向自身的指针,因为指针大小是确定的:
c
struct Node {
int data;
struct Node *next;
};
这就是链表节点常用的定义方式。使用 typedef 定义自引用结构体时,必须先写出结构体标签:
c
typedef struct Node {
int data;
struct Node *next;
} Node;
结构体定义完成后,外部代码可以使用简洁的 Node *。
四、结构体内存对齐
结构体成员在内存中通常按照声明顺序排列,但编译器可能在成员之间插入填充字节,这就是内存对齐。
常见规则可以概括为:
- 第一个成员的偏移量为 0。
- 后续成员的偏移量需要是其对齐数的整数倍。
- 成员的对齐数通常取编译器默认对齐数和成员大小中的较小值。
- 结构体总大小需要是结构体最大对齐数的整数倍。
- 嵌套结构体要先按照自身的对齐要求放置,外层结构体还要满足整体对齐要求。
以两个成员顺序不同的结构体为例:
c
struct WithPadding {
char c1;
int value;
char c2;
};
struct LessPadding {
char c1;
char c2;
int value;
};
在常见编译器和默认对齐设置下,WithPadding 中的 int 前后可能产生填充,而 LessPadding 把两个字符成员放在一起,通常可以减少浪费。成员类型和数量相同,声明顺序不同,结构体大小也可能不同。
可以使用 offsetof 查看成员偏移:
c
#include <stddef.h>
#include <stdio.h>
struct Record {
char flag;
int value;
double score;
};
int main(void)
{
printf("flag: %zu\n", offsetof(struct Record, flag));
printf("value: %zu\n", offsetof(struct Record, value));
printf("score: %zu\n", offsetof(struct Record, score));
printf("size: %zu\n", sizeof(struct Record));
return 0;
}
内存对齐主要是为了兼容硬件访问规则和提高处理器访问效率,本质上是用空间换时间。设计结构体时,可以把占用空间相近或较小的成员集中放置,但不要只凭经验猜测大小,应通过 sizeof 和 offsetof 验证。
部分编译器支持 pragma pack 修改默认对齐数:
c
#pragma pack(push, 1)
struct PackedHeader {
char type;
int length;
short version;
};
#pragma pack(pop)
降低对齐数可以减少结构体大小,但可能导致未对齐访问,影响性能或移植性。它更适合布局明确的文件格式和通信协议,使用时必须结合目标编译器验证。
五、结构体传参
结构体可以按值传递,但这会复制整个对象:
c
void print_record(struct Record record)
{
printf("%d\n", record.value);
}
结构体较小时,按值传递简单直观;如果结构体包含很大的数组或许多成员,复制会增加参数传递的时间和栈空间开销。更常见的方式是传递结构体地址:
c
void print_record(const struct Record *record)
{
printf("%d\n", record->value);
}
调用时传入地址:
c
struct Record record = {0};
print_record(&record);
如果函数只读取结构体,建议使用 const;如果函数需要修改调用者的结构体,则使用普通的结构体指针。
六、位段:把整数拆成更细的字段
位段允许在结构体中指定某个整型成员占用的比特数:
c
struct Flags {
unsigned int readable : 1;
unsigned int writable : 1;
unsigned int executable : 1;
unsigned int priority : 3;
};
位宽表示比特数而不是字节数,适合表示权限标志、硬件寄存器字段或取值范围很小的状态。位段通常有这些限制:
- 成员类型通常是 int、unsigned int、signed int,部分实现也支持 char 或其他整型类型。
- 位宽不能超过底层分配单元的位数。
- 位段不能直接取地址。
- 位段的具体布局与编译器、目标平台和字节序有关。
不同实现可能在有符号位段的解释方式、位段分配方向、跨存储单元时的处理方式上存在差异。因此位段虽然节省空间,但可移植性较弱。跨平台协议应使用明确的整数类型和位运算手动编码。
七、枚举:给有限取值命名
枚举用于表示一组有限且互斥的取值,例如星期、颜色和设备状态:
c
enum Day {
MONDAY,
TUESDAY,
WEDNESDAY,
THURSDAY,
FRIDAY,
SATURDAY,
SUNDAY
};
默认情况下,第一个枚举常量的值为 0,后续成员依次加 1。也可以显式指定值:
c
enum Permission {
PERMISSION_READ = 1,
PERMISSION_WRITE = 2,
PERMISSION_EXECUTE = 4
};
定义枚举变量:
c
enum Day today = WEDNESDAY;
枚举能提高可读性和可维护性,把相关常量组织在同一个类型中,调试时也更容易理解。枚举常量本质上参与整数表达式,但外部输入仍应检查是否属于有效取值,不能把任意整数直接当成合法状态。
八、联合体:多个成员共享一块内存
联合体又称共用体,它的所有成员从同一个地址开始存放,共享同一块内存:
c
union Value {
int integer;
float decimal;
char character;
};
给某个成员赋值后,可以通过该成员读取对应数据;如果随后给另一个成员赋值,原来成员的字节就可能被覆盖:
c
union Value value;
value.integer = 42;
value.decimal = 3.14f;
联合体最适合表示同一时刻只使用一种类型的数据,例如变体对象、节省空间的消息载荷或硬件寄存器映射。读取联合体时,必须明确当前哪个成员是有效的。
联合体的大小至少要能够容纳最大成员,并且通常还要满足整体对齐要求:
c
union Buffer {
char bytes[5];
int number;
};
计算联合体大小时,不能简单地只取成员大小的最大值,还要考虑最大对齐数和尾部填充。与结构体不同,联合体成员的地址通常相同,因为它们共享起始位置。
九、用联合体观察字节序
联合体可以帮助观察一个整数在内存中的第一个字节:
c
#include <stdio.h>
union EndianProbe {
unsigned int value;
unsigned char first_byte;
};
int main(void)
{
union EndianProbe probe = {.value = 1};
if (probe.first_byte == 1) {
puts("little endian");
} else {
puts("big endian");
}
return 0;
}
小端模式下,整数最低有效字节位于低地址,first_byte 通常读到 1;大端模式下,低地址保存高位字节。需要强调的是,依赖联合体在不同成员之间重新解释对象表示,涉及实现相关行为;强调标准可移植性时,可以使用 memcpy 把对象的字节复制到 unsigned char 数组后再分析。
十、标签和联合体的组合
常见的安全设计是使用一个枚举标签说明联合体当前的有效成员:
c
enum PayloadKind {
PAYLOAD_INT,
PAYLOAD_FLOAT
};
union Payload {
int integer;
float decimal;
};
struct Message {
enum PayloadKind kind;
union Payload payload;
};
使用时先检查标签,再读取对应成员:
c
struct Message message = {
.kind = PAYLOAD_INT,
.payload.integer = 100
};
if (message.kind == PAYLOAD_INT) {
printf("%d\n", message.payload.integer);
}
这种"标签加联合体"的模式可以清楚表达变体数据,避免调用者不知道当前联合体成员的问题。
十一、如何选择自定义类型
| 需求 | 合适的类型 |
|---|---|
| 一个对象同时拥有多个不同属性 | 结构体 |
| 多个小字段需要压缩到若干比特 | 位段 |
| 取值来自有限的命名集合 | 枚举 |
| 同一块空间在不同时间表示不同类型 | 联合体 |
结构体强调同时存在,联合体强调共享空间,枚举强调有限取值,位段强调精确控制比特。它们可以组合使用,形成清晰的数据模型。
十二、设计自定义类型时的实用建议
- 用结构体表达一个完整对象,不要把无关字段随意堆在一起。
- 需要自引用时使用结构体指针,不要直接嵌套同类型对象。
- 传递较大的结构体时优先传地址,只读函数参数加上 const。
- 设计结构体时考虑对齐和填充,必要时使用 sizeof 与 offsetof 验证实际布局。
- 不要把 pragma pack 当成通用优化手段,修改对齐前先确认协议和平台要求。
- 位段适合本地实现的紧凑标志,不适合作为跨平台二进制协议的默认方案。
- 枚举常量应使用有意义的命名,并对来自文件、网络或用户的值进行范围校验。
- 联合体成员共享内存,写入一个成员后只能按当前有效成员读取。
- 需要跨平台解释对象字节时,优先使用明确的整数类型、位运算或 memcpy。
- 用 sizeof、offsetof 和实际编译结果验证假设,不要只凭成员声明推断布局。
自定义类型的价值不只是减少变量数量,而是把数据的含义、生命周期和内存布局表达清楚。结构体让对象更完整,枚举让状态更明确,联合体让空间得到复用,位段让每个比特都能承担语义。掌握它们的差异之后,链表、状态机、协议解析和设备数据结构都可以建立在更可靠的类型设计之上。