从C到Rust:为什么不需要union?Rust更优雅的设计

0 阅读16分钟

枚举与联合体 —— C 的分离与不安全 vs Rust 的统一与安全

C 的 enum(命名整数常量)和 union(类型不安全的重叠存储)是分离的,

"带标签的联合体"需要程序员手动组合两者,极易出错。

Rust 的 enum 是代数数据类型(ADT)——每个变体可以携带不同类型的数据,

并通过 match 提供穷尽性检查,从根本上消除了 C 的标签-数据不匹配问题。


一、C 的 enum:看起来像类型,实际上是整数

1.1 C 的 enum 就是整数常量


// C 中的枚举

enum Color {

RED, // = 0

GREEN, // = 1

BLUE // = 2

};

  


// 但实际上就是整数:

enum Color c = RED;

int i = c; // ✅ 隐式转换为 int(c = 0)

c = 42; // ✅ 可以赋值任意整数——编译器只给 warning(或不给)

c = 1000; // ✅ 1000 不在 Color 定义中,但完全合法

C 的 enum 没有类型安全。 它可以被赋予任何整数值,编译器最多发一个 warning,在默认编译选项下甚至没有 warning。

1.2 enum 不能携带数据


// C 中无法表达"一个值可以是整数、字符串或错误码":

enum ValueType {

VAL_INT, // 只能表示"这是一个整数",不能携带整数值

VAL_STRING, // 只能表示"这是一个字符串",不能携带字符串指针

VAL_ERROR // 只能表示"这是一个错误",不能携带错误码

};

// 要携带数据,需要和 union 配合——但那是手动的、不安全的

1.3 switch 没有穷尽性检查


enum Color { RED, GREEN, BLUE };

  


const char* color_name(enum Color c) {

switch (c) {

case RED: return "red";

case GREEN: return "green";

// ❌ 忘了 BLUE!编译器不报错(除非开启 -Wswitch 且启用)

}

// 如果传入了 BLUE——未定义行为(返回什么?)

}

大多数 C 编译器在默认设置下不会检查 switch 是否覆盖了所有枚举值。即使开启了 -Wswitch,也只是一个 warning——可以被忽略。

1.4 enum 的大小不确定


enum Small { A, B, C };

enum Large { X = 0xFFFFFFFF, Y }; // 需要 32 位

  


// sizeof(enum Small) 是多少?

// 可能是 1、2、4——取决于编译器实现

// C 标准只说了"足够容纳所有枚举值",没指定具体大小

1.5 C 的 enum 总结

| 问题 | 说明 |

|------|------|

| 类型不安全 | 可以赋任意整数值,编译器不阻止 |

| 不能携带数据 | 只是一个整数标签,没有数据附着 |

| 无穷尽性检查 | switch 漏掉变体,编译器默认不报错 |

| 大小不确定 | 实现可以自由选择底层整数类型 |


二、C 的 union:共享内存,类型不安全

2.1 union 的本质

C 的 union 让多个变量共享同一块内存:


union Data {

int i;

float f;

char str[4];

};

  


// 所有成员共享同一块内存(4 字节)

union Data d;

d.i = 42; // 写入 int

printf("%d", d.i); // ✅ 42

  


d.f = 3.14; // 写入 float——覆盖了 i 的内存

printf("%d", d.i); // ❌ 未定义行为!读取 float 的内存作为 int

union 不知道当前哪个成员是"活跃的"。 你可以写入一个成员,然后读取另一个——编译器不会阻止,但这是未定义行为(或实现定义的行为)。

2.2 union 的主要问题


// 问题 1:类型不安全——可以读任何成员

union Data d;

d.i = 42;

printf("%f", d.f); // ❌ 编译通过,但行为未定义

  


// 问题 2:没有判别式——当前"活跃"的成员是什么?

// 没人知道,全靠程序员记住

  


// 问题 3:复杂数据结构中的 union 可能被误用

struct Message {

enum MsgType type; // 判别式

union {

int int_val;

char* str_val;

double double_val;

} data; // 数据

};

  


struct Message m;

m.type = MSG_INT;

m.data.int_val = 42;

  


// 但没有任何东西阻止你这样做:

m.data.str_val = "hello"; // 即使 type 还是 MSG_INT!

print_message(m); // 内部根据 type=MSG_INT 读取 int_val

// 但实际存的是 char*——灾难

2.3 C 中"带标签的联合体"的手动模式

C 程序员为了解决"一个值可以是多种类型之一"的问题,手动组合 enum + union + struct:


enum ShapeType {

SHAPE_CIRCLE,

SHAPE_RECT,

SHAPE_TRIANGLE,

};

  


