枚举与 Option:把可能性写进类型
struct 表达「一个东西有哪些部件」,enum 表达「一个东西可能是哪一种」。这是两种互补的数据组织方式:枚举把互斥的可能性写进类型,编译器因此能检查每个分支是否被处理。这篇讲枚举的变体形态、与 struct 的组合方式、Option 作为标准缺席表示的设计理由,以及 match 穷尽性带来的收益。
一、枚举是和类型,struct 是积类型
一个枚举的值恰好是其变体之一;一个 struct 的值同时拥有所有字段。术语来自类型理论:enum 是和类型(sum type),struct 是积类型(product type)。这解释了为什么两者经常嵌套使用——真实数据既有「是哪一种」也有「有哪些部件」两个维度。
// 互斥的可能性
enum Shape {
Circle(f64), // 元组变体,带数据
Rect { w: f64, h: f64 }, // 结构体变体,命名字段
Point, // 无数据变体
}
fn describe(s: &Shape) -> u32 {
match s {
Shape::Circle(r) => (3.14 * r * r) as u32,
Shape::Rect { w, h } => (w * h) as u32,
Shape::Point => 0,
}
}
变体带数据是 Rust 枚举与 C 枚举的本质差别:每个变体可以挂任意类型的数据,编译器据此做穷尽匹配。变体名用双冒号路径访问,不能省略枚举名,这避免了不同枚举之间同名变体冲突。
枚举的内存表示也值得了解:大小取所有变体中最大的那个(外加一个区分变体的 tag),同一块内存在不同时刻复用。因此枚举零额外堆开销,性能与手写 union 相当,但类型安全由编译器担保。这也意味着变体携带数据差异很大时,最大的那个决定整体尺寸——存一个含大数据的变体会让全是小变体的实例也占同样空间,适时用 Box 把大变体挪到堆上是标准优化。
二、Option:标准化的「没有」
Option 本身就是标准库的枚举:enum Option<T> { Some(T), None }。把「值不存在」编码进类型系统,是 Rust 消灭空指针异常的核心设计。在有空值语言里,任何引用都可能为 null,防御性检查永远做不完;在 Rust 里,只有 Option 类型的位置才可能缺席,普通引用的读法默认有效。
fn first(slice: &[i32]) -> Option<i32> {
if slice.is_empty() { None } else { Some(slice[0]) }
}
fn main() {
match first(&[]) {
Some(v) => println!("{}", v),
None => println!("空的"),
}
}
从其他语言迁移过来的头几个月会觉得 Option 包裹很烦——其实它只是把检查从「可能忘掉」变成「编译器检查必须写」。组合子(map、unwrap_or)与 ? 让书写成本降到很低,换来的是「看到 Option 就知道这里可能缺席」的类型语言。
一个语言设计上的细节:Option 的两个变体 Some 和 None 可以不经限定直接使用(prelude 里 re-export),因此写 None 而不是 Option::None。初学者常误以为 None 是关键字,其实它是枚举变体,与 Option::None 等价。理解这点之后遇见 Result::Ok 的限定写法也不会奇怪——prelude 只把最常用的名字拉进来,避免污染命名空间。
三、枚举与 struct 的组合
枚举变体里放 struct、struct 字段里放枚举都常见。设计原则:字段在所有分支都有意义就放 struct,只有部分分支有意义就放变体里。
struct Dimensions { w: f64, h: f64 }
enum Message {
Resize(Dimensions),
Move { x: i32, y: i32 },
Exit,
}
// struct 里嵌枚举:状态机
struct Conn {
state: State,
addr: String,
}
enum State { Idle, Sending(Vec<u8>), Closed }
一个经典经验:当发现自己在 struct 里放了一堆 is_xxx: bool 且这些标志互斥时,就该把这部分抽成枚举。标志组合爆炸是枚举缺位的典型信号。
反过来也有征兆提示该把枚举拆开:如果某个枚举的所有变体都带完全相同的字段集合,只是分类不同,那用 struct 加一个 tag 枚举字段往往更清晰——共享字段的重复定义既啰嗦又容易改漏。两种征兆方向相反,说的其实是同一件事:数据形状决定结构选择,而不是反过来。
四、穷尽性与漏分支
match 对枚举必须穷尽所有变体,忘写分支直接编译失败。这是枚举最有价值的特性:之后新增变体,所有 match 处会同步报错,逼你逐处确认处理方式。相较之下,异常语言的 switch 漏分支只在运行时暴露。
| 情形 | 编译器行为 | 结果 |
|---|---|---|
| match 漏变体 | 编译失败 | 错误在上线前消失 |
通配 _ 兜底 |
编译通过 | 新增变体被静默吞掉 |
| if let 单分支 | 不检查其余 | 只适合确实不变的分支 |
| 新增变体后重编译 | 所有具体 match 报错 | 强制逐处决策 |
建议:能用具体变体列表就别用 _,保留穷尽性带来的免费重构保护。这条建议有一个例外:第三方库升级新增变体时,你的通配分支会静默接纳新分类。若依赖外部 enum,可以故意写具体分支让升级编译失败,从而强制阅读其变更日志——把编译器当评审用。
五、与模式匹配的联动
枚举的全部力量要配合 match/let-else 才完整。let-else 是较新的语法,不匹配时提前返回,专治「先校验再干活」的函数。
fn area(s: &Shape) -> Option<f64> {
let Shape::Rect { w, h } = s else {
return None; // 不是矩形,提前离场
};
Some(w * h)
}
注意这里返回 Option 而非 panic:形状不是预期变体是常规情况,属于可预期分支,错误处理一章的分界原则在这里依然适用。
let-else 与 if let 的适用差别值得记牢:let-else 在不匹配时提前离开函数,适合「校验守卫」;if let 则把两种分支都留在同一作用域里继续写。模式匹配一章有更完整的语法说明,这里只需要记住它们都配合枚举变体工作。
六、常见模式与速查
枚举在实际项目里承载三类职责:状态机表示、领域分类、错误枚举(配合 Result)。Option 则作为「允许缺席」的通用记号,出现在函数返回、struct 字段、容器取值三个位置。设计自己的 API 时记住:返回 Option 表示「这种情况很常见」,返回 Result 表示「这种情况是意外」——二者的文档含义不同,不要混用。
| 需求 | 选择 | 理由 |
|---|---|---|
| 值属于几个互斥类别 | enum | 类型即分类 |
| 值可能不存在 | Option | 缺席是常态 |
| 失败需要附原因 | Result 加枚举 | 错误是意外 |
| 标志互斥但都可选 | enum 替代 bool 组 | 防组合爆炸 |
| 分支处理方式不同 | match 穷尽 | 编译器兜底 |
错误枚举的实际形态在 Option 与 Result 一章有完整展开,本篇聚焦类型组织。写解析器时枚举尤其好用,正则表达式精进 的词法分类就是用枚举逐变体处理的样板,配合 match 穷尽性,解析逻辑几乎不会漏掉新 token 种类。
延伸阅读
- 下一篇「trait 与泛型」给枚举与 struct 抽象出共同行为,读到这里你已具备类型系统的全部基础件。
- 想看枚举如何承载复杂领域逻辑,Tauri 桌面开发指南 的命令系统用了大量带数据变体,可以直接仿写。
- 结合 Claude Code 使用指南 的重构工作流,可以把旧代码里的 bool 标志组合批量改写成枚举,收益一次可见。
