共用体
共用体和结构体一样,都能把多个成员组织在一起,但两者的存储方式并不相同。结构体的各成员各有自己的空间,共用体的各成员则共享同一块存储空间。本文围绕 C 语言共用体,梳理声明、成员访问、读写覆盖、内存对齐、初始化等常见知识点,并通过对比与问答形式说明容易混淆的细节。 本系列还有更多其他作品:万字详解:C指针与动态内存分配、一文详解:C结构体、一文详解:C枚举
一、基本概念
共用体是一种特殊的数据类型,关键字是 union。同一个共用体变量的各成员共享存储空间,可以在不同时间存放不同类型的数据。各成员的起始地址相同,但成员占用的字节数不一定相同。例如,一个成员占 4 字节,另一个成员占 10 字节,它们从同一地址开始,共享起始的那部分空间,并不是每个成员都占满整个共用体。
共用体和结构体有什么区别?
| 成员的存储空间 | 各成员有各自的空间 | 各成员共享空间 |
| 能否同时保存各成员独立的值 | 可以 | 不可以 |
| 修改一个成员 | 不会因此改写其他成员 | 可能覆盖其他成员的数据 |
| 大小 | 需要容纳所有成员,并考虑对齐 | 需要容纳最大成员,并考虑对齐 |
例如,需要同时保存学生的学号和成绩,应使用结构体:
struct Student {
int id;
float score;
};
共享内存的是同一个共用体变量的成员,不是同一种共用体类型的所有变量。
union Data {
int i;
float f;
};
union Data a, b;
a.i = 10;
b.f = 3.5f;
a 和 b 是两个独立变量,修改 b.f 不影响 a.i。
二、声明与定义
声明共用体类型:
union Data {
int i;
float f;
char c;
};
用该类型定义变量:
union Data d;
在 C 中,如果没有使用 typedef 定义类型别名,不能直接写:
Data d; // 不正确
也可以在声明类型的同时定义变量:
union Data {
int i;
float f;
char c;
} d;
三、访问成员
共用体变量通过 . 访问成员:
d.i = 10;
printf("%d\\n", d.i);
指向共用体的指针通过 -> 访问成员:
union Data *p = &d;
p->i = 20;
printf("%d\\n", p->i);
. 和 & 放在一起,怎样结合?
. 的优先级高于一元运算符 &。
int *p = &d.i;
等价于:
int *p = &(d.i);
意思是取成员 d.i 的地址。
取地址与读取值是两种不同的操作。 即使成员尚未初始化,也可以取它的地址,但不能因此随意读取它的值。
优先级和求值顺序有什么区别?
优先级决定表达式怎样组合,即谁与谁组成一个整体。求值顺序决定运行时先计算谁、后计算谁。
例如:
f() + g() * h()
根据优先级,它相当于:
f() + (g() * h())
这决定了 g() 和 h() 的返回值相乘,再与 f() 的返回值相加。不代表一定先调用 g()、h(),最后才调用 f()。C 没有规定这里三个函数调用的先后次序。
优先级决定计算关系,与各部分的求值顺序无关。
四、读取与写入:什么操作会覆盖数据?
写入成员可能覆盖其他成员的数据,读取本身不会造成覆盖。
d.i = 10;
printf("%d\\n", d.i);
printf("%d\\n", d.i);
两次都输出 10,因为读取没有修改保存的内容。
d.i = 10;
d.f = 3.5f;
后一次赋值改写了共享内存,不能再指望 d.i 仍然保存整数 10。
后一次写入可能覆盖前一次保存的数据,如果需要先后使用不同成员,可以在覆盖之前使用当前值:
d.i = 10;
printf("%d\\n", d.i);
d.f = 3.5f;
printf("%f\\n", d.f);
如果需要同时保留多个值,应使用结构体或其他独立的存储空间。
五、读取另一个成员,不等于类型转换
d.f = 3.5f;
printf("%d\\n", d.i);
这里不是把浮点数 3.5f 转换为整数 3,而是按照 int 的方式解释对应的内存内容。
同一组 0 和 1,原本按一种类型的编码规则解释是 3.5,改用另一种类型的规则解释,就不能指望得到相同的数值。
在常见的 32 位 int、IEEE 754 单精度 float 平台上:
| 0x40600000 | 按 float 解释 | 3.5 |
| 0x40600000 | 按 int 解释 | 1080033280 |
这个具体结果依赖平台,不是 C 语言在所有平台上的保证。读取非最近写入的成员涉及数据表示等细节,可能遇到陷阱表示,不能普遍保证得到有意义的值。
初学时,通常应读取最近一次写入的成员。
真正的数值转换是什么样的?
d.f = 3.5f;
int n = (int)d.f;
先读取浮点数 3.5f,再转换为整数 3,用于初始化 n。
可以这样简化理解:类型转换相当于使用一个临时副本,得到转换后的值,不修改原值。
这里的“临时副本”是理解模型,不代表一定在内存中实际创建了一个副本。
n = 8;
不会影响 d.f,因为 n 是独立变量,有自己的存储空间。
如果把转换结果写回共用体呢?
d.f = 3.5f;
d.i = (int)d.f;
执行过程:
因此,最后 d.i 为整数 3,不能再保证 d.f 是 3.5f。
转换不修改原值,但将转换结果赋给共享内存中的成员,会改写内存。
六、共用体与指针
union Data d;
int *p = &d.i;
d.f = 3.5f;
给 d.f 赋值不会让 p 变成悬空指针。
只要 d 仍然存在,相关存储空间仍然有效。这里改变的是共享内存的内容,而不是让那块空间消失。
接着执行:
*p = 20;
相当于:
d.i = 20;
它通过指针把整数 20 写入成员 i,不能再保证 d.f 保持原来的值。
地址是否有效,与其中保存的内容是否改变,是两个不同的问题。
七、共用体大小与内存对齐
共用体必须足以容纳最大的成员,同时满足对齐要求:
共用体大小 ≥ 最大成员大小
实际大小使用 sizeof 获取:
sizeof(union Data)
sizeof d
什么是内存对齐?
内存对齐是指:某种类型的数据在内存中存放时,起始地址需要是某个数的倍数。这个数就是它的对齐要求。(这部分在[万字详解:C 指针与动态内存分配](万字详解:C 指针与动态内存分配-CSDN博客)简单提及过)
假设某个平台的 int 占 4 字节,要求按 4 字节对齐:
| 100 | 是 |
| 101 | 否 |
| 102 | 否 |
| 104 | 是 |
从地址 100 开始的 int 占用 100、101、102、103 四个字节。
要求对齐的是起始地址,不是它占用的每个字节地址。
对齐用于适应处理器访问内存的方式,通常由编译器安排。类型的大小与对齐要求不是同一个概念,不能认为占几字节就必然要求几字节对齐。
为什么共用体大小可能超过最大成员?
union Example {
int i; // 假设占 4 字节,要求 4 字节对齐
char s[10]; // 占 10 字节
};
假设这个共用体的对齐要求为 4 字节,那么它的大小通常是 12 字节。
这是为了让它们组成数组时,每个元素都满足对齐要求:
| 10 字节 | 100 | 110,不满足 4 字节对齐 |
| 12 字节 | 100 | 112,满足 4 字节对齐 |
补出来的字节称为填充字节,不代表增加了成员。
填充字节存放的是“垃圾值”吗?
填充字节也有实际的 0 和 1,但不能依赖它们具有某个固定内容,也不需要把它们作为成员数据管理。
“垃圾内容”可以作为口语理解,但不意味着程序专门生成了随机数。
同样,执行:
union Example d;
d.i = 10;
只能保证 d.i 得到了整数 10,不能认为其余字节都会自动清零。
八、共用体初始化
1. 不指定成员名:初始化第一个成员
union Data {
int i;
float f;
};
union Data a = {10}; // a.i 为整数 10
union Data b = {3.5f}; // b.i 为整数 3
不会根据初始化值的类型自动选择成员。
第二句仍然初始化第一个成员 i,因此发生浮点数到整数的转换。
如果调整成员顺序:
union Other {
float f;
int i;
};
union Other x = {10};
初始化的是 f,保存浮点数 10.0f。
2. 指定成员名:初始化指定成员
union Data c = {.f = 3.5f};
.f 表示选择成员 f 进行初始化。
3. 不能像结构体那样依次初始化多个成员
union Data c = {10, 3.5f}; // 不符合初始化规则
第二个初始化值是多余的,编译器必须给出诊断。即使某个编译器警告后继续编译,也不代表同时保存了两个值。
4. 初始化与赋值不同
union Data d = {.f = 3.5f}; // 定义变量时初始化
d.i = 10; // 之后赋值
共用体可以先后给不同成员赋值,但不能同时保存各成员独立的值。
九、结构体省略初始化的成员会怎样?
必须区分“整个变量没有初始化”和“初始化列表只写了部分成员”。
struct Student {
int id;
float score;
};
| 函数内普通局部变量 struct Student a; | 成员未初始化,不能直接读取使用 |
| struct Student b = {.score = 95.0f}; | score 为 95.0f,省略的 id 为 0 |
| struct Student c = {0}; | id 为 0,score 为 0.0f |
| 函数外定义,或使用 static,没有显式初始化 | 成员按规则默认初始化,这里分别为 0、0.0f |
默认初始化中的“零”按类型理解:
- 整数成员得到整数零。
- 浮点成员得到浮点零。
- 指针成员得到空指针。
不能简单把这种初始化等同于所有位机械地清零。
这个规则不能理解成共用体未选择的成员也分别保存了零:
union Data d = {.i = 10};
这里只初始化了 i,不能说 f 也被初始化为 0.0f,因为它们共享存储空间。
十、共用体不会自动记录当前成员
共用体本身不会自动记录“当前存放的是 int 还是 float”。
如果程序需要区分,可以另外保存标记:
enum Type {
INTEGER,
FLOATING
};
struct Value {
enum Type type;
union {
int i;
float f;
} data;
};
这里:
- type 和 data 是结构体的不同成员,各有自己的空间。
- data.i 和 data.f 是同一个共用体的成员,共享空间。
写入时维护标记与数据的一致性:
struct Value v;
v.type = FLOATING;
v.data.f = 3.5f;
读取时根据标记选择成员:
if (v.type == INTEGER) {
printf("%d\\n", v.data.i);
} else if (v.type == FLOATING) {
printf("%f\\n", v.data.f);
}
标记本身有什么特殊功能吗?
没有。标记只是普通变量,没有自动管理共用体的功能。
标记能影响程序运行行为,是因为程序把它用于 if 等判断;如果不这样使用,它就不会自动影响共用体的读写。不能把程序员写出的判断逻辑,当成标记本身自带的功能。
因此:
v.type = INTEGER;
不会自动把浮点数转换成整数。
v.data.i = 10;
也不会自动把 v.type 更新为 INTEGER。
标记与数据之间的联系,由程序员通过代码建立,一致性也需要自己维护。
标记并不是使用共用体的必要条件。如果程序通过其他方式知道应该读取哪个成员,也可以不设置标记。
以上就是本篇全部内容,结构体、枚举、共用体让C能描述更复杂的数据和关系,从而支撑更复杂的功能。如有帮助,还请点赞关注,感谢支持。
学习路上,我们常在。