struct Shape {

enum ShapeType type; // 标签——告诉 union 中哪个是活跃的

union {

struct { double radius; } circle;

struct { double w, h; } rect;

struct { double a, b, c; } triangle;

} data; // 数据——根据 type 决定读哪个成员

};

这个模式的问题:


// 问题 1:标签和数据可能不一致

struct Shape s;

s.type = SHAPE_CIRCLE;

s.data.rect.w = 10; // ❌ type 是 CIRCLE,但写入了 rect 的数据

s.data.rect.h = 20;

area(&s); // 按 CIRCLE 读取 radius——读到的是 w 的位模式

  


// 问题 2:增加新变体时,需要手动找到所有 switch

// 如果有 50 个地方 switch(s->type),必须一个一个改

// 漏掉一个就是 bug,编译器不报错

  


// 问题 3:每个 Shape 都需要额外的 type 字段(内存开销)

// sizeof(struct Shape) = sizeof(enum ShapeType) + sizeof(union {...}) + padding

2.4 C 的 enum + union 总结

| 问题 | 说明 |

|------|------|

| 标签-数据不匹配 | type 说 CIRCLE,但 data 存的是 rect——编译通过,运行出错 |

| 类型不安全 | 可以从错误的 union 成员读取,编译器不阻止 |

| 无穷尽性检查 | 新增变体后,漏掉某个 switch 分支不报错 |

| 内存浪费 | tag 和 data 分开存储,额外的 padding 和对齐开销 |

| 工程负担 | 需要手动维护 tag 和 data 的一致性,每个 switch 都要手动更新 |


三、Rust 的 enum:统一了标签与数据

3.1 枚举变体可以携带数据

Rust 的 enum 直接解决了 C 中 enum + union 分离的问题——每个变体可以携带不同类型的数据


enum Shape {

Circle { radius: f64 }, // 变体携带 struct 字段

Rect { w: f64, h: f64 },

Triangle { a: f64, b: f64, c: f64 },

}

  


// 使用时,标签和数据始终在一起:

let c = Shape::Circle { radius: 5.0 };

let r = Shape::Rect { w: 3.0, h: 4.0 };

标签和数据永远不会不一致——因为它们是一起定义的、一起创建的。 不存在 C 中那种"type 是 CIRCLE 但 data 存的是 rect"的情况。

3.2 更丰富的变体形式

Rust 的枚举变体可以有三种形式:


// 1. 无数据变体(类似 C 的 enum)

enum Color {

Red,

Green,

Blue,

}

  


// 2. 元组变体(匿名数据)

enum Status {

Ok, // 无数据

Err(String), // 单个字符串

Code(i32, String), // 两个匿名值

}

  


// 3. 结构体变体(具名字段)

enum Message {

Quit, // 无数据

Move { x: i32, y: i32 }, // 命名字段

Write(String), // 元组变体

ChangeColor { r: u8, g: u8, b: u8 }, // 命名字段

}

3.3 类型安全的保证


// Rust 中不能给枚举赋任意整数值:

let c: Color = Color::Red;

// let c: Color = 42; // ❌ 编译错误:42 不是 Color

  


// 不能从错误的变体读取数据:

let s = Shape::Circle { radius: 5.0 };

// println!("{}", s.w); // ❌ 编译错误:Circle 没有 w 字段

3.4 内存布局:比 C 更高效

Rust 编译器会对枚举的内存布局进行优化:


enum OptionalInt {

Some(i32),

None,

}

  


// Rust 不会存储为 "tag (4 字节) + data (4 字节) + padding"

// 而是利用"空指针优化"(niche optimization):

// None 用 i32 中不可能的值来表示(如 i32::MAX + 1 不可能的值)

// 所以 OptionalInt 的大小 == i32 的大小 == 4 字节

// 不需要额外的 tag!

  


use std::mem::size_of;

assert_eq!(size_of::<OptionalInt>(), 4);

// 和 size_of::<i32>() 一样!没有额外开销

  


// 更常见的例子:

assert_eq!(size_of::<Option<&i32>>(), size_of::<&i32>());

// Option<&T> 和 &T 一样大——None 用 NULL 指针表示

// C 中用 NULL 表示"没有值",但类型系统不知道

// Rust 中 Option<&T> 明确区分 Some/None,且零额外开销

Rust 的 enum 不仅比 C 的 enum+union 更安全,而且往往更紧凑。 没有 tag 字段、没有 padding、没有"两个地方存信息"。

3.5 Option<T>Result<T, E>——最经典的枚举

标准库中最常用的两个枚举,都是 Rust 的 enum:


// 可能不存在

