假设你在做一个活动系统,商品有三种类型:图书(需要书名、作者、页数)、杯子(只需要设计图案)、衬衫(需要设计图案、颜色、尺码)。如果你用一个结构体包含所有可能的属性,你会发现大部分时候有大量字段是闲置的——图书不需要"颜色",杯子不需要"页数"。每种商品只用到了全部属性中的一小部分,却因为结构体的"全或无"特性而白白浪费了内存。

这就引出了本讲的两个主角:联合体(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 个字节按字节读出——同一个内存,两种解读。这就是联合体的本质价值:不用强转指针,就能以不同视角观察同一块内存。

联合体大小的计算

联合体大小的规则比结构体简单,但需要注意对齐:

  1. 联合体的大小至少是最大成员的大小
  2. 如果最大成员的大小不是"最大对齐数"的整数倍,就要对齐到最大对齐数的整数倍
#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 } 有区别吗?有,而且区别很大:

维度#defineenum
类型检查无(纯文本替换)有(编译器检查类型)
调试友好预处理器已替换,调试器看不到符号调试器中可见枚举常量名
作用域全局(从定义处到文件末尾)遵循 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 节点、协议变体等真实项目代码的必备基础。

思考题

  1. union { char c[9]; double d; } 的 sizeof 是多少?如果成员是 char c[9]; long long ll; 呢?(提示:对齐数)
  2. 联合体初始化的三种方式:= {0}、= { .i = 42 }、= {42} 分别有什么含义?它们等价吗?
  3. 为什么说"给联合体的新成员赋值会覆盖其他成员的值"?这和大小端有关系吗?
  4. enum Color { RED = 5, GREEN, BLUE }; 中 GREEN 和 BLUE 的值分别是多少?
  5. #define 和 enum 在什么场景下必须用 #define?(提示:编译期条件判断 #ifdef)
  6. 用联合体实现一个函数 unsigned long float_to_bits(float f),把 float 的位模式转成 unsigned long(提示:联合体成员共享内存)。
  7. 设计一个"标签联合体"表示几何图形:圆形(半径)和矩形(长、宽),并用 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 经典模式。