跳转到主内容枚举和联合体 | 物联网民工

枚举和联合体

C 里 enum 和 union 经常一起出现。enum 表示当前是哪种类型,union 复用同一块内存保存不同数据:

enum ValueKind {
    VALUE_INT,
    VALUE_FLOAT,
    VALUE_TEXT,
};

struct Value {
    enum ValueKind kind;
    union {
        int i;
        float f;
        const char *text;
    } data;
};

这是 C 里的 tagged union。它很常见,也很有用,但正确性靠纪律:

if (value->kind == VALUE_INT) {
    printf("%d\n", value->data.i);
}

如果 kind 是 VALUE_TEXT,却去读 data.i,C 类型系统不会拦住你。Rust 处理这类问题时,优先用 enum 把 tag 和数据放进同一个类型。

C enum 更像有名字的整数

C 的 enum 常用来替代裸数字:

enum State {
    STATE_IDLE,
    STATE_READING,
    STATE_ERROR,
};

这比 0、1、2 清楚。但第一层可以把它理解成“有名字的整数常量集合”。很多 C 编译器会把它按整数处理,代码里也常常和 switch 配套:

switch (state) {
case STATE_IDLE:
    break;
case STATE_READING:
    break;
case STATE_ERROR:
    break;
}

如果状态只需要一个名字,没有额外数据,Rust 也可以写成类似形式:

enum State {
    Idle,
    Reading,
    Error,
}

处理分支用 match:

fn describe(state: State) -> &'static str {
    match state {
        State::Idle => "idle",
        State::Reading => "reading",
        State::Error => "error",
    }
}

match 会做穷尽检查。新增一个状态后,旧的 match 没处理它就会编译失败。

C union 只管复用内存

C 的 union 表示多个字段占用同一块存储:

union Number {
    int i;
    float f;
};

它不记录当前有效的是哪个字段。写入 i 后再读 f,语义要看具体场景、平台和规则。union 本身没有 tag。

所以真实代码里经常会把 enum 和 union 包在一个 struct 里:

enum TokenKind {
    TOKEN_INT,
    TOKEN_IDENT,
};

struct Token {
    enum TokenKind kind;
    union {
        int int_value;
        const char *ident;
    } data;
};

这就是 tagged union。kind 是 tag,data 是 payload。

问题是:tag 和 payload 的关系仍然靠程序员维护。类型允许你写出不一致的数据:

struct Token t;
t.kind = TOKEN_INT;
t.data.ident = "abc";

这个对象从业务语义看是坏的,但 C 的类型结构表达不出“TOKEN_INT 时只能读 int_value”。

Rust enum 把 tag 和数据绑在一起

Rust 直接把这种结构写成一个 enum:

enum Token {
    Int(i32),
    Ident(String),
}

这里没有单独的 kind 字段,也没有独立的 union data。每个变体自己带数据:

  • Token::Int(i32) 才有整数值
  • Token::Ident(String) 才有标识符文本

构造时:

let a = Token::Int(42);
let b = Token::Ident(String::from("name"));

你构造不出“tag 是 Int,但 payload 是字符串”的值,因为 Rust enum 没有把 tag 和 payload 分开放。

这就是 Rust enum 和 C enum 最大的差别:Rust enum 更接近安全的 tagged union。

match 取出对应数据

C 里要先判断 tag,再按约定读 union 字段:

switch (token->kind) {
case TOKEN_INT:
    printf("%d\n", token->data.int_value);
    break;
case TOKEN_IDENT:
    printf("%s\n", token->data.ident);
    break;
}

Rust 里 match 同时完成判断和解包:

fn print_token(token: &Token) {
    match token {
        Token::Int(value) => println!("{value}"),
        Token::Ident(name) => println!("{name}"),
    }
}

在 Token::Int 分支里拿到的是整数,在 Token::Ident 分支里拿到的是字符串。分支和数据是一一对应的。

如果以后增加一种 token:

enum Token {
    Int(i32),
    Ident(String),
    End,
}

原来的 match 会提示没有处理 Token::End。这比 C 里忘记更新某个 switch 更早暴露问题。

状态机也适合用携带数据的 enum

C 状态结构经常写成这样:

enum ConnState {
    CONN_DISCONNECTED,
    CONN_CONNECTING,
    CONN_CONNECTED,
    CONN_ERROR,
};

struct Connection {
    enum ConnState state;
    int fd;
    int retry_count;
    int error_code;
};

所有字段一直都在,但不是每个状态都需要:

  • DISCONNECTED 时 fd 是否有效?
  • CONNECTING 时 error_code 是否有效?
  • CONNECTED 时 retry_count 还有没有意义?
  • ERROR 时 fd 要不要关闭?

Rust 可以写成:

enum Connection {
    Disconnected,
    Connecting { retry_count: u8 },
    Connected { fd: i32 },
    Error { code: i32 },
}

现在 Disconnected 根本没有 fd 字段,Error 分支才有 code。很多非法组合从类型上就表达不出来。

Option 和 Result 也是 enum

C 经常用特殊值表示“没有”:

int find_index(const int *items, size_t len, int target)
{
    for (size_t i = 0; i < len; i++) {
        if (items[i] == target) {
            return (int)i;
        }
    }
    return -1;
}

Rust 用 Option:

fn find_index(items: &[i32], target: i32) -> Option<usize> {
    for (index, item) in items.iter().enumerate() {
        if *item == target {
            return Some(index);
        }
    }
    None
}

Option<T> 本质上就是 enum:

enum Option<T> {
    Some(T),
    None,
}

错误处理里的 Result 也是 enum:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

这两种类型都来自同一个思路:不要把“有没有值”“成功还是失败”藏在特殊整数、空指针或输出参数里,而是写进类型。

Rust 也有 union,但不是默认选择

Rust 也提供 union:

#[repr(C)]
union Number {
    i: i32,
    f: f32,
}

但它更接近 C 的底层布局工具,而不是普通业务建模工具。读取 union 字段需要 unsafe,因为编译器不知道当前有效的是哪个字段:

let n = Number { i: 42 };

unsafe {
    println!("{}", n.i);
}

普通 Rust 代码里,遇到 C tagged union 的需求,优先考虑 Rust enum。只有在 FFI、ABI 兼容、硬件寄存器映射、二进制布局边界等场景,才考虑 #[repr(C)] union。

不要过早用 _

match 里可以用 _ 表示其他情况:

match token {
    Token::Int(value) => println!("{value}"),
    _ => println!("other"),
}

这很方便,但在协议、状态机、命令类型这类场景要谨慎。过早使用 _ 会吞掉未来新增的变体,削弱穷尽检查的价值。

如果每个变体都有明确工程意义,优先把分支写全。新增变体时,让编译器提醒你哪些地方需要更新。

这一层先记住什么

C 和 Rust 的对应关系可以先这样看:

C enum 常量               -> Rust enum 无数据变体
C switch                  -> Rust match
C union                   -> Rust union,主要用于底层/FFI
C enum + union tagged data -> Rust 携带数据的 enum
特殊值 -1 / NULL           -> Option<T>
返回码 + 输出参数          -> Result<T, E>

Rust enum 不是 C enum 的简单换皮。它更像把 C 里的 tag、union payload 和分支处理规则合在一起,让错误组合更难写出来。

继续阅读