enum Option<T> {

None,

Some(T),

}

  


// 可能失败

enum Result<T, E> {

Ok(T),

Err(E),

}


// 使用 Option:

let name: Option<String> = find_user(id);

match name {

Some(n) => println!("找到: {}", n),

None => println!("未找到"),

}

  


// 使用 Result:

let file: Result<File, Error> = File::open("config.txt");

match file {

Ok(f) => process(f),

Err(e) => println!("打开失败: {}", e),

}

在 C 中,"可能不存在"用 NULL 表示,"可能失败"用返回值 + errno 表示——都是运行时的约定,编译器不检查。Rust 用 OptionResult 把这些情况编码到类型系统中——必须在编译期处理它们


四、补充:Rust 的 union——只有必要时才用

Rust 也有 union,但它被标记为 unsafe,且很少使用:


// Rust 的 union

union Data {

i: i32,

f: f32,

}

  


let d = Data { i: 42 };

// println!("{}", d.f); // ❌ 不安全的!不能直接读非活跃成员

// 需要 unsafe:

unsafe {

println!("{}", d.f); // ⚠️ 程序员保证 d 中当前是 f32

}

在 Rust 中,99% 的情况下你不需要 union——用 enum 就好了。 union 只在以下场景需要:

  • FFI 调用 C 的 union

  • 极低级别的性能优化(和 C 交互时复用内存)


五、match:模式匹配的穷尽之美

5.1 基本 match

match 是 Rust 中处理枚举的主要方式:


enum Shape {

Circle { radius: f64 },

Rect { w: f64, h: f64 },

}

  


fn area(shape: Shape) -> f64 {

match shape {

Shape::Circle { radius } => 3.14159 * radius * radius,

Shape::Rect { w, h } => w * h,

}

// 编译器检查:所有变体都已覆盖?

// 是——编译通过 ✅

// 否——编译错误 ❌(穷尽性检查)

}

5.2 穷尽性检查(Exhaustiveness)

这是 match 最重要的特性:编译器确保所有可能性都被处理。


fn area(shape: Shape) -> f64 {

match shape {

Shape::Circle { radius } => 3.14159 * radius * radius,

// ❌ 编译错误:没有处理 Shape::Rect

}

}

// error: non-exhaustive patterns: `Rect` not covered

对比 C 的 switch:


// C —— 漏掉变体,编译器不报错

double area(enum ShapeType type, union ShapeData data) {

switch (type) {

case SHAPE_CIRCLE:

return 3.14159 * data.circle.radius * data.circle.radius;

// ❌ 忘了 SHAPE_RECT——编译器沉默

// 运行到 RECT 时:返回未初始化的值,或执行错误的计算

}

}

5.3 _ 通配符

当你只关心部分变体时,可以用 _ 处理其余:


fn describe(c: Color) -> &'static str {

match c {

Color::Red => "红色",

_ => "不是红色", // 处理 Green 和 Blue

}

}

但要注意:如果之后 Color 新增一个变体:


// 新增后:enum Color { Red, Green, Blue, Yellow }

  


fn describe(c: Color) -> &'static str {

match c {

Color::Red => "红色",

_ => "不是红色", // ✅ 仍然能编译——通配符覆盖了新变体

// 但逻辑可能是错的——Yellow 被归入了"不是红色"

}

}

通配符让你失去穷尽性检查的保护。 好的做法是尽量显式列出所有变体,只在确实不需要区分时使用 _

5.4 模式匹配的多种形式


// 1. 匹配字面量

let x = 42;

match x {

0 => "零",

1 | 2 => "一或二", // 多模式用 | 连接

3..=10 => "三到十", // 范围匹配(..= 包含右端点)

_ => "其他",

}

  


// 2. 解构结构体

struct Point { x: i32, y: i32 }

let p = Point { x: 10, y: 20 };

match p {

Point { x, y } => println!("({}, {})", x, y),

Point { x: 0, y } => println!("x=0, y={}", y), // 只匹配 x=0

}

  


// 3. 解构枚举

enum Message {

Move { x: i32, y: i32 },

Write(String),

}

match msg {

Message::Move { x, y } => move_to(x, y),

Message::Write(text) => println!("{}", text),

}

  


// 4. 嵌套解构

match shape {

Shape::Circle { radius } if radius > 10.0 => "大圆",

Shape::Circle { .. } => "小圆",

Shape::Rect { w, h } => "矩形",

}

5.5 守卫(match guards)

在模式后面加 if 条件:


fn classify(n: i32) -> &'static str {

match n {

x if x < 0 => "负数",

0 => "零",

x if x > 0 && x <= 100 => "小正数",

_ => "大正数",

}

}

