结构体、联合、枚举、位域与类型别名
本节目标
查询 C23 结构体、联合、枚举、位域、柔性数组成员、对象布局与 typedef。
ISO C23 的正式术语中,“聚合类型”只包括数组类型和结构体类型,不包括联合体类型。本章把结构体、联合体、枚举、位域和类型别名放在一起,只是为了教学与检索组织;判断一段代码时,先确认类型是否完整、当前保存的是哪个联合体成员,再区分语言保证的成员语义与实现决定的对象布局。
结构体类型、标签与成员
结构体把一组按名字访问的成员组合成一个对象。标签位于标签命名空间;成员名只属于相应结构体;对象名、函数名和 typedef 名则位于普通标识符命名空间。
| 写法 | 此处的含义 | 可做什么 |
|---|---|---|
struct Node; | 声明标签,类型仍不完整 | 声明指向它的指针 |
struct Node *next; | 指针类型本身完整 | 保存同类型对象的地址 |
struct Node { int value; struct Node *next; }; | 完成结构体定义 | 定义对象并访问成员 |
不完整结构体的大小和成员尚未知,因此不能先定义该类型的对象或将它作为非指针成员。完成定义后,struct Node 才能用于这些需要完整对象类型的上下文。
聚合初始化、指定初始化器与复制
花括号初始化器按成员声明顺序提供值;指定初始化器用 .member = value 明确目标,适合字段较多或希望减少顺序依赖的代码。没有显式初始化的其余成员按相应初始化规则获得值。
struct Point {
int x;
int y;
};
struct Point origin = {.x = 3, .y = 4};
struct Point copy = origin;
结构体对象可以赋值给兼容类型的另一个结构体对象。结构体赋值复制成员值,指针成员仍指向同一对象;因此它是成员级复制,含指针成员时属于浅复制,不会自动复制指针所指的对象。
成员访问与结构体指针
. 的左操作数是结构体或联合体对象,-> 的左操作数是指向相应类型的指针。pointer->member 对应 (*pointer).member,但使用前仍须满足指针访问的全部前置条件。
struct Point point = {.x = 3, .y = 4};
struct Point *pointer = &point;
point.x = 5;
pointer->y = 6;
指针必须非空、正确对齐、指向仍在生存期内且类型适合的对象;若要写成员,对象还必须可修改。-> 只是成员访问语法,不会替调用方检查空指针或延长对象生存期。
成员顺序、填充、对齐与偏移
结构体成员按声明顺序排列,但实现可以在成员之间或末尾加入填充。对非位域成员,后声明成员在对象中的地址更高;具体填充数量、对象总大小和所需对齐仍由成员类型及实现共同决定。
offsetof(struct Packet, payload) 可查询完整结构体中普通成员的字节偏移,结果类型为 size_t。按照宏的约束,假定先声明 static type temporary;,&(temporary.member-designator) 必须能够形成地址常量;type 参数不能现场定义新类型,指定成员为位域时行为未定义。因此 offsetof 不用于位域成员。它适合在当前实现中表达成员位置,不应把某次结果写成所有实现都相同的常量。
| 可以依赖 | 不能据此假设 |
|---|---|
| 非位域成员保持声明顺序 | 任意两个成员必然紧邻 |
offsetof 给出当前类型中指定普通成员的偏移 | 其他编译器、目标平台或编译选项给出同一数值 |
| 实现满足各成员的对齐要求 | 内存表示天然等于文件或网络格式 |
柔性数组成员
柔性数组成员是结构体最后一个成员处的不完整数组声明;该结构体在它之前还必须有至少一个其他命名成员。柔性数组成员不把尾部元素计入普通 sizeof 结果;通过 . 或 -> 访问这个成员时,标准另按对象范围为它提供特殊的数组访问规则。
struct ByteBuffer {
size_t length;
unsigned char data[];
};
这样的结构体不能作为另一个结构体的成员,也不能作为数组元素类型;包含这种结构体的联合体也受到同样的成员与数组限制。访问 data[index] 前,程序必须另外保证对象后方确实提供了足够、正确对齐且仍在生存期内的存储。尾部存储通常与动态分配一起设计;该主题将在“动态内存、生命周期与所有权”章节集中处理,两章公开前这里不提供公共链接。
联合体与带标签变体
联合体的成员共享存储。写入一个成员会改变当前保存值所对应的成员;若程序需要在多个成员之间安全切换,应把判别项与联合体放进同一个结构体。读取联合体前先用显式判别项确认当前保存的成员。
enum ValueKind {
VALUE_COUNT,
VALUE_RATIO,
};
struct TaggedValue {
enum ValueKind kind;
union {
int count;
double ratio;
} value;
};
static int read_count(const struct TaggedValue *item) {
if (item == nullptr || item->kind != VALUE_COUNT) {
return 0;
}
return item->value.count;
}
这里的 kind 是程序约定,不是联合体自动维护的元数据。每次写入 value 时都要同步设置判别项,并只进入与该判别项匹配的读取分支。这是推荐的接口设计边界,不是对 C23 联合体规则的删减:读取的成员不是最后写入的成员时,对应对象表示会按新类型重新解释,结果可能是该类型的非值表示,因而不能把结果当作普通语义值使用。
C23 另有一个共同初始序列保证:若联合体包含多个具有共同初始序列的结构体,而且完整联合体类型的声明在当前位置可见,就可以检查其中任一结构体的共同初始部分。直接访问原子结构体或原子联合体的成员是未定义行为;需要逐成员读取时,先对整个原子对象执行原子加载,得到非原子副本后再访问其成员。
枚举类型与枚举常量
枚举给有限状态命名,枚举常量是可用于整型常量表达式的名字。C23 的固定底层枚举与未指定固定底层类型的枚举必须分别判断:前者在声明中写出底层整数类型,后者的兼容整数类型由实现依据标准约束选择。
enum WireState : unsigned char {
WIRE_IDLE,
WIRE_BUSY,
};
enum LocalState {
LOCAL_IDLE,
LOCAL_BUSY,
};
固定底层形式明确枚举类型的兼容整数类型,但不等于指定了整个结构体、位域或协议的字节布局。未固定底层类型时,不要假定枚举对象必定采用某个特定宽度或与某个命名整数类型相同。
位域、宽度与实现边界
位域用冒号后的宽度限制一个成员可表示的位数。位域宽度必须是非负整数常量表达式,不能超过省略冒号和宽度后所得类型对象的宽度;宽度为零时不能有声明符。C23 允许的位域类型是有无限定的 bool、signed int、unsigned int、位精确整数类型,或实现定义的其他类型;是否允许原子类型也由实现定义。
作为接口设计约定,优先使用 unsigned int 位域并把它当作普通数值读取或赋值;写入前应保证值处于该宽度可表示的范围内。
struct Flags {
unsigned int enabled : 1;
unsigned int priority : 3;
};
struct Flags flags = {.enabled = 1, .priority = 5};
unsigned int priority = flags.priority;
位域的分配顺序、对齐和存储单元边界属于实现相关信息,不能从声明推导线上的位序或字节序。不能取得位域的地址,也不能把位域传给要求普通对象地址的接口;需要序列化时,应使用掩码、移位和明确宽度的整数值构造协议表示。
typedef 与类型别名
typedef 创建已有类型的别名,不创建新的不兼容类型。它使用普通标识符命名空间,与结构体标签所在的标签命名空间不同,因此常见写法可以让标签和别名采用不同名字,也可以在不冲突时采用相同拼写。
struct RecordTag {
int value;
};
typedef struct RecordTag Record;
Record item = {.value = 42};
| 名字 | 所在命名空间 | 指代 |
|---|---|---|
RecordTag | 标签 | struct RecordTag 的标签 |
Record | 普通标识符 | 同一结构体类型的别名 |
item | 普通标识符 | 该类型的对象 |
下面的完整程序把指定初始化、枚举判别项、联合体成员、无符号位域、typedef 和结构体复制组合起来。代表程序不进行动态分配;两个 Record 对象具有自动存储期,字符串字面量表示的数组具有静态存储期。程序不观察任何实现特定的布局数值:
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
typedef enum {
VALUE_COUNT,
VALUE_RATIO,
} ValueKind;
typedef union {
int count;
double ratio;
} Value;
typedef struct {
const char *name;
ValueKind kind;
Value value;
unsigned int enabled : 1;
unsigned int priority : 3;
} Record;
static const char *kind_name(ValueKind kind) {
switch (kind) {
case VALUE_COUNT:
return "count";
case VALUE_RATIO:
return "ratio";
}
return "unknown";
}
static int count_value(const Record *record) {
if (record == nullptr || record->kind != VALUE_COUNT) {
return 0;
}
return record->value.count;
}
int main(void) {
const Record record = {
.name = "sample",
.kind = VALUE_COUNT,
.value = {.count = 42},
.enabled = 1,
.priority = 5,
};
const Record copy = record;
printf("record-name=%s\n", record.name);
printf("record-kind=%s\n", kind_name(record.kind));
printf("record-value=%d\n", count_value(&record));
printf("record-enabled=%s\n", record.enabled != 0 ? "yes" : "no");
printf("record-priority=%u\n", (unsigned int)record.priority);
printf("copy-value=%d\n", count_value(©));
return EXIT_SUCCESS;
}
输出:
record-name=sample
record-kind=count
record-value=42
record-enabled=yes
record-priority=5
copy-value=42
常见陷阱
| 症状 | 为什么危险 | 排查与修正 |
|---|---|---|
| 读取或比较填充字节 | 填充不是成员值,写成员后也不能假定其字节具有稳定内容 | 按成员比较;序列化时逐字段编码 |
| 读取错误的联合体成员 | 判别项与当前保存成员不一致,可能把对象表示当成另一类型解释 | 先检查显式判别项,再读取对应成员 |
| 尝试对位域取址 | 位域不是可由普通对象指针单独指向的对象 | 先把位域值复制到普通对象,再把该对象地址交给接口 |
| 把结构体直接写成协议数据 | 填充、对齐、枚举表示、字节序和位域布局都可能随实现变化 | 明确字段宽度和编码顺序,逐字段转换 |
把 typedef 当作新名义类型 | 别名与原类型兼容,不能形成额外类型隔离 | 若需区分语义,使用不同结构体类型并设计转换接口 |
对象布局不能直接当作跨实现二进制协议。排查本章所涉类型问题时,按“类型是否完整 → 初始化覆盖哪些成员 → 指针是否满足访问条件 → 联合体判别项是否匹配 → 是否依赖填充、偏移或位域布局 → 别名是否被误认为新类型”逐项核对。