结构体表示“这些全都要,同时存在”。union 表示“这些里面恰好一个,同一时刻只有一个”。所有成员叠放在同一个地址上,所以联合体的大小只等于它最大的成员,而写入一个成员就会破坏其他成员。
这听起来像个陷阱,不加检查的话确实如此。但这也是 C 表达“可能是几种类型之一的值”的方式,比如一个记号要么是数字要么是字符串,一条消息要么是点击要么是按键,而不必同时为所有类型付出内存代价。
声明和使用联合体
语法和结构体完全一样,只是关键字不同。
访问成员用 .(通过指针时用 ->),和结构体一模一样。不同之处在于,只有最近一次写入的成员才保存着有意义的值。执行 v.f = 3.5f 之后再读 v.i,得到的不是 3,而是 3.5f 的位模式恰好拼成的某个整数。
大小:取决于最大的成员
比较一下成员相同的结构体和联合体的内存布局:
在典型的机器上,结构体是 24 字节(int 占 4,double 占 8,char 占 1,再加上填充),而联合体是 8 字节,也就是它的 double 的大小,再按对齐取整。打印出来的三个地址完全相同,一行输出就道尽了联合体的全部本质。
注意 %p 对应的参数都转换成了 void *。printf 的 %p 要求的正是这个类型;传入其他指针类型属于未定义行为,尽管它通常看起来能正常工作。参见格式说明符。
初始化联合体
不带指定符的花括号初始化,初始化的是第一个成员:
应该使用的是指定初始化的写法。{42} 悄悄依赖于成员的顺序,以后调整了声明顺序,被初始化的成员也会跟着变,这是个相当棘手的 bug,因为代码看起来没有任何变化。
真正的问题:当前有效的是哪个成员?
联合体不会记录你最后写入的是哪个成员。它只是一些字节;这个信息只存在于你的脑子里,而这恰恰是信息最容易丢失的地方。
打印出来的数字又大又奇怪,那是 1.0f 的位模式被当作 int 读出的结果。没有崩溃,没有警告,程序只是悄无声息地错了。联合体完全履行了它的承诺;错在我们忘了当前有效的是哪个成员。
解决办法:带标签的联合体
标准的解决方案是把联合体和一个记录当前有效成员的枚举配成一对,再用一个结构体把两者包起来。这种组合叫作带标签的联合体(tagged union,也叫可辨识联合体),在应用代码里,几乎每一个联合体都应该这样写。
现在每次读取都要经过对 kind 的 switch,所以不可能读到从未写入过的成员,只要每次写入时也同时设置标签。把写入操作封装成小的构造函数(value_from_int、value_from_string),是让人不可能忘记这一点的常用做法。
节省的内存是实实在在的:这里每个 Value 只需 24 字节的数据加上标签,而同时保存三者的结构体需要 4 + 4 + 24 字节。当有十万个这样的值时,差别就很明显了。
用 -Wall 编译还能多一道保险:如果以后在枚举中添加了 VAL_BOOL 却忘了为它写 case,GCC 会对未处理的枚举值发出警告。
匿名联合体
C11 允许在结构体中放一个没有名字的联合体成员,它的成员会提升到外层结构体的命名空间中:
因为联合体本身没有名字,你可以写 s->circle.r,而不是 s->as.circle.r。读起来更简短,代价是隐藏了这里用到联合体的事实;不过只要标签就在旁边,这没什么问题。
联合体真正派上用场的地方
四种常见用途:
- 变体值。 解释器、JSON 和配置文件解析器、消息队列,都要处理类型在运行时才确定的值。带标签的联合体是它们的标准表示方式。
- 内存紧凑的记录。 当一个结构体有好几个互斥的字段,而这样的结构体有数百万个时,让它们重叠可以直接节省内存。
- 协议和硬件布局。 载荷取决于报头某个字节的数据包,天然适合用带标签的联合体来表示,设备寄存器也是如此。
- 查看字节。 把一个值和一个
unsigned char[]数组叠放在一起,就能查看它的每一个字节,比如用来判断字节序:
这就是类型双关(type punning):故意把字节按另一种类型读回来。在 C 中,像这样通过联合体读取是明确允许的,结果由实现定义(不像在不相关类型之间转换指针那样违反别名规则),而按 unsigned char 查看字节始终是安全的。把 int 重新解释为 float 则是另一回事:结果完全取决于你的平台如何表示数据,所以不要把它放进可移植的代码里。
常见错误
- 读取没有写入过的成员。 最主要的风险。用标签来解决。
- 以为联合体会做类型转换。 它不会。
u.i = 3; float f = u.f;是重新解释位;int i = 3; float f = i;才是类型转换。参见类型转换。 - 把指针放进联合体后忘了它。 如果某个分支保存着你分配的
char *,用另一个成员覆盖联合体就会造成内存泄漏,因为再也没有任何东西指向那块缓冲区了。切换分支之前先释放。 - 指望编译器替你检查。 它不会。联合体是 C 中为数不多的、除了大小和对齐之外语言完全不提供帮助的特性之一;标签是你唯一的护栏。
常见问题
C 语言的联合体是什么?
联合体是一种所有成员共享同一块内存的类型。写入一个成员会覆盖其他成员,所以联合体在同一时刻只保存其中一个成员。它的声明方式和结构体一样,只是使用 union 关键字:union Value { int i; float f; };。
C 语言的联合体和结构体有什么区别?
结构体给每个成员分配各自的存储空间,同时保存所有成员,所以它的大小至少是各成员大小之和。联合体把所有成员叠放在同一个地址上,同一时刻只保存一个,所以它的大小等于最大的那个成员。
C 语言联合体的大小是多少?
足以容纳它最大的成员,再按对齐要求向上取整。一个由 int(4 字节)和 double(8 字节)组成的联合体是 8 字节,而不是 12。要在自己的平台上确认,用 sizeof 就行。
读取一个没有写入过的联合体成员会怎样?
你会把同一组字节按另一种类型重新解释。先写 u.i = 1 再读 u.f 并不会做类型转换,而是把这个整数的位模式当作 float 来读,得到一个毫无意义的数字。在标准 C 中,这充其量是未指定的,所以才有了带标签联合体这种模式:在旁边保存一个标签,记录当前有效的是哪个成员。