假设你在做一个活动系统,商品有三种类型:图书(需要书名、作者、页数)、杯子(只需要设计图案)、衬衫(需要设计图案、颜色、尺码)。如果你用一个结构体包含所有可能的属性,你会发现大部分时候有大量字段是闲置的——图书不需要"颜色",杯子不需要"页数"。每种商品只用到了全部属性中的一小部分,却因为结构体的"全或无"特性而白白浪费了内存。
这就引出了本讲的两个主角:联合体(union)和枚举(enum)。联合体解决的是"同一块内存分时复用"的问题——所有成员共享同一块内存空间,你在不同时刻可以使用不同的成员。枚举解决的则是"用有意义的名字代替魔法数字"的问题——让你的代码从 if (type == 1) 变成 if (type == BOOK),可读性瞬间提升。
它们和结构体一起,构成了 C 语言自定义类型的"三剑客"。上一讲我们深入讨论了结构体——它的成员各自独立占用空间,总大小是所有成员大小之和(加上对齐填充)。联合体正好相反——所有成员共享同一块内存,大小等于最大成员的大小(对齐调整后)。这个根本区别,是你理解联合体的起点。
联合体类型的声明
联合体在语法上和结构体几乎一模一样,只是关键字从 struct 换成了 union。声明方式:
union Un {
char c; // 1 字节
int i; // 4 字节
};
// sizeof(union Un) = 4(等于最大成员的大小)声明了一个联合体类型后,你可以创建变量:
union Un un = {0};但注意:在 C89 中,联合体的初始化器只作用于第一个成员——= {0} 相当于 un.c = 0。C99 支持指定初始化器:union Un un = { .i = 42 };。
和结构体一样,联合体也有三种常见写法(带标签、匿名、typedef):
typedef union
{
int i;
float f;
char bytes[4]; /* 用 4 个字节观察同一个 float */
} Value;Value 类型的联合体可以让同一块内存"既是 int 又是 float 又是字节数组"——这是很多底层协议的实现基础。
联合体的特点
联合体最核心的特点就四个字:内存共享。所有成员从同一个地址开始,占用同一块内存。你可以通过两个简单的实验来验证。
实验一:打印地址
#include <stdio.h>
union Un
{
char c; /* 1 字节 */
int i; /* 4 字节 */
};
int main()
{
union Un un = {0};
/* 三个地址的输出应该完全一致 */
printf("&un = %p\n", (void *)&un);
printf("&un.i = %p\n", (void *)&(un.i));
printf("&un.c = %p\n", (void *)&(un.c));
/* 输出(示例):三个地址完全相同 */
printf("sizeof(union Un) = %zu\n", sizeof(un));
/* 输出: 4(等于最大成员 int 的大小) */
return 0;
}三个地址的输出一模一样——&un、&un.i、&un.c 指向同一块内存的起点。这就是"共享"的含义。
实验二:成员互相影响
#include <stdio.h>
union Un
{
char c;
int i;
};
int main()
{
union Un un = {0};
/* 先给 i 赋值 */
un.i = 0x11223344;
printf("赋值 i 后: un.i = 0x%X\n", un.i);
/* 再给 c 赋值——这会覆盖 i 的最低字节 */
un.c = 0x55;
printf("赋值 c 后: un.i = 0x%X\n", un.i);
/* 小端输出: 0x11223355(最低字节从 0x44 变成了 0x55) */
return 0;
}这个实验直接展示了"一荣俱荣、一损俱损"的关系——给 c 赋值 1 个字节,直接覆盖了 i 的最低字节,把 0x11223344 变成了 0x11223355。这说明 c 和 i 确实是从同一个地址开始的。同一时刻,联合体只有一个成员是有效的——当你给一个新成员赋值时,其他成员的值就被覆盖了。
来对比一下相同成员的结构体和联合体,内存布局的差异更加清晰:
#include <stdio.h>
/* 相同成员的结构体 */
struct S
{
char c; /* 1 字节 */
int i; /* 4 字节 */
};
/* 相同成员的联合体 */
union U
{
char c; /* 1 字节 */
int i; /* 4 字节 */
};
int main()
{
printf("sizeof(struct S) = %zu\n", sizeof(struct S));
/* 输出: 8(c + 3填充 + i) */
printf("sizeof(union U) = %zu\n", sizeof(union U));
/* 输出: 4(等于最大成员 int 的大小) */
return 0;
}结构体是"叠加"——各成员依次排开,8 字节;联合体是"覆盖"——所有成员堆在同一块区域,4 字节。
用联合体判断大小端
第 19 讲我们学了用指针法判断大小端,联合体法则是一种更优雅的方式——它不依赖指针强制转换,而是利用"成员共享内存"的特性,自然地完成了一次类型重新解读:
#include <stdio.h>
int check_sys()
{
union
{
int i; /* int 和 char 共享内存 */
char c;
} un;
un.i = 1; /* 在内存中,小端: 01 00 00 00,大端: 00 00 00 01 */
return un.c; /* 读取第一个字节:1=小端,0=大端 */
}
int main()
{
if (check_sys() == 1)
printf("当前机器是小端模式\n");
else
printf("当前机器是大端模式\n");
return 0;
}这个写法的精妙之处在于:un.i = 1 后,un.c 读取的是同一块内存的第一个字节。小端机器上第一个字节是 0x01,大端机器上是 0x00——一行 return un.c 就完成了判断。这也是很多公司笔试中的经典答案。
用联合体读写 float 的字节表示
联合体最常见的底层用途之一:把 float/double 的字节表示"拆开"看。这在解析二进制协议、调试浮点问题时非常有用:
#include <stdio.h>
typedef union
{
float f;
unsigned char bytes[4]; /* 用字节数组观察 float 的内存 */
} FloatView;
int main()
{
FloatView v;
v.f = 9.0f; /* 9.0 = 1.001 × 2^3 */
/* 打印 9.0f 的 4 个字节(小端机器) */
printf("9.0f 的字节表示: ");
int i;
for (i = 0; i < 4; i++)
printf("%02X ", v.bytes[i]);
printf("\n");
/* 输出: 00 00 10 41
解读:0x41100000,即 S=0, E=10000010(130), M=001000...(9.0 的 IEEE754) */
return 0;
}v.f = 9.0f 写入 4 个字节,v.bytes 把这 4 个字节按字节读出——同一个内存,两种解读。这就是联合体的本质价值:不用强转指针,就能以不同视角观察同一块内存。
联合体大小的计算
联合体大小的规则比结构体简单,但需要注意对齐:
- 联合体的大小至少是最大成员的大小
- 如果最大成员的大小不是"最大对齐数"的整数倍,就要对齐到最大对齐数的整数倍
#include <stdio.h>
union Un1
{
char c[5]; /* 5 字节,对齐数 1 */
int i; /* 4 字节,对齐数 4 */
/* 最大成员 = 5 字节
最大对齐数 = 4
5 不是 4 的倍数 → 对齐到 8 */
};
union Un2
{
short c[7]; /* 14 字节,对齐数 2 */
int i; /* 4 字节,对齐数 4 */
/* 最大成员 = 14 字节
最大对齐数 = 4
14 不是 4 的倍数 → 对齐到 16 */
};
int main()
{
printf("sizeof(union Un1) = %zu\n", sizeof(union Un1));
/* 输出: 8 */
printf("sizeof(union Un2) = %zu\n", sizeof(union Un2));
/* 输出: 16 */
return 0;
}注意 Un1 的情况:最大成员是 char c[5](5 字节),但最大对齐数是 int 的 4,5 不是 4 的倍数,所以要补到 8。联合体的大小不是简单地取"最大成员大小",还要满足对齐约束——这是面试常考的一个点。
再来看一个综合了结构体和联合体的经典面试题:
#include <stdio.h>
struct A
{
char c; /* 偏移 0 */
int i; /* 偏移 4(对齐数 4) */
}; /* sizeof(struct A) = 8 */
union B
{
struct A a; /* 8 字节,最大对齐数 4 */
double d; /* 8 字节,对齐数 8 */
}; /* 最大成员 8,最大对齐数 8 → sizeof = 8 */
struct C
{
int n; /* 4 字节 */
union B b; /* 8 字节,对齐数 8 → 从偏移 8 开始 */
}; /* n(0-3) + 4填充 + b(8-15) = 16 */
int main()
{
printf("sizeof(struct A) = %zu\n", sizeof(struct A)); /* 8 */
printf("sizeof(union B) = %zu\n", sizeof(union B)); /* 8 */
printf("sizeof(struct C) = %zu\n", sizeof(struct C)); /* 16 */
return 0;
}这题的要点:联合体作为结构体成员时,它的"对齐数"是内部所有成员的最大对齐数(这里是 8),所以 b 从偏移 8 开始放。结构体 C 的布局是 n(0~3) + 填充(4~7) + b(8~15) = 16 字节。
联合体的实际应用:礼品兑换单
回到引言中的例子。不用联合体时,结构体包含了所有商品的全部属性——即使当前商品是杯子,也要为书名、作者这些图书专属字段分配内存。用联合体改写后:
#include <stdio.h>
#include <string.h>
/* 商品类型枚举 */
enum ItemType { BOOK = 1, MUG = 2, SHIRT = 3 };
/* 使用联合体节省内存的礼品结构 */
struct Gift
{
int stock; /* 库存量——公共属性 */
double price; /* 定价——公共属性 */
int item_type; /* 商品类型——决定访问联合体的哪个成员 */
union
{
struct /* 图书专属属性 */
{
char title[20];
char author[20];
int pages;
} book;
struct /* 杯子专属属性 */
{
char design[30];
} mug;
struct /* 衬衫专属属性 */
{
char design[30];
int colors;
int sizes;
} shirt;
} item;
};
int main()
{
struct Gift gift;
/* 设置为一本图书 */
gift.stock = 100;
gift.price = 49.90;
gift.item_type = BOOK;
strcpy(gift.item.book.title, "C语言从入门到入土");
strcpy(gift.item.book.author, "张三");
gift.item.book.pages = 500;
printf("礼品类型: %d\n", gift.item_type);
printf("书名: %s\n", gift.item.book.title);
printf("价格: %.2f\n", gift.price);
/* 使用联合体后,结构体大小 =
公共属性 + max(sizeof(book), sizeof(mug), sizeof(shirt))
而不是 公共属性 + sizeof(book) + sizeof(mug) + sizeof(shirt) */
printf("sizeof(struct Gift) = %zu\n", sizeof(struct Gift));
return 0;
}union 内部的三个子结构体共享同一块内存——同一时刻只存在一种商品的专属属性。结构体的大小 = 公共属性 + max(book, mug, shirt),比原先"全部属性一锅端"的方式节省了大量空间。根据 item_type 的值,你可以安全地访问对应的成员:type == BOOK 时读 item.book,type == MUG 时读 item.mug。
这其实就是在手动实现一个"带标签的联合体"(tagged union)——用一个标签(item_type)记录当前正在使用联合体的哪个成员。很多现代语言(如 Rust 的 enum、Swift 的 enum with associated values)在语言层面原生支持这种模式,而在 C 中你需要手动管理。
C11 匿名联合体:去掉中间层的访问路径
C11 引入了匿名联合体(anonymous union)——在结构体内部直接嵌套匿名联合体时,成员可以直接访问,不需要写 s.item.book 这样的多层路径:
#include <stdio.h>
#include <string.h>
struct Packet
{
int type; /* 标签 */
union /* C11 匿名联合体:没有名字 */
{
int data_i;
float data_f;
char data_s[16];
}; /* 注意:没有成员名! */
};
int main()
{
struct Packet p;
p.type = 1;
p.data_i = 42; /* 直接访问,等价于 p.匿名.data_i */
printf("整型数据: %d\n", p.data_i);
p.type = 2;
p.data_f = 3.14f; /* 覆盖同一块内存 */
printf("浮点数据: %.2f\n", p.data_f);
p.type = 3;
strcpy(p.data_s, "hello"); /* 再次覆盖 */
printf("字符串数据: %s\n", p.data_s);
return 0;
}匿名联合体让"标签 + 联合体"模式的代码更干净:p.data_i 比 p.data.item_i 少了一层。但它要求编译器支持 C11(MSVC 的 C 模式和较新的 GCC/Clang 都支持)。注意:匿名成员只能在结构体/联合体内部使用,它没有自己的名字,但它的成员直接"提升"到外层作用域。
枚举类型的声明
枚举就是把所有可能的取值一一列举出来。现实生活中有很多数据天然适合枚举:一周七天、一年十二月、三原色、性别……
enum Day {
Mon, // 0
Tue, // 1
Wed, // 2
Thu, // 3
Fri, // 4
Sat, // 5
Sun // 6
};Mon、Tue 等叫"枚举常量"。默认情况下,第一个枚举常量的值是 0,后面的依次递增 1。你也可以手动指定值:
enum Color {
RED = 2,
GREEN = 4,
BLUE = 8
};
enum Status {
OK = 200,
NOT_FOUND = 404,
SERVER_ERROR = 500
};手动赋值后,后续常量从该值开始递增——所以如果你写 RED = 5, GREEN, BLUE,那么 GREEN = 6, BLUE = 7。
枚举的底层实现:就是 int
还有一个底层事实值得知道:C 标准规定(C23 之前),每个枚举常量本质上就是一个 int 类型的常量。所以 sizeof(enum Color) 通常等于 sizeof(int)(4 字节),枚举变量在内存中的表示就是一个普通的 int。这也解释了为什么 C 语言对"整数和枚举互转"如此宽松——后面你会看到,这既是方便也是坑。
#include <stdio.h>
enum Day
{
Mon, /* 0 */
Tue, /* 1 */
Wed, /* 2 */
Thu, /* 3 */
Fri, /* 4 */
Sat, /* 5 */
Sun /* 6 */
};
enum Color
{
RED = 2, /* 手动指定起始值 */
GREEN = 4,
BLUE = 8
};
enum Status
{
OK = 200,
NOT_FOUND = 404,
SERVER_ERROR = 500
};
int main()
{
enum Day today = Wed;
enum Color favorite = BLUE;
enum Status code = OK;
printf("today = %d\n", today); /* 输出: 2 */
printf("favorite = %d\n", favorite); /* 输出: 8 */
printf("status code = %d\n", code); /* 输出: 200 */
/* 枚举变量在内存里就是 int,可以放进 int */
int raw = today;
printf("raw = %d\n", raw); /* 2 */
return 0;
}注意 C23 的一个变化:C23 标准允许枚举的基础类型不再局限于 int——可以显式指定为其他整型(如 enum E : char {...},类似 C++ 的做法)。但在此之前(C99/C11/C17),枚举常量就是 int。你的代码如果要兼容旧标准,就按"枚举 = int"来理解。
枚举在 switch 语句中的使用是最经典的模式——它能大幅提升代码可读性:
#include <stdio.h>
enum Direction
{
NORTH, /* 0 */
SOUTH, /* 1 */
EAST, /* 2 */
WEST /* 3 */
};
void move(enum Direction dir)
{
switch (dir)
{
case NORTH:
printf("向北移动\n");
break;
case SOUTH:
printf("向南移动\n");
break;
case EAST:
printf("向东移动\n");
break;
case WEST:
printf("向西移动\n");
break;
default:
printf("未知方向\n");
break;
}
}
int main()
{
move(NORTH); /* 输出: 向北移动 */
move(EAST); /* 输出: 向东移动 */
move(5); /* C 语言中合法但危险: 未知方向 */
return 0;
}注意 move(5) 在 C 语言中是合法的——C 语言的枚举类型检查比较宽松,你可以把任意整数赋给枚举变量(虽然不推荐)。在 C++ 中这是不允许的,C++ 的类型检查更严格。如果你在写需要同时在 C 和 C++ 中编译的头文件,请注意这个差异。
枚举类型的优点
很多初学者会问:#define RED 0 和 enum { RED = 0 } 有区别吗?有,而且区别很大:
| 维度 | #define | enum |
|---|---|---|
| 类型检查 | 无(纯文本替换) | 有(编译器检查类型) |
| 调试友好 | 预处理器已替换,调试器看不到符号 | 调试器中可见枚举常量名 |
| 作用域 | 全局(从定义处到文件末尾) | 遵循 C 作用域规则 |
| 一次定义多个 | 需要多个 #define | 一行声明多个常量 |
| 内存占用 | 不占用(宏只是编译期文本替换) | 枚举常量不占运行时内存;枚举变量占用(通常 4 字节,即一个 int) |
枚举有类型检查——这是最大的优势。你可以声明 enum Color c = RED;,编译器知道 c 是 enum Color 类型。如果你试图把 enum Color 和 enum Day 混用,好的编译器会给出警告。而 #define 是预处理器层面的纯文本替换,编译器根本不知道 RED 是一个"颜色类型"的常量——它看到的只有一个裸数字 0。
调试时能看到名字——枚举常量在编译后保留了符号信息,调试器可以显示 RED 而不是 0。#define 在预处理阶段就被替换成了字面值,调试时你只能看到裸数字。
作用域可控——如果你在函数内部声明枚举类型,它的常量就只在该函数内可见。#define 没有作用域概念,它从定义处开始一直有效到文件末尾(除非用 #undef):
#include <stdio.h>
/* 使用 #define */
#define STATUS_OK 0
#define STATUS_ERROR 1
#define STATUS_PENDING 2
/* 使用 enum */
enum StatusEnum
{
ENUM_OK = 0,
ENUM_ERROR = 1,
ENUM_PENDING = 2
};
/* enum 可以在函数内部声明,具有作用域控制 */
void demo_scope()
{
enum LocalColor { LOCAL_RED, LOCAL_GREEN, LOCAL_BLUE };
enum LocalColor c = LOCAL_RED;
/* #define 没有作用域——如果在函数内 #define,
它从这行开始到文件末尾都有效 */
#define LOCAL_MAGIC 42
printf("局部枚举: %d, 局部宏: %d\n", c, LOCAL_MAGIC);
}
int main()
{
/* #define 定义的是无类型的宏 */
int s1 = STATUS_OK; /* 合法,但无类型保护 */
float s2 = STATUS_ERROR; /* 也合法,宏只是文本替换 */
/* enum 定义的是有类型的常量 */
enum StatusEnum e1 = ENUM_OK;
printf("宏: %d, 枚举: %d\n", s1, e1);
demo_scope();
/* printf("%d\n", LOCAL_RED); */
/* 编译错误!LOCAL_RED 在 demo_scope 外面不可见 */
/* printf("%d\n", LOCAL_MAGIC); */
/* 但是可以!因为 #define 从定义处到文件末尾都有效 */
return 0;
}枚举的坑:为什么不能"遍历"?
很多从 Python/Java 过来的同学会问:C 的枚举能像 for (Day d : days) 一样遍历吗?不能。因为 C 的枚举本质上是 int 常量,没有一个运行时的"枚举列表"对象——你只能通过已知的常量名访问,或者自己建一个数组:
#include <stdio.h>
enum Day { Mon, Tue, Wed, Thu, Fri, Sat, Sun };
int main()
{
/* C 没有"遍历枚举"的机制,只能手动建立映射数组 */
int day_values[] = { Mon, Tue, Wed, Thu, Fri, Sat, Sun };
const char *day_names[] = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"};
int i;
for (i = 0; i < 7; i++)
printf("%s = %d\n", day_names[i], day_values[i]);
return 0;
}另一个坑:如果枚举值不连续(比如 RED = 2, GREEN = 4, BLUE = 8),"for 循环 0 到 8" 的方式会产生大量无意义的中间值。所以工程上要么保持枚举连续,要么用 switch 显式处理每个值。
枚举类型的使用
枚举最实用的场景是做函数参数——用有意义的名称代替"魔法数字"。比如实现一个日志系统:
#include <stdio.h>
/* 用枚举代替"魔法数字" */
enum LogLevel
{
LOG_DEBUG, /* 0 */
LOG_INFO, /* 1 */
LOG_WARN, /* 2 */
LOG_ERROR /* 3 */
};
void log_message(enum LogLevel level, const char *msg)
{
/* 根据级别选择前缀 */
const char *prefix;
switch (level)
{
case LOG_DEBUG: prefix = "[DEBUG]"; break;
case LOG_INFO: prefix = "[INFO] "; break;
case LOG_WARN: prefix = "[WARN] "; break;
case LOG_ERROR: prefix = "[ERROR]"; break;
default: prefix = "[????] "; break;
}
printf("%s %s\n", prefix, msg);
}
int main()
{
log_message(LOG_INFO, "系统启动");
log_message(LOG_DEBUG, "正在加载配置...");
log_message(LOG_WARN, "磁盘空间不足");
log_message(LOG_ERROR, "无法连接数据库");
/* 对比:如果没有枚举,代码会变成:
log_message(1, "系统启动"); // 1 是什么级别?没人看得出来 */
return 0;
}日志系统用枚举还有一个好处:级别本身可以参与比较运算——level >= LOG_WARN 判断是否达到警告级别,这在 C 里是完全合法的(枚举就是 int):
/* 带过滤的日志系统:低于指定级别就不输出 */
void log_message_filtered(enum LogLevel min_level, enum LogLevel level,
const char *msg)
{
if (level < min_level)
return; /* 太啰嗦,过滤掉 */
log_message(level, msg);
}用枚举做位标志(bit flags)
虽然枚举是 int,但它也可以和位运算结合,用来表示"一组开关"——这是 C 里非常实用的模式:
#include <stdio.h>
/* 枚举常量手动指定为 2 的幂——每个值对应一个独立的位 */
enum FileFlags
{
FLAG_READ = 1 << 0, /* 0x01 可读 */
FLAG_WRITE = 1 << 1, /* 0x02 可写 */
FLAG_EXEC = 1 << 2, /* 0x04 可执行 */
FLAG_HIDDEN = 1 << 3 /* 0x08 隐藏 */
};
int main()
{
/* 组合标志:用按位或 */
int flags = FLAG_READ | FLAG_WRITE;
/* 检查某个标志:用按位与 */
if (flags & FLAG_READ)
printf("可读\n");
if (flags & FLAG_WRITE)
printf("可写\n");
if (flags & FLAG_EXEC)
printf("可执行\n"); /* 不打印 */
/* 添加标志:按位或赋值 */
flags |= FLAG_EXEC;
/* 移除标志:先取反再与 */
flags &= ~FLAG_WRITE;
printf("最终 flags = 0x%X\n", flags); /* READ|EXEC = 0x05 */
return 0;
}这个模式的关键是:每个枚举值必须是 2 的幂(1 << n),这样它们才能互不干扰地组合在一个 int 里。很多系统 API(比如 open() 的权限位)就是这么设计的。
标签联合体(Tagged Union):联合体 + 枚举的完美组合
联合体和枚举结合在一起,还能实现更强大的模式——标签联合体(Tagged Union),这是 C 语言中实现"类型安全变体数据"的标准做法:
#include <stdio.h>
#include <string.h>
/* 数据类型枚举 */
enum DataType
{
TYPE_INT,
TYPE_FLOAT,
TYPE_STRING
};
/* 使用"标签+联合体"模式实现类型安全的变体 */
struct Variant
{
enum DataType type; /* 记录当前存储的数据类型 */
union
{
int i_val;
float f_val;
char s_val[32];
} data;
};
/* 打印变体数据 */
void print_variant(const struct Variant *v)
{
switch (v->type)
{
case TYPE_INT:
printf("整型: %d\n", v->data.i_val);
break;
case TYPE_FLOAT:
printf("浮点: %.2f\n", v->data.f_val);
break;
case TYPE_STRING:
printf("字符串: %s\n", v->data.s_val);
break;
default:
printf("未知类型\n");
break;
}
}
int main()
{
struct Variant v;
/* 存储整型 */
v.type = TYPE_INT;
v.data.i_val = 42;
print_variant(&v);
/* 存储浮点 */
v.type = TYPE_FLOAT;
v.data.f_val = 3.14f;
print_variant(&v);
/* 存储字符串 */
v.type = TYPE_STRING;
strcpy(v.data.s_val, "Hello Union!");
print_variant(&v);
printf("sizeof(struct Variant) = %zu\n", sizeof(struct Variant));
/* 大小 = enum(4) + 最大联合体成员(32) + 对齐填充 = 36 或 40 */
return 0;
}这个模式的关键纪律:每次给联合体赋值前,必须先更新 type。读的时候,只能根据 type 访问对应的联合体成员——如果你把 type 设为 TYPE_INT 却去读 data.f_val,得到的就是垃圾值(或者更糟,是上一个 float 留下的位模式被当 int 读)。这也是"标签"存在的意义:它把"这块内存里现在是什么类型"这个信息显式记录下来,让联合体的使用变安全。
总结
联合体让你在"内存效率"和"类型安全"之间找到平衡——所有成员共享同一块空间,用标签来记录"此刻哪个成员有效"。枚举让你的代码从"数字代码"变成"人类可读的故事"——有类型检查、调试友好、作用域可控。它们不像结构体那样无处不在,但在正确的场景下使用,效果立竿见影。
最后还有几个容易踩的坑值得一提:联合体同一时刻只有一个成员有效,给一个新成员赋值会覆盖其他成员;联合体的初始化在 C89 中只能作用于第一个成员(C99 允许指定初始化器 { .i = 42 });枚举常量不仅可以是连续递增的,你还可以手动指定任意整数值,也可以赋相同的值(虽然没什么意义);虽然 C 语言允许 enum Color c = 5;,但这破坏了类型安全,如果未来要迁移到 C++,现在就养成好习惯。
一句话总结:联合体是"内存的复用",枚举是"名字的复用"——前者省空间,后者省脑力。两者结合(标签联合体),就构成了 C 语言实现"可变的、类型安全的复合数据"的完整方案,这也是你之后学习 JSON 解析器、AST 节点、协议变体等真实项目代码的必备基础。
思考题
union { char c[9]; double d; }的 sizeof 是多少?如果成员是char c[9]; long long ll;呢?(提示:对齐数)- 联合体初始化的三种方式:
= {0}、= { .i = 42 }、= {42}分别有什么含义?它们等价吗? - 为什么说"给联合体的新成员赋值会覆盖其他成员的值"?这和大小端有关系吗?
enum Color { RED = 5, GREEN, BLUE };中 GREEN 和 BLUE 的值分别是多少?#define和enum在什么场景下必须用#define?(提示:编译期条件判断#ifdef)- 用联合体实现一个函数
unsigned long float_to_bits(float f),把 float 的位模式转成 unsigned long(提示:联合体成员共享内存)。 - 设计一个"标签联合体"表示几何图形:圆形(半径)和矩形(长、宽),并用 switch 计算面积。
思考题参考答案
1. union { char c[9]; double d; } 的 sizeof 是多少?如果成员是 char c[9]; long long ll; 呢?
两个都是 16(在 64 位平台上)。联合体的大小 = "至少最大成员大小" 然后对齐到"最大对齐数"的整数倍:
union { char c[9]; double d; }:c[9]大小 9、对齐数 1;double大小 8、对齐数 8。最大成员大小 = 9,最大对齐数 = 8。9 不是 8 的倍数,补到 16。union { char c[9]; long long ll; }:ll在 64 位平台为 8 字节、对齐数 8。最大成员大小仍 = 9,最大对齐数仍 = 8,同样补到 16。
注意 long long 的对齐依赖平台:在少数 32 位实现上,long long 可能是 8 字节大小但对齐到 4,此时最大对齐数 = 4,9 对齐到 4 的倍数就是 12。但主流的 64 位平台(你平时编译运行的环境)两个结果都是 16。这再次说明:联合体的大小不是简单取最大成员大小,还要满足对齐约束——本讲正文 Un1=8、Un2=16 解释的正是这一规则。
2. 联合体初始化的三种方式:= {0}、= { .i = 42 }、= {42} 分别有什么含义?它们等价吗?
= {0}:C89 的风格,初始化作用于第一个成员。对union { int i; char c; }而言等价于un.i = 0(若第一个成员是i)。= { .i = 42 }:C99 的指定初始化器,明确把名为i的成员初始化为 42。它不依赖成员的声明顺序,指向性最强。= {42}:C99 的位置初始化,按声明顺序作用于第一个成员。对第一个成员为i的联合体,等价于un.i = 42。
它们并不总是等价。只有当"你想初始化的成员恰好就是第一个成员"时,= {0}、= {42} 和 = { .第一个成员 = 值 } 的语义才一致。举例:union { int i; char c; } 里,= {0} 是 i=0,= {.i=42} 是 i=42,= {42} 是 i=42——三者的效果并不相同。所以在初始化联合体时,最推荐显式用指定初始化器 = { .成员 = 值 },避免"位置初始化命中第一个成员"带来的歧义。
3. 为什么说"给联合体的新成员赋值会覆盖其他成员的值"?这和大小端有关系吗?
因为联合体的所有成员从同一个起始地址、共享同一块内存——给任意成员写值,本质上就是往这块共享内存里写字节,自然会覆盖先前其他成员写下的内容,所以"同一时刻只有一个成员有效"。
这跟"覆盖"这个机制本身无关(无论大小端,写这块内存都会覆盖),但具体覆盖哪几个字节、读出来是什么,和大小端有关。以 union { int i; char c; } 为例,先 un.i = 0x11223344 再 un.c = 0x55:小端机器上 char 恰好落在这块内存的最低字节,把 0x44 覆盖成 0x55,un.i 读成 0x11223355(本讲正文也就是这么演示的);若换到大端机器,un.c 会覆盖的是最高字节,un.i 读成 0x55223344。所以"覆盖关系"是联合体的固有特性,而"覆盖发生在哪个字节"由字节序决定。
4. enum Color { RED = 5, GREEN, BLUE }; 中 GREEN 和 BLUE 的值分别是多少?
GREEN = 6,BLUE = 7。枚举常量的默认规则是:第一个没显式赋值的常量从 0 开始,后续依次 +1;而一旦某处手动指定了值,之后的常量就从该值开始继续递增。这里 RED = 5,所以 GREEN = 5 + 1 = 6,BLUE = 6 + 1 = 7。如果中间再出现新的显式赋值,则以那个新值为起点继续递增。这是枚举最常见的取值规则,务必记牢。
5. #define 和 enum 在什么场景下必须用 #define?
必须用 #define 的场景是预处理阶段就要进行判断/替换的地方,枚举常量的值是编译期(编译器语义分析阶段)才确立的,预处理阶段根本看不到它。典型就是条件编译:
#ifdef USE_LOGGING /* 判断"符号是否被定义" */
#define LOG(fmt, ...) /* ... */
#else
#define LOG(...) /* 什么也不做 */
#endif在 #if / #elif 中进行常量数值比较时也同样只能用宏常量,不能用枚举常量——因为 #if 里的表达式是在预处理器阶段求值的,那时枚举常量还没建立。此外需要"纯文本替换"的场合(如拼接 token、给函数/变量起别名)也只能靠宏。而像 case 标签、数组大小这类"编译期常量表达式",枚举常量完全满足(它本质是 int 常量),此时用 enum 更好,因为带类型检查、可读、可调试。
6. 用联合体实现一个函数 unsigned long float_to_bits(float f),把 float 的位模式转成 unsigned long。
利用联合体"成员共享内存"的特性,把 float 和整数放进同一联合体,写 float、读整数即可:
#include <stdio.h>
/* 用联合体在 float 和 unsigned long 之间复用同一块内存 */
union FloatInt
{
float f;
unsigned long u;
};
unsigned long float_to_bits(float f)
{
union FloatInt u;
u.f = f; /* 以 float 写入 4 字节 */
return u.u; /* 以整数读出同样的位模式 */
}
int main()
{
printf("%lx\n", float_to_bits(9.0f)); /* 0x41100000 */
printf("%lx\n", float_to_bits(1.0f)); /* 0x3f800000 */
return 0;
}9.0f 的 IEEE 754 位模式是 0x41100000,1.0f 是 0x3F800000——把它们当作 unsigned long 读出正是这两串十六进制。这正是上一讲"同一个内存,不同的解读规则"的又一次落地,也避免了用 & 强转指针的写法。注意:unsigned long 在本方法中只需能容纳 float 的 4 字节(绝大多数平台都≥4 字节),打印用 %lx 与之匹配。
7. 设计一个"标签联合体"表示几何图形:圆形(半径)和矩形(长、宽),并用 switch 计算面积。
用枚举记录图形类型,用联合体存具体参数,再配合 switch 计算面积:
#include <stdio.h>
/* 图形类型 */
enum ShapeType
{
SHAPE_CIRCLE,
SHAPE_RECT
};
/* 标签联合体:type 记录类型,shape 存参数 */
struct Shape
{
enum ShapeType type;
union
{
struct { double radius; } circle; /* 圆:半径 */
struct { double length, width; } rect; /* 矩形:长、宽 */
} data;
};
/* 计算面积 */
double area(const struct Shape *s)
{
switch (s->type)
{
case SHAPE_CIRCLE:
return 3.141592653589793 * s->data.circle.radius
* s->data.circle.radius;
case SHAPE_RECT:
return s->data.rect.length * s->data.rect.width;
default:
return 0.0; /* 未知类型,防御性返回 */
}
}
int main()
{
struct Shape c = { SHAPE_CIRCLE, { .circle = { .radius = 2.0 } } };
struct Shape r = { SHAPE_RECT, { .rect = { .length = 3.0, .width = 4.0 } } };
printf("圆面积: %.2f\n", area(&c)); /* 12.57 */
printf("矩形面积: %.2f\n", area(&r)); /* 12.00 */
return 0;
}这里的关键纪律和正文的 Variant 完全一致:写图形参数前先写对 type,读参数时 switch 严格按照 type 分支访问对应成员。若把 type 设为 SHAPE_CIRCLE 却去读 data.rect,读到的是同一块内存里被按 int 攒起来的垃圾位模式——结果不可预测。这正是"标签"让联合体变得类型安全的缘由,也是你在 JSON 解析器、AST 节点等真实结构里频繁用到的 C 经典模式。
还没有评论 — 第一条由你来留。