守卫让你在模式匹配的基础上增加条件判断。

5.6 @ 绑定

当你需要同时匹配模式结构和绑定值:


enum OptionalInt {

Some(i32),

None,

}

  


match val {

Some(x @ 0..=10) => println!("小数字: {}", x),

Some(x @ 11..=100) => println!("中等数字: {}", x),

Some(x) => println!("大数字: {}", x),

None => println!("没有值"),

}

5.7 if let——只关心一个变体时的简洁写法


let color = Some(Color::Red);

  


// match 写法——需要写通配符

match color {

Some(c) => println!("有颜色: {:?}", c),

None => {} // 必须写

}

  


// if let 写法——更简洁

if let Some(c) = color {

println!("有颜色: {:?}", c);

}

// 没有 else 分支,不处理 None——简洁但牺牲穷尽性

5.8 while let——循环中的模式匹配


let mut stack = vec![1, 2, 3];

  


while let Some(top) = stack.pop() {

println!("{}", top);

}

// 弹出所有元素,直到 stack.pop() 返回 None

5.9 match 是表达式

Rust 中 match 是表达式(有返回值),不是语句:


let description = match shape {

Shape::Circle { radius } => format!("半径 {} 的圆", radius),

Shape::Rect { w, h } => format!("{}x{} 的矩形", w, h),

};

// match 的结果赋给了 description

这比 C 的 switch(没有返回值,需要手动赋值)更简洁、更安全:


// C —— switch 是语句,需要手动管理

const char* description;

switch (type) {

case SHAPE_CIRCLE:

// 需要分配字符串、手动管理内存

// 忘记 break 会 fall-through

break;

case SHAPE_RECT:

break;

}

5.10 match 的编译期优化

Rust 编译器会对 match 进行优化——不是简单的 if-else 链:


// 对于简单的枚举,match 被编译为跳转表(和 C 的 switch 一样高效)

match c {

Color::Red => 0,

Color::Green => 1,

Color::Blue => 2,

}

// 编译为:jmp [jump_table + c * sizeof(pointer)]


六、C vs Rust 完整对比

6.1 对比表

维度C(enum + union)Rust(enum)
标签与数据分离(两个地方)统一(变体自带数据)
类型安全无——可赋任意整数、读任意成员有——类型严格,不能乱读
穷尽性检查无——switch 漏掉变体不报错有——match 必须覆盖所有变体
携带数据不能——需要额外 union可以——每个变体有自己的类型
内存布局tag + union + padding(可能冗余)可能优化为 None + niche(更紧凑)
标签-数据一致性手动维护(容易出错)编译器保证
新增变体时的安全性需要手动更新所有 switch——漏了不报错编译器指出所有未覆盖的 match
处理"没有值"NULL(类型不安全)Option<T>(类型安全)
处理"可能失败"返回值 + errno(类型不安全)Result<T, E>(类型安全)
内存效率tag(可能 4 字节)+ union + padding空指针优化:Option<&T> = &T 大小

6.2 同一个功能:C vs Rust


// C —— enum + union + switch

enum JsonValueType {

JSON_NULL, JSON_BOOL, JSON_INT, JSON_STRING, JSON_ARRAY

};

  


struct JsonValue {

enum JsonValueType type; // 标签

union { // 数据

int bool_val;

long long int_val;

char* str_val;

struct JsonValue* array_val;

} data;

};

  


// 使用:

void print_json(struct JsonValue* v) {

switch (v->type) { // 如果漏掉一个变体——编译器沉默

case JSON_NULL: printf("null"); break;

case JSON_BOOL: printf("%s", v->data.bool_val ? "true" : "false"); break;

case JSON_INT: printf("%lld", v->data.int_val); break;

// 忘了 JSON_STRING 和 JSON_ARRAY

}

// 如果传入了 JSON_STRING——什么都不打印(bug)

}


// Rust —— enum + match

enum JsonValue {

Null,

Bool(bool),

Int(i64),

String(String),

Array(Vec<JsonValue>),

// 新增变体时:编译器会告诉你所有 match 需要更新

}

  


fn print_json(v: &JsonValue) {

match v {

JsonValue::Null => print!("null"),

JsonValue::Bool(b) => print!("{}", b),

JsonValue::Int(i) => print!("{}", i),

JsonValue::String(s) => print!("\"{}\"", s),

JsonValue::Array(arr) => {

print!("[");

for item in arr {

print_json(item);

}

print!("]");

}

}

// ✅ 所有变体都已覆盖——编译通过

// 如果 JSON_NULL 被改名为 JsonValue::Null——编译器报错,提示所有使用点

}


