枚举:代数数据类型、Option 与 Result
📚 Rust 课程系列
如果说结构体是把数据「组合」在一起的积类型,那么枚举就是把「互斥的几种可能」表达为同一个类型。Rust 的枚举并非 C/Java 那种单纯的整数常量列表,而是真正的代数数据类型(Algebraic Data Type,ADT)——每个变体可以携带完全不同类型、不同数量的数据。这一差距决定了 Rust 用一个枚举就能优雅地建模消息协议、状态机、抽象语法树,而无需动用继承体系或标签字段。
本章的主线是「用类型把可能性写在签名里」。我们会从枚举的字面定义出发,一路下沉到内存布局与 niche 优化,再回到日常工程中两个最重要的预定义枚举 Option<T> 与 Result<T, E>——前者消灭了「十亿美元错误」空指针,后者把失败编码进返回类型。模式匹配的细节留给下一篇,本章只把它作为枚举的「驱动装置」使用。
枚举基础:从整数常量到代数数据类型
三种语言的枚举,三种境界
最朴素的枚举:连接可能处于的三种状态。
1 | |
Idle、Connecting、Ready 是 ConnState 的三个变体(variant)。它们都是同一个类型 ConnState,因此可以放进同一个字段、同一个向量、同一个 match。这一点看似平淡,却已经是 C 枚举的上限。
1 | |
C 的 enum 本质是 int 常量的集合,变体不能携带数据;Java 的 enum 进了一步,每个变体是单例对象,可以加字段和方法,但所有变体共享同一套字段结构——你无法让 Connecting 带一个 deadline 而 Ready 带一个 peer_addr。
1 | |
Rust 的关键跃迁在于:每个变体可以携带不同类型、不同数量的数据,这正是 ADT 的特征。
1 | |
它们都是 Message 类型,但携带的数据互不相同。用其他语言实现等价表达,往往要引入继承层次或 tag + union 手工拼装,Rust 把这件事做成了语言原生能力。
🔄 对比:TypeScript 的 discriminated union(
type Msg = { kind: "quit" } | { kind: "move", x: number, y: number } | ...)在表达力上接近 Rust 枚举,但它是结构化的类型别名,运行时仍是普通对象;Rust 枚举是单一的 nominal 类型,内存上是一个紧凑的 tag+payload,且穷尽性检查由编译器强制。Python 的Enum/dataclass组合、Go 的常量加interface{}都达不到这种「类型即文档」的程度。
代数数据类型:和类型与积类型
ADT 的「代数」二字源于类型构造的两种基本方式:
- 积类型(product type):
struct是积类型,其「大小」(可能的取值数)是各字段大小之积。一个{ a: bool, b: u8 }有2 × 256 = 512种取值。 - 和类型(sum type):
enum是和类型,其大小是各变体大小之和。一个enum E { A(bool), B(u8) }有2 + 256 = 258种取值。
1 | |
和类型的关键价值在于精确表达「或」:「这个值要么是 A,要么是 B」。结构体表达「与」(同时拥有 a 和 b),枚举表达「或」(此刻是 A 或 B 之一)。现实世界大量场景是「或」:HTTP 请求可能是 GET 也可能是 POST、解析结果可能是成功也可能是错误、网络消息可能是心跳也可能是数据帧——这些天然适合枚举。
💡 提示:判断该用 struct 还是 enum 的经验法则——如果各字段总是同时存在,用 struct;如果几种形态互斥、同一时刻只取其一,用 enum。把互斥形态硬塞进一个 struct(用
bool标志位区分)往往是坏味道,编译器帮不了你检查标志组合的合法性。
变体的构造与匹配
构造一个变体只需 EnumName::Variant(...):
1 | |
带命名字段的变体用 { } 初始化(像结构体字面量),元组式变体用 ()。读取内部数据要靠模式匹配,这是枚举的「驱动装置」——下一章会全面展开,这里先看基本形态:
1 | |
match 的每个分支用模式解构出变体内部的数据,x、y、s 等绑定到对应字段。注意 &Message 配合 match m 时,模式里的绑定默认是借用(&i32、&String),这是 Rust 的 match ergonomics 在起作用,避免你到处写 &。
⚠️ 注意:变体的名字(
Move、Write)在不同枚举里可以重复,因为它们总是通过EnumName::Variant限定访问,不会污染命名空间。但同一枚举内的变体必须唯一。
枚举方法与穷尽性:编译器驱动的状态机
用 impl 给枚举加方法
枚举和结构体一样可以用 impl 块定义方法。把行为和数据放在一起,是惯用的 Rust 风格:
1 | |
is_urgent 与 call 背后都是模式匹配,但 matches! 适合「只问是不是、不要内部数据」的场合,返回 bool。
💡 提示:
matches!等价于一个只返回true/false的match,但不会移动内部数据。matches!(opt, Some(_))比opt.is_some()更灵活,能表达「Some且内部满足某条件」这种细粒度判断。
穷尽性:新增变体即编译错误
match 必须覆盖所有变体,否则编译失败。这条规则叫穷尽性检查(exhaustiveness checking),是枚举最重要的安全保证。当你给 Message 新增一个变体:
1 | |
上一节的 call 立刻编译失败:
1 | |
编译器逐个列出所有未覆盖的 match,逼你补上 Resize 分支。在一个有几百处 match 的大型代码库里,这是无价之宝——新增变体不会留下一处「忘了处理」的隐患。
🔄 对比:在面向对象语言里,给基类加一个子类,所有
instanceof链不会被编译器提醒;Java 的switch加default分支后更是彻底沉默。Rust 的穷尽性把「新增分支」从运行时风险降级为编译期清单,重构时心里踏实得多。
状态机建模:让非法转移无法编译
穷尽性最出彩的应用是状态机。考虑一个 TCP 连接的状态:Closed、Listening、Established。把状态写成枚举,把转移写成返回新状态的 fn:
1 | |
每个转移函数 match 当前状态,合法状态返回新状态,非法状态原样返回(或返回 Result 报错)。若日后新增 Conn::Closing 状态,所有转移函数都会因不完整 match 报错——状态机的「转移表」天然受编译器守护。这种风格比用 String 表示状态、用 if state == "ESTABLISHED" 判断要安全得多,因为拼写错误和遗漏分支都会在编译期暴露。
提示框:通配符的代价
1 | |
_ 通配符能让你少写分支,但它抵消了穷尽性——新增变体后这个 match 不会报错,默默走 _ 分支。原则是:除非真的想「对所有其他情况一视同仁」(如日志、计数),否则显式列出每个变体,把穷尽性留作安全网。
变体设计最佳实践:让不合法状态无法表示
「让不合法状态无法表示」(make illegal states unrepresentable)是类型设计的金科玉律,源自 Haskell 社区。枚举是实现这一原则的利器。
反模式:用布尔标志区分互斥形态
一个常见的坏味道——用结构体加布尔字段表达「二选一」:
1 | |
这个结构体能表达 loading=true, error=Some(...) 这种自相矛盾的状态(一边加载中一边出错)。编译器无法阻止你构造它,运行时只能靠 if 兜底。换成枚举,非法组合直接消失:
1 | |
三种状态互斥,Loading 时不可能同时有 Ok 或 Err。任何处理 Resp 的代码都被 match 强制面对全部三种情况。
用枚举编码业务规则
考虑一个订单流程:待支付 → 已支付 → 已发货 → 已签收。用 String 表示状态时,"已签收" → "待支付" 这种回退不会被类型系统拦截。把状态链编码成枚举,转移只在 impl 里发生:
1 | |
每个变体只携带该状态真正需要的字段:Pending 不需要快递单号,Shipped 才有。这比一个塞满 Option 的巨型结构体清晰得多。
DTO 与业务实体分离
接口层(HTTP API、序列化)与内部业务实体往往有不同的可见性需求。直接把内部枚举序列化出去,会暴露实现细节、并让内部重构牵动外部协议。惯用法是定义一个轻量 DTO(Data Transfer Object)枚举,与内部枚举之间显式转换:
1 | |
From<&Task> for TaskDto 是显式的转换边界,内部 Task 的 payload 不会泄漏到 API。这种分离让内部重构(加字段、改状态机)不破坏外部协议——只要 DTO 不变,客户端无感。
⚠️ 注意:不要把含
Mutex、JoinHandle、文件描述符等不可序列化字段的内部类型直接当 DTO。DTO 应当是纯数据、可Serialize/Deserialize、Clone的轻量结构。
内存布局深入:判别符与 payload 联合体
理解枚举的内存布局,是写高性能 Rust 的必修课。同一时刻只有一个变体「活跃」,Rust 用判别符(discriminant)+ payload 联合体来表示——本质是 C 里经典的 tagged union。
判别符:变体的编号
每个变体被分配一个整数编号。无数据的枚举(所有变体都是 unit,称 fieldless enum)本质上就是这个整数本身:
1 | |
编号默认从 0 递增,也可显式赋值:
1 | |
位运算风格的取值让 Perm 可以当位标志用。编译器选取能容纳所有编号的最小无符号整数类型作为判别符:变体数 ≤ 256 用 u8,更多则 u16/u32/u64。Color 三个变体,判别符 1 字节,整个枚举也只占 1 字节。
tag + union:数据携带型枚举
变体一旦带数据,就要在判别符之外再开一块 payload 联合体,按最大变体的大小预留:
1 | |
以 64 位平台为例,Message 一个实例的 32 字节是这样摆放的——判别符在前,payload 跟后,中间补齐对齐:
1 | |
String 自身对齐为 8,所以 1 字节判别符之后要补 7 字节填充,让 payload 落在 8 字节边界上。三个变体复用同一块 payload,区别只在 tag 的值与实际写入的字节。精确到每个变体:
| 变体 | discriminant | payload 实际占用 |
|---|---|---|
Quit | 0 | 0 字节(闲置 24 字节) |
Move { x, y } | 1 | 8 字节(两个 i32) |
Write(String) | 2 | 24 字节(ptr + len + cap) |
由此得到枚举大小与对齐的通用公式:
1 | |
💡 提示:枚举是「用空间换表达力」——每个值都占用最大变体的大小,即使当前变体很小。当某个变体远大于其他变体时(如
Write(String)对比Quit),可考虑Box大变体,把 24 字节堆化,枚举本体降到判别符 + 指针。这是内存敏感场景的常见优化,后文会专门展开。
用 size_of 亲手验证
标准库的 std::mem::size_of / align_of 能打印确切的字节数:
1 | |
Color 输出 size=1 align=1,Message 输出 size=32 align=8,与上面的手算一致。调试时还可用 cargo show-asm 查看汇编,或 nightly 的 cargo rustc -- -Zprint-type-sizes 打印每个类型的确切字节数与字段偏移。下面这段 unsafe 代码能把任意值的原始字节逐个 dump 出来:
1 | |
把 Message::Move { x: 1, y: 2 } 喂进去,你会看到首字节是 01(tag=1),随后 01 00 00 00 02 00 00 00(两个小端 i32),其余是未初始化的填充字节。这种逐字节观察是理解布局最直接的方式。
⚠️ 注意:
from_raw_parts读出的填充字节是未初始化内存,其内容未定义。打印无妨,但不要把填充字节当作有意义的值参与逻辑——那会引入未定义行为。
Niche 优化:把判别符藏进无效值里
Niche 优化(niche optimization)是 Rust 枚举布局最精妙的一环,它让 Option<&T>、Option<Box<T>> 等「可能为空的指针」真正做到零开销。
原理:借用无效位模式
许多类型并非所有位模式都合法——引用和 Box 的指针永远非 null、bool 只有 0/1 两个合法值、NonZeroU8 排除 0。这些「不可能出现的位模式」叫 niche(壁龛)。当枚举某个变体不带数据(如 Option 的 None),编译器就用一个 niche 值编码它,整个判别符字段随之消失。
以 Option<&u8> 为例:引用是 8 字节非空指针,编译器约定「指针为全 0(null)就是 None」。于是 Some(&u8) 与 &u8 在内存里逐字节相同,None 就是 8 个零字节——不需要任何额外 tag:
1 | |
代码验证:
1 | |
Option<Box<u8>> 也是 8 字节:Box 的指针非空,null 编码 None。Option<bool> 仍 1 字节:bool 只有 0/1 合法,2~255 共 254 个 niche,足够编码 None,无需扩容。
有 niche 与无 niche 的判据
而 i32 的全部 2^32 种位模式都合法,没有 niche 可借,Option<i32> 只能老老实实加判别符:
1 | |
一句话判据:类型有「天然无效值」就能 niche 优化,没有就得加 tag。常见情况归纳如下:
| 类型 | 有无 niche | Option<T> 大小 |
|---|---|---|
&T / &mut T / Box<T> | 有(null) | 同 T |
NonNull<T> / NonZeroU8 等 | 有(0) | 同 T |
Vec<T> / String | 有(null 指针) | 同 T |
bool | 有(2~255) | 1 字节 |
char | 有(非法区间) | 4 字节 |
i32 / u32 / f64 | 无 | T + tag |
裸 u8 | 无 | 2 字节 |
| 自定义无 niche 结构体 | 无 | T + tag |
🔬 进阶:自定义类型也能拥有 niche。
struct NonZero(u8)没有无效值,但#[repr(transparent)] struct NonZero(NonZeroU8)透传了NonZeroU8的 niche。标准库的NonZero*系列正是为此而设——既保留了整数语义,又给Option让出了优化空间。设计 FFI 或性能敏感类型时,善用 niche 能省下可观内存。
对比 C++ std::optional
1 | |
C++ 的 std::optional<T> 也对部分类型做优化(如 std::optional<bool>),但靠类型 trait 特化,覆盖面不如 Rust 普遍;Rust 把 niche 优化做成了布局层的通用规则,Option 本身只是普通枚举,零成本是「免费」的。这种差异在大量 Option 字段叠加时(如 AST 节点、配置结构)会显著放大。
手动控制布局:repr 与 FFI
默认的 #[repr(Rust)] 允许编译器自由重排变体内部字段、选择判别符类型,以省空间,但不保证跨编译器稳定。当布局关乎 ABI(FFI 场景)或需精确约束判别符时,用 #[repr(...)] 显式控制。
四种常见 repr 形态
1 | |
#[repr(u8)]/#[repr(i32)]等:单独使用时只固定判别符类型(哪怕变体少于 256 也强制u8,或反之放大到u32),字段顺序仍是 Rust 默认。#[repr(C)]:让枚举按「判别符在前、payload union 在后」的 C 布局,字段顺序与填充可预测。#[repr(C, u8)]:同时约束 C 布局与判别符类型,跨语言交互最可预测。
FFI 场景:与 C 的 tagged union 对齐
C 代码里常见的 tagged union 长这样:
1 | |
Rust 端用 #[repr(C, u32)] 镜像这个布局,即可零拷贝传递:
1 | |
#[repr(C, u32)] 保证判别符是 u32、payload 紧随其后、填充与 C 编译器一致,Rust 的 FfiMsg 与 C 的 struct ffi_msg 在内存里逐字节对齐。这是 Rust 与 C 互操作的基石——更多 FFI 细节见《Unsafe Rust 与常用 trait 详解》。
验证布局差异
1 | |
实际数值随平台波动,但 #[repr(C)] 系列的结果可预测且跨编译器一致,#[repr(Rust)] 则允许编译器做更激进的重排。需要 ABI 稳定的库(如发布给第三方 C 绑定的 crate)必须用 #[repr(C)]。
⚠️ 注意:
#[repr(C)]不等于「最紧凑」。C 布局只保证顺序与填充可预测,有时比 Rust 默认布局更大(因为 Rust 默认会重排字段省填充)。#[repr(C)]是为 ABI 而非省内存设计的。
递归枚举与 Box
枚举变体可以引用自身所在的类型,构成递归类型——函数式语言的链表、树形结构都靠它表达。但递归枚举有一个硬约束:变体不能直接包含自身,必须用 Box 间接。
为何递归枚举必须用 Box
考虑一个朴素(但编译失败)的链表定义:
1 | |
Cons(i32, List) 要求 List 的大小已知,而 List 的大小取决于 Cons,Cons 又取决于 List——编译器陷入无穷回归。Rust 要求所有类型在定义点大小已知,这条规则拒绝了这个定义。
Box<T> 是指向堆分配 T 的拥有型指针,它本身大小固定(一个指针),把递归的「下一层」推到堆上,打破了大小依赖:
1 | |
Box<List> 是 8 字节指针,Cons 的大小 = i32 + 指针 + tag,整个 List 大小确定。链表的「下一节点」存在堆上,通过指针访问。
ConsList 示例
1 | |
from_iter 逆序遍历构造,把每个元素 Cons 到链表头。sum 递归遍历——但要注意,深度递归在长链表上有栈溢出风险(后文《智能指针、迭代器与闭包》会讲迭代器改写为尾递归/循环)。生产代码里链表通常用 Vec 或 std::collections::LinkedList,这里仅作递归枚举的示例。
简单 AST:表达式求值
递归枚举最常见的实战场景是抽象语法树(AST)。一个支持加减乘和字面量的表达式:
1 | |
Add 和 Mul 各有两个子表达式,每个都是 Box<Expr>——指针打破大小依赖,同时让树形结构在堆上展开。eval 递归求值,match 穷尽所有节点类型。新增 Expr::Neg(Box<Expr>)(一元负号)时,所有 match 报错,逼你补全求值逻辑。
graph TD
A["Mul"] --> B["Add"]
A --> C["Num(3.0)"]
B --> D["Num(1.0)"]
B --> E["Num(2.0)"]💡 提示:
Box是递归类型的标准解法,但它把子节点放堆上、多一次间接寻址。对性能敏感的树(如编译器 AST),有更进阶的方案:用Vec做 arena 把节点扁平化存储、用索引代替指针(enum Expr { Num(f64), Add(usize, usize) }+Vec<Expr>)。这在《智能指针、迭代器与闭包》会展开。
枚举大小优化实战:Box 大变体
当一个枚举的某个变体远大于其他变体时,每个实例都要为最大变体预留空间——哪怕绝大多数实例用的是小变体。这时 Box 大变体能显著降低枚举本体大小。
问题:大变体拖累整体大小
1 | |
Log 携带 String(24 字节)和 Instant(8 字节左右),是其他变体的数倍。即便程序 99% 的事件是 Click,每个 Event 仍占 40 字节。把 Event 放进 Vec<Event> 缓存百万条时,浪费惊人。
解法:Box 大变体
1 | |
把 Log 的载荷整体塞进 Box,枚举本体只需容纳判别符 + 一个指针(8 字节),大小从 40 降到 16。代价是构造 Log 时多一次堆分配、访问时多一次解引用。对小变体占主导的场景,这是净收益。
🔬 进阶:是否该 Box 取决于访问模式。若大变体频繁构造且枚举被大量缓存(如日志缓冲、事件队列),Box 划算;若大变体是主流、几乎每条都用,Box 反而增加分配开销。用
size_of量出基准,再做决策。Box的堆分配成本见《智能指针、迭代器与闭包》。
多变体都很大时的取舍
如果多个变体都很大,Box 哪个?经验是 Box 最大的那个——max(sizeof(各变体)) 决定 payload,把最大的压下去收益最大。若所有变体都差不多大,Box 单个变体收益有限,不如考虑把整个枚举存为 Box<Enum>(在集合里存 Vec<Box<Enum>>),或重新设计数据布局。
fieldless enum:整数转换与反向解析
所有变体都不带数据的枚举叫 fieldless enum(也叫 C-like enum)。它最接近 C 枚举,可以与整数互转,是 FFI、位标志、状态码的常用工具。
as 转整数与显式判别符
1 | |
as 把变体转成其判别符值。显式赋值时可用任意能容纳的整数类型,位运算风格也合法:
1 | |
注意位标志风格只是借枚举命名整数常量,真正的「位标志集合」类型应配合 bitflags crate(见《工具链、Cargo 与外部 crate》),它提供类型安全的并集/交集/包含判断。
TryFrom:整数反向转枚举
as 是单向的(枚举 → 整数),反向(整数 → 枚举)要自己实现,因为任意整数可能不对应任何变体。Rust 1.34+ 的 TryFrom 是标准做法:
1 | |
TryFrom 把「整数可能非法」编码进 Result,调用方必须处理 Err。若枚举变体连续且规律,可写宏自动生成(见《模块、属性与宏》)。也有 num_enum crate 自动派生 TryFrom,省去手写 match。
⚠️ 注意:不要用
transmute把整数强转成枚举——若整数值落在未定义的判别符区间,是未定义行为。哪怕用unsafe,也应当先校验值合法,或用num_enum/手写TryFrom保证安全。
fieldless enum 与 match 依然穷尽
即便变体不带数据,match 仍要求穷尽。新增 HttpStatus::BadRequest = 400 后,上面的 try_from 会因 match 不完整而编译失败——这正好提醒你同时更新反向转换。这是 fieldless enum 与 ADT 共享的安全保证。
Option:把「缺失」编码进类型
Option<T> 的定义极其简单,却是 Rust 消灭空指针的根基:
1 | |
十亿美元错误
Tony Hoare 在 1965 年发明了 null 引用,后来称其为「十亿美元错误」——null 让每个引用类型都隐式带上了「可能为空」的状态,而编译器无法帮你检查。Java 的 NullPointerException、C 的段错误、Python 的 AttributeError: 'NoneType' 都源于此。
Rust 的做法是:没有 null。一个 i32 就是 i32,绝不可能「突然消失」;只有显式写出 Option<i32>,才表示「这个值可能不存在」。两者是不同的类型,不能把 Option<i32> 当 i32 用,必须先取出内部的值——而取出 Some 的同时,编译器强制你面对 None 的可能。
🔄 对比:Kotlin 的
String?、TypeScript 在strictNullChecks下的string | undefined、Swift 的Optional<String>走的都是同一条路——用类型系统区分「一定有」和「可能有」。Rust 的区别在于Option就是个普通枚举,所有处理手段(match、if let、组合子)都是通用机制,而非语言特例。Go 的nil仍渗透在指针、slice、map、channel、interface 里,逃不开 Hoare 的诅咒。
构造与判断
1 | |
None 必须带类型标注——Option<T> 的 T 无法从 None 推断。is_some / is_none 返回 bool,unwrap 在 None 时 panic(生产代码慎用,后文详述)。
解包方式对照表
选择哪一种取决于「缺失时怎么办」:
| 方式 | 缺失时行为 | 适用场景 |
|---|---|---|
match | 自定义分支 | 需对 Some/None 分别处理 |
if let | 静默忽略 | 只关心 Some,缺失无所谓 |
let else | 提前返回 | 早期校验,缺失就退出 |
? | 提前返回 None | 在返回 Option 的函数里传播 |
unwrap_or(d) | 返回默认值 d | 有合理兜底 |
unwrap_or_else(f) | 调 f 计算默认值 | 默认值计算昂贵 |
unwrap_or_default() | 用 Default | 类型有自然默认值 |
unwrap() / expect(m) | panic | 仅限「绝对不该是 None」 |
1 | |
unwrap_or(v) 用固定值,unwrap_or_else(f) 延迟计算(默认值需要格式化或查询时用它,避免成功路径也白算一遍),unwrap_or_default() 要求 T: Default。expect(m) 与 unwrap 同样 panic,但消息可定制,调试时更友好。
⚠️ 注意:
unwrap()在生产代码里通常是坏味道——它在None时 panic,把一个本可在类型层面处理的「缺失」退化成了运行时崩溃。测试代码、不变式保证非空的场景例外。组合子方法(map、and_then、filter等)的深入用法见《错误处理与 Panic 恢复》。
基础组合子:map、and_then、filter
组合子(combinator)是用已有 Option 构造新 Option 的小函数,让你链式处理而不必每步都 match:
1 | |
map 对应函数式语言的 fmap,and_then 对应 flatMap/bind——它是 Option 的 monad bind 操作。and_then 与 map 的区别在于变换函数的返回类型:map(f) 里 f: T -> U,and_then(f) 里 f: T -> Option<U>。若 f 本身可能失败,用 and_then 避免得到 Option<Option<U>>。
1 | |
这条链任一步 None 都短路返回 None,无需层层嵌套 match。组合子是惯用 Rust 处理「可能缺失」的核心武器,深入的错误处理组合子见《错误处理与 Panic 恢复》。
与所有权交互:as_ref / as_deref / as_mut
Option<T> 持有内部的 T。有时你只想看一眼内部值而不愿交出所有权,这时用 as_ref 借用(呼应《所有权、借用与生命周期》):
1 | |
as_mut 返回 Option<&mut T> 用于可变借用,as_deref_mut 是其 deref 版本。这套方法让你在不消耗 Option 的前提下操作内部值:
1 | |
💡 提示:写
fn(&Option<T>)参数几乎总是坏味道——应写fn(&T)或fn(Option<&T>)。as_ref/as_deref让你在调用处把&Option<T>转成Option<&T>,函数签名因此更通用。这是写惯用 Rust 的必备习惯。
Result:把「失败」编码进类型
如果说 Option 回答「有没有」,Result 回答「成没成,没成是为什么」:
1 | |
把失败编码进类型
许多语言用异常表示失败:try/catch 是与正常控制流并行的隐藏通道,调用者不读源码就不知道函数会抛什么。Rust 没有(非 panic 的)异常,失败就是一个普通的返回值 Result<T, E>——错误类型 E 写在签名里,调用者必须处理 Err,无法假装它不存在。
🔄 对比:Java 的 checked exception 同样要求声明,但运行时异常(
RuntimeException)和跨层传播让「声明」形同虚设;Go 的if err != nil显式但冗长、易遗漏。Rust 的Result配合?运算符,既显式又不啰嗦——错误路径写在类型里,传播却只需一个符号。Python 的异常文化与 Java 类似,except Exception吞掉一切的坏习惯难以根除。
构造与判断
1 | |
? 运算符:错误传播
? 是 Result 的核心人体工学。它在 Ok 时取值继续,Err 时提前返回:
1 | |
? 还会自动做错误类型转换——若 E 实现了 From<E2>,? 把 Err(e2) 转成 Err(E::from(e2))。这让错误在不同层级间翻译变得自然:
1 | |
map_err 把底层错误(VarError、ParseIntError)翻译成业务可读的 String,? 在翻译后传播。这种「层层翻译错误」是 Rust 错误处理的骨架,深入用法——自定义错误类型、thiserror/anyhow、库与应用的错误策略——见《错误处理与 Panic 恢复》。
Result 的组合子
Result 的组合子与 Option 高度同构,多了 map_err 处理错误侧:
1 | |
map 改成功值,map_err 改错误值,and_then 串联可能失败的操作。Result 也有 is_ok/is_err/unwrap_or/unwrap_or_else 等,语义与 Option 对应。
💡 提示:
?在Result上下文里传播Err,在Option上下文里传播None。两种上下文不能混用——Result函数里的?不能直接作用于Option,需要先ok_or转换(见下节)。
Option 与 Result 互转
两者经常需要搭桥。Option 没有「为什么缺失」的信息,Result 的 Err 带了原因。ok_or 给缺失补上一个原因,ok 则丢弃原因只留「有没有」。
ok_or / ok_or_else / ok
1 | |
ok_or(v) 用固定错误值,ok_or_else(f) 延迟计算(错误信息需要格式化时用它,避免成功路径也白算一遍)。ok() 把 Result 转成 Option,丢弃 Err 的具体信息——只关心「有没有」时用它。
选择判据:缺失=正常 vs 缺失=出错
1 | |
实务经验:跨函数/跨层边界用 Result(调用方需要知道原因),函数内部的局部「有没有」用 Option。比如内部缓存查询返回 Option(没命中是正常的),对外 API 则把 None 翻译成 Result::Err 附上上下文。
1 | |
这种分层让内部代码简洁(不背错误上下文),外部接口又有完整错误信息。
? 跨类型传播
? 不能直接在 Option 与 Result 间混用,但标准库给了搭桥方法。在 Result 函数里处理 Option,用 ok_or 转;在 Option 函数里处理 Result,用 ? 前先 .ok() 丢错误信息:
1 | |
更地道的方式是让 read_to_string 的 io::Error 直接用 ? 传播,这里仅为演示搭桥。生产代码应保留原始错误类型,见《错误处理与 Panic 恢复》。
枚举与 trait object:开闭原则的两种走向
枚举与 trait object 是 Rust 表达多态的两种方式,它们对「开闭原则」(OCP)的取舍正好相反。
新增变体 vs 新增行为
1 | |
1 | |
| 维度 | 枚举 | trait object |
|---|---|---|
| 新增变体 | 改 enum + 所有 match 报错提醒 | 加新 struct + impl,无侵入 |
| 新增行为 | 给 enum 加方法,所有变体一处实现 | 给 trait 加方法,所有 impl 报错 |
| 内存 | 紧凑 tag+payload,栈上 | 指针 + 堆分配,虚函数派发 |
| 派发 | 静态(match 内联) | 动态(vtable) |
| 变体集合 | 闭集(编译期固定) | 开集(可跨 crate 扩展) |
选择判据:变体集合稳定、行为经常新增——用枚举(穷尽性帮你覆盖所有变体);变体集合开放、行为稳定——用 trait object(外部 crate 可加新实现)。HTTP 请求方法、AST 节点类型适合枚举;插件、中间件、多种存储后端适合 trait object。两者也能混用:枚举做内部分发,trait object 做扩展点。
🔬 进阶:trait object 与泛型的取舍(静态派发 vs 动态派发)见《类型系统:泛型、trait 与多态》。这里只点出枚举与 trait object 在开闭原则上的镜像关系——这是 Rust 类型设计的核心权衡之一。
类型状态模式预告
枚举还能编码「类型状态」(type-state),把状态的合法性推进到类型层。例如一个 Builder 在未配置完端口时不能 build:
1 | |
Builder<NoPort> 没有 build 方法——未配置端口时编译期就无法 build,比运行时检查更早暴露错误。这是泛型 + 类型状态的高级用法,预告《类型系统:泛型、trait 与多态》与《Unsafe Rust 与常用 trait 详解》。枚举版的类型状态(用变体表示阶段)也能做,但不如泛型版能在编译期阻止非法调用。
实战示例:消息分发、状态机与 AST
用一个稍完整的例子把枚举的几个核心用法串起来:一个简易聊天服务器的消息分发、连接状态机、命令解析 AST。
消息枚举与分发
1 | |
新增 ChatMsg::System { body } 时,dispatch 因 match 不完整而报错,逼你补全日志逻辑——穷尽性在多人协作中守住一致性。
连接状态机
1 | |
user 返回 Option<&str>——已连接时有用户名,关闭时无。Option 把「可能没有」编码进类型,调用方必须处理 None。状态机的转移函数返回新 Conn,非法转移原样返回(也可改成 Result 报错)。
stateDiagram-v2
[*] --> Closed
Closed --> Connected: login(user)
Connected --> Closed: logout()命令解析:Option/Result 串联
1 | |
splitn 的 next() 返回 Option,用 ok_or 补上原因转成 Result,? 传播。parse 内部全程 Result,把所有「缺失=出错」的情况(空输入、缺少内容)编码进返回类型。调用方拿到 Result<Cmd, String> 必须处理 Err。
三段合一
把消息分发、状态机、命令解析组合,就是一个微型聊天协议的骨架。枚举让每种「互斥可能」都有名字、有结构、有穷尽性保护;Option 与 Result 让「缺失」与「失败」从运行时隐患变成类型签名。这三者合起来,就是 Rust 用类型系统建模业务的核心工具箱——后续章节(泛型与 trait、错误处理、智能指针)会在这个基础上继续深化。
💡 提示:本章只触及模式匹配的皮毛。
match的高级模式(嵌套解构、绑定模式、守卫、范围模式)以及if let/while let/let else的完整用法,见《模式匹配》。枚举与模式匹配是一对搭档,分开讲是为了各自充分展开。
读完本章,你应能:用枚举精确建模互斥状态、读懂 size_of 背后的判别符与 payload 布局、判断一个类型是否有 niche 并预测 Option<T> 的大小、用 Box 处理递归类型与大变体、在 Option 与 Result 间自如转换、并按开闭原则在枚举与 trait object 间做出选择。下一站《模式匹配》会把 match 这把刀磨到最利,让枚举的全部威力发挥出来。



