七、与 C 程序员的对话

"C 的 enum + union 我用了二十年,没出过问题"

C 程序员:"你说的这些 C 的 enum 和 union 的问题,我早就知道了,写代码时候注意就好了。带标签的联合体模式我用了二十年,没出过问题。"

Rust:"我信你。但问题不在你,在 C 本身:C 把"标签-数据一致性"的责任完全交给了你,但它不给你任何工具来保证一致性。你靠的是经验和纪律——但经验和纪律是人的维度,不是语言的维度。团队里不是每个人都有你的经验,而且你三个月后回来改代码也不一定记得当时的全部约定。"


// C —— "注意就好"

struct JsonValue* parse_json(const char* input);

int get_int(struct JsonValue* v) {

// "作为 parse_json 的使用者,你应该知道返回的是什么类型"

// 但如果你不知道呢?或者 parse_json 的实现在某个版本中变了?

return v->data.int_val; // ❌ 可能 type 是 JSON_STRING,但读了 int

}


// Rust —— 编译器帮你记住

fn parse_json(input: &str) -> Result<JsonValue, Error>;

  


fn get_int(v: &JsonValue) -> Option<i64> {

match v {

JsonValue::Int(i) => Some(*i), // ✅ 只有 Int 变体才读整数

_ => None, // 其他变体都返回 None

}

}

"Rust 的 match 不就是 switch 吗?"

C 程序员:"match 看起来就是 switch 换个写法,有什么区别?"

Rust:"区别在三个地方:穷尽性(编译器检查所有变体是否已覆盖)、统一标签和数据(不需要 separate enum + union)、match 是表达式(有返回值)。C 的 switch 漏掉一个 case 不报错,Rust 的 match 漏掉一个变体不编译。"


// C —— 漏掉了,编译器沉默

switch (type) {

case INT: process_int(val); break;

case FLOAT: process_float(val); break;

// 忘了 STRING——编译器不报错

}


// Rust —— 漏掉了,编译器报错

match value {

Value::Int(i) => process_int(i),

Value::Float(f) => process_float(f),

// ❌ 编译错误:Value::String 没有被覆盖

}

// error: non-exhaustive patterns: `String` not covered

"union 不是很好用吗?怎么就成了不安全的了?"

C 程序员:"C 的 union 方便啊,同一块内存可以用不同的方式解读——写硬件驱动、网络协议解析的时候特别有用。"

Rust:"Rust 也有 union——但要加 unsafe。这恰好是正确的区隔:当你确实需要'同一块内存用不同方式解读'时(硬件寄存器、协议头解析),Rust 允许你这么做——但必须显式标记 unsafe。99% 的日常编码中你不需要这种能力,enum 就够了。"


// C —— union 用于任何地方,包括不需要的地方

union Data { int i; float f; };

void process(union Data d) {

// 这个函数接受 int 还是 float?——不知道

// 调用方的意图是什么?——不知道

}


// Rust —— 99% 的场景用 enum,union 只用在不安全/FFI 场景

enum Data {

Int(i32),

Float(f32),

}

  


fn process(data: Data) {

match data {

Data::Int(i) => { /* i 是 i32 */ }

Data::Float(f) => { /* f 是 f32 */ }

}

}


八、小结

8.1 C 的问题


C 的 enum:只是命名整数

→ 类型不安全、不能携带数据、switch 无穷尽性检查

  


C 的 union:内存重叠的多个类型

→ 类型不安全、不知道哪个成员活跃、读取错误成员是 UB

  


C 的 enum + union 组合(带标签联合体)

→ 标签和数据分离 → 可能不一致

→ 手动维护 → 新增变体容易遗漏

→ 编译器不帮助检查 → 纯靠人的纪律

8.2 Rust 的解决


Rust 的 enum = 代数数据类型

→ 每个变体可携带不同类型的数据

→ 标签和数据统一 → 不可能不一致

→ 类型安全 → 不能赋任意整数,不能读错误的变体

  


Rust 的 match = 带有穷尽性检查的模式匹配

→ 编译器检查所有变体已覆盖

→ 新增变体时,编译器指出所有需要修改的 match

→ match 是表达式,有返回值

→ 支持解构、守卫、@绑定、范围匹配

8.3 一句话总结

C 把"一个值可以是多种类型之一"拆成了三个分离的概念——enum(标签)、union(数据)、switch(检查)——程序员必须手动维护它们之间的一致性,编译器不提供任何帮助。Rust 用一个统一的 enum(变体自带数据)+ match(穷尽性检查)解决了这个问题——标签和数据永不分离,编译器检查每一个使用点,新增变体时不会再有遗漏。