📚 Rust 课程系列

  1. 课程概览
  2. 基础语法(一):变量、数据类型与字符串
  3. 切片(Slice):序列的借用视图
  4. 基础语法(二):运算符、表达式与控制流
  5. 函数与输入输出
  6. 所有权、借用与生命周期
  7. 结构体
  8. 枚举(本文)
  9. 模式匹配
  10. 类型系统:泛型、trait 与多态
  11. 集合与容器
  12. 错误处理与 Panic 恢复
  13. 模块、属性与宏
  14. 智能指针、迭代器与闭包
  15. 并发与异步编程
  16. Unsafe Rust 与常用 trait 详解
  17. 工具链、Cargo 与外部 crate
  18. 最佳实践、性能与调试

如果说结构体是把数据「组合」在一起的积类型,那么枚举就是把「互斥的几种可能」表达为同一个类型。Rust 的枚举并非 C/Java 那种单纯的整数常量列表,而是真正的代数数据类型(Algebraic Data Type,ADT)——每个变体可以携带完全不同类型、不同数量的数据。这一差距决定了 Rust 用一个枚举就能优雅地建模消息协议、状态机、抽象语法树,而无需动用继承体系或标签字段。

本章的主线是「用类型把可能性写在签名里」。我们会从枚举的字面定义出发,一路下沉到内存布局与 niche 优化,再回到日常工程中两个最重要的预定义枚举 Option<T>Result<T, E>——前者消灭了「十亿美元错误」空指针,后者把失败编码进返回类型。模式匹配的细节留给下一篇,本章只把它作为枚举的「驱动装置」使用。

枚举基础:从整数常量到代数数据类型

三种语言的枚举,三种境界

最朴素的枚举:连接可能处于的三种状态。

1
2
3
4
5
enum ConnState {
Idle,
Connecting,
Ready,
}

IdleConnectingReadyConnState 的三个变体(variant)。它们都是同一个类型 ConnState,因此可以放进同一个字段、同一个向量、同一个 match。这一点看似平淡,却已经是 C 枚举的上限。

1
2
3
4
5
6
// C:枚举只是给整数起名字
enum ConnState {
Idle, // = 0
Connecting, // = 1
Ready, // = 2
};

C 的 enum 本质是 int 常量的集合,变体不能携带数据;Java 的 enum 进了一步,每个变体是单例对象,可以加字段和方法,但所有变体共享同一套字段结构——你无法让 Connecting 带一个 deadlineReady 带一个 peer_addr

1
2
3
4
5
6
7
// Java:变体是单例对象,字段结构必须统一
enum ConnState {
Idle,
Connecting,
Ready;
// 无法让不同变体携带不同的数据
}

Rust 的关键跃迁在于:每个变体可以携带不同类型、不同数量的数据,这正是 ADT 的特征。

1
2
3
4
5
6
enum Message {
Quit, // 无数据
Move { x: i32, y: i32 }, // 命名字段
Write(String), // 元组式
ChangeColor(i8, i8, i8), // 多字段元组
}

它们都是 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
2
3
4
5
积类型(struct):取值数 = 字段之积
{ a: bool, b: u8 } => 2 × 256 = 512 种取值

和类型(enum):取值数 = 变体之和
enum E { A(bool), B(u8) } => 2 + 256 = 258 种取值

和类型的关键价值在于精确表达「或」:「这个值要么是 A,要么是 B」。结构体表达「与」(同时拥有 a 和 b),枚举表达「或」(此刻是 A 或 B 之一)。现实世界大量场景是「或」:HTTP 请求可能是 GET 也可能是 POST、解析结果可能是成功也可能是错误、网络消息可能是心跳也可能是数据帧——这些天然适合枚举。

💡 提示:判断该用 struct 还是 enum 的经验法则——如果各字段总是同时存在,用 struct;如果几种形态互斥、同一时刻只取其一,用 enum。把互斥形态硬塞进一个 struct(用 bool 标志位区分)往往是坏味道,编译器帮不了你检查标志组合的合法性。

变体的构造与匹配

构造一个变体只需 EnumName::Variant(...)

1
2
3
4
let q = Message::Quit;
let m = Message::Move { x: 3, y: 4 };
let w = Message::Write(String::from("hi"));
let c = Message::ChangeColor(0, 0, 0);

带命名字段的变体用 { } 初始化(像结构体字面量),元组式变体用 ()。读取内部数据要靠模式匹配,这是枚举的「驱动装置」——下一章会全面展开,这里先看基本形态:

1
2
3
4
5
6
7
8
9
10
11
12
fn describe(m: &Message) -> String {
match m {
Message::Quit => String::from("退出"),
Message::Move { x, y } => {
format!("移动到 ({}, {})", x, y)
}
Message::Write(s) => format!("写入 {}", s),
Message::ChangeColor(r, g, b) => {
format!("颜色 rgb({},{},{})", r, g, b)
}
}
}

match 的每个分支用模式解构出变体内部的数据,xys 等绑定到对应字段。注意 &Message 配合 match m 时,模式里的绑定默认是借用(&i32&String),这是 Rust 的 match ergonomics 在起作用,避免你到处写 &

⚠️ 注意:变体的名字(MoveWrite)在不同枚举里可以重复,因为它们总是通过 EnumName::Variant 限定访问,不会污染命名空间。但同一枚举内的变体必须唯一。

枚举方法与穷尽性:编译器驱动的状态机

用 impl 给枚举加方法

枚举和结构体一样可以用 impl 块定义方法。把行为和数据放在一起,是惯用的 Rust 风格:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
impl Message {
fn is_urgent(&self) -> bool {
matches!(self, Message::Write(s) if s.starts_with("URGENT"))
}

fn call(&self) {
match self {
Message::Quit => println!("quit"),
Message::Move { x, y } => {
println!("move to ({}, {})", x, y)
}
Message::Write(s) => println!("write {}", s),
Message::ChangeColor(r, g, b) => {
println!("rgb({},{},{})", r, g, b)
}
}
}
}

is_urgentcall 背后都是模式匹配,但 matches! 适合「只问是不是、不要内部数据」的场合,返回 bool

💡 提示matches! 等价于一个只返回 true/falsematch,但不会移动内部数据。matches!(opt, Some(_))opt.is_some() 更灵活,能表达「Some 且内部满足某条件」这种细粒度判断。

穷尽性:新增变体即编译错误

match 必须覆盖所有变体,否则编译失败。这条规则叫穷尽性检查(exhaustiveness checking),是枚举最重要的安全保证。当你给 Message 新增一个变体:

1
2
3
4
5
6
7
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i8, i8, i8),
Resize { w: u32, h: u32 }, // 新增
}

上一节的 call 立刻编译失败:

1
2
error[E0004]: non-exhaustive patterns: `Resize { w, h }`
not covered

编译器逐个列出所有未覆盖的 match,逼你补上 Resize 分支。在一个有几百处 match 的大型代码库里,这是无价之宝——新增变体不会留下一处「忘了处理」的隐患。

🔄 对比:在面向对象语言里,给基类加一个子类,所有 instanceof 链不会被编译器提醒;Java 的 switchdefault 分支后更是彻底沉默。Rust 的穷尽性把「新增分支」从运行时风险降级为编译期清单,重构时心里踏实得多。

状态机建模:让非法转移无法编译

穷尽性最出彩的应用是状态机。考虑一个 TCP 连接的状态:ClosedListeningEstablished。把状态写成枚举,把转移写成返回新状态的 fn

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
enum Conn {
Closed,
Listening,
Established { peer: String },
}

impl Conn {
fn open(self) -> Conn {
match self {
Conn::Closed => Conn::Listening,
// 已在监听/连接中,不允许再 open
other => other,
}
}

fn accept(self, peer: String) -> Conn {
match self {
Conn::Listening => Conn::Established { peer },
other => other,
}
}
}

每个转移函数 match 当前状态,合法状态返回新状态,非法状态原样返回(或返回 Result 报错)。若日后新增 Conn::Closing 状态,所有转移函数都会因不完整 match 报错——状态机的「转移表」天然受编译器守护。这种风格比用 String 表示状态、用 if state == "ESTABLISHED" 判断要安全得多,因为拼写错误和遗漏分支都会在编译期暴露。

提示框:通配符的代价

1
2
3
4
match msg {
Message::Quit => 0,
_ => 1, // 通配符:吞掉所有其他变体
}

_ 通配符能让你少写分支,但它抵消了穷尽性——新增变体后这个 match 不会报错,默默走 _ 分支。原则是:除非真的想「对所有其他情况一视同仁」(如日志、计数),否则显式列出每个变体,把穷尽性留作安全网。

变体设计最佳实践:让不合法状态无法表示

「让不合法状态无法表示」(make illegal states unrepresentable)是类型设计的金科玉律,源自 Haskell 社区。枚举是实现这一原则的利器。

反模式:用布尔标志区分互斥形态

一个常见的坏味道——用结构体加布尔字段表达「二选一」:

1
2
3
4
5
6
// 反模式:loading 与 data 的合法组合难以穷举
struct Resp {
loading: bool,
data: Option<String>,
error: Option<String>,
}

这个结构体能表达 loading=true, error=Some(...) 这种自相矛盾的状态(一边加载中一边出错)。编译器无法阻止你构造它,运行时只能靠 if 兜底。换成枚举,非法组合直接消失:

1
2
3
4
5
enum Resp {
Loading,
Ok(String),
Err(String),
}

三种状态互斥,Loading 时不可能同时有 OkErr。任何处理 Resp 的代码都被 match 强制面对全部三种情况。

用枚举编码业务规则

考虑一个订单流程:待支付 → 已支付 → 已发货 → 已签收。用 String 表示状态时,"已签收" → "待支付" 这种回退不会被类型系统拦截。把状态链编码成枚举,转移只在 impl 里发生:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
enum Order {
Pending { amount: u32 },
Paid { amount: u32, paid_at: u32 },
Shipped { amount: u32, tracking: String },
Done { amount: u32 },
}

impl Order {
fn pay(self, paid_at: u32) -> Order {
match self {
Order::Pending { amount } => {
Order::Paid { amount, paid_at }
}
other => other, // 已支付不能再支付
}
}
}

每个变体只携带该状态真正需要的字段:Pending 不需要快递单号,Shipped 才有。这比一个塞满 Option 的巨型结构体清晰得多。

DTO 与业务实体分离

接口层(HTTP API、序列化)与内部业务实体往往有不同的可见性需求。直接把内部枚举序列化出去,会暴露实现细节、并让内部重构牵动外部协议。惯用法是定义一个轻量 DTO(Data Transfer Object)枚举,与内部枚举之间显式转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 内部业务实体:字段丰富、可能含锁/连接
enum Task {
Queued { id: u64, payload: Vec<u8> },
Running { id: u64, worker: String },
}

// 对外 DTO:只暴露协议需要的字段
#[derive(serde::Serialize)]
enum TaskDto {
Queued { id: u64 },
Running { id: u64, worker: String },
}

impl From<&Task> for TaskDto {
fn from(t: &Task) -> Self {
match t {
Task::Queued { id, .. } => {
TaskDto::Queued { id: *id }
}
Task::Running { id, worker } => {
TaskDto::Running { id: *id, worker: worker.clone() }
}
}
}
}

From<&Task> for TaskDto 是显式的转换边界,内部 Taskpayload 不会泄漏到 API。这种分离让内部重构(加字段、改状态机)不破坏外部协议——只要 DTO 不变,客户端无感。

⚠️ 注意:不要把含 MutexJoinHandle、文件描述符等不可序列化字段的内部类型直接当 DTO。DTO 应当是纯数据、可 Serialize/DeserializeClone 的轻量结构。

内存布局深入:判别符与 payload 联合体

理解枚举的内存布局,是写高性能 Rust 的必修课。同一时刻只有一个变体「活跃」,Rust 用判别符(discriminant)+ payload 联合体来表示——本质是 C 里经典的 tagged union。

判别符:变体的编号

每个变体被分配一个整数编号。无数据的枚举(所有变体都是 unit,称 fieldless enum)本质上就是这个整数本身:

1
2
3
4
enum Color { Red, Green, Blue }   // Red=0, Green=1, Blue=2

let c = Color::Green;
assert_eq!(c as u32, 1); // 可直接 as 转整数

编号默认从 0 递增,也可显式赋值:

1
2
3
4
5
6
enum Perm {
Read = 1,
Write = 2,
Exec = 4,
}
assert_eq!(Perm::Read as u8 | Perm::Write as u8, 3);

位运算风格的取值让 Perm 可以当位标志用。编译器选取能容纳所有编号的最小无符号整数类型作为判别符:变体数 ≤ 256 用 u8,更多则 u16/u32/u64Color 三个变体,判别符 1 字节,整个枚举也只占 1 字节。

tag + union:数据携带型枚举

变体一旦带数据,就要在判别符之外再开一块 payload 联合体,按最大变体的大小预留:

1
2
3
4
5
enum Message {
Quit, // 无数据
Move { x: i32, y: i32 }, // 8 字节
Write(String), // 24 字节(64 位)
}

以 64 位平台为例,Message 一个实例的 32 字节是这样摆放的——判别符在前,payload 跟后,中间补齐对齐:

1
2
3
4
5
6
7
8
9
10
+----+------------------+----------------------------------+
|tag | padding | payload union (24B) |
| u8 | 7 bytes | = max(0, 8, 24) bytes |
+----+------------------+----------------------------------+
discriminant = tag + padding (8B) payload = max 变体 (24B)

同一块 payload,按当前变体写入不同内容:
Quit (tag=0) -> [ unused 24B ]
Move (tag=1) -> [ x:i32 | y:i32 | unused 16B ]
Write (tag=2) -> [ ptr 8 | len 8 | cap 8 ]

String 自身对齐为 8,所以 1 字节判别符之后要补 7 字节填充,让 payload 落在 8 字节边界上。三个变体复用同一块 payload,区别只在 tag 的值与实际写入的字节。精确到每个变体:

变体discriminantpayload 实际占用
Quit00 字节(闲置 24 字节)
Move { x, y }18 字节(两个 i32)
Write(String)224 字节(ptr + len + cap)

由此得到枚举大小与对齐的通用公式:

1
2
size(enum)  = sizeof(disc) + max(sizeof(各变体)) + 对齐填充
align(enum) = max(align(disc), align(各变体))

💡 提示:枚举是「用空间换表达力」——每个值都占用最大变体的大小,即使当前变体很小。当某个变体远大于其他变体时(如 Write(String) 对比 Quit),可考虑 Box 大变体,把 24 字节堆化,枚举本体降到判别符 + 指针。这是内存敏感场景的常见优化,后文会专门展开。

用 size_of 亲手验证

标准库的 std::mem::size_of / align_of 能打印确切的字节数:

1
2
3
4
5
6
7
8
use std::mem::{size_of, align_of};

fn main() {
println!("Color: size={} align={}",
size_of::<Color>(), align_of::<Color>());
println!("Message: size={} align={}",
size_of::<Message>(), align_of::<Message>());
}

Color 输出 size=1 align=1Message 输出 size=32 align=8,与上面的手算一致。调试时还可用 cargo show-asm 查看汇编,或 nightly 的 cargo rustc -- -Zprint-type-sizes 打印每个类型的确切字节数与字段偏移。下面这段 unsafe 代码能把任意值的原始字节逐个 dump 出来:

1
2
3
4
5
6
7
8
9
fn dump_bytes<T>(val: &T) {
let bytes = unsafe {
std::slice::from_raw_parts(
val as *const T as *const u8,
std::mem::size_of::<T>(),
)
};
println!("{:02x?}", bytes);
}

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(壁龛)。当枚举某个变体不带数据(如 OptionNone),编译器就用一个 niche 值编码它,整个判别符字段随之消失

Option<&u8> 为例:引用是 8 字节非空指针,编译器约定「指针为全 0(null)就是 None」。于是 Some(&u8)&u8 在内存里逐字节相同,None 就是 8 个零字节——不需要任何额外 tag:

1
2
3
4
5
6
7
8
9
Option<&u8>   8 bytes  (与 &u8 逐字节相同,无判别符)

Some(&x) +------------------------------------------+
| pointer = &x (nonzero) |
+------------------------------------------+

None +------------------------------------------+
| 0x00 0x00 ... 0x00 (null) |
+------------------------------------------+

代码验证:

1
2
3
4
5
use std::mem::size_of;

assert_eq!(size_of::<Option<&u8>>(), size_of::<&u8>()); // 8
assert_eq!(size_of::<Option<Box<u8>>>(), size_of::<&u8>()); // 8
assert_eq!(size_of::<Option<bool>>(), 1); // 254 个 niche

Option<Box<u8>> 也是 8 字节:Box 的指针非空,null 编码 NoneOption<bool> 仍 1 字节:bool 只有 0/1 合法,2~255 共 254 个 niche,足够编码 None,无需扩容。

有 niche 与无 niche 的判据

i32 的全部 2^32 种位模式都合法,没有 niche 可借,Option<i32> 只能老老实实加判别符:

1
2
3
4
5
6
7
8
9
10
11
12
Option<i32>   8 bytes  (i32 无 niche,必须加判别符)

Some(42) +----------------+-------+-------+
| 42 (i32) | tag=1 | pad |
+----------------+-------+-------+

None +----------------+-------+-------+
| undefined | tag=0 | pad |
+----------------+-------+-------+

size_of::<Option<i32>>() == 8
// 4 (i32) + 1 (tag) + 3 (对齐填充)

一句话判据:类型有「天然无效值」就能 niche 优化,没有就得加 tag。常见情况归纳如下:

类型有无 nicheOption<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 / f64T + tag
u82 字节
自定义无 niche 结构体T + tag

🔬 进阶:自定义类型也能拥有 niche。struct NonZero(u8) 没有无效值,但 #[repr(transparent)] struct NonZero(NonZeroU8) 透传了 NonZeroU8 的 niche。标准库的 NonZero* 系列正是为此而设——既保留了整数语义,又给 Option 让出了优化空间。设计 FFI 或性能敏感类型时,善用 niche 能省下可观内存。

对比 C++ std::optional

1
2
3
Rust:Option<&T> == &T,零开销(布局层规则)
C++: std::optional<T&> 不被标准支持,
std::optional<T> 通常 sizeof = T + 1(带 bool 标志)

C++ 的 std::optional<T> 也对部分类型做优化(如 std::optional<bool>),但靠类型 trait 特化,覆盖面不如 Rust 普遍;Rust 把 niche 优化做成了布局层的通用规则,Option 本身只是普通枚举,零成本是「免费」的。这种差异在大量 Option 字段叠加时(如 AST 节点、配置结构)会显著放大。

手动控制布局:repr 与 FFI

默认的 #[repr(Rust)] 允许编译器自由重排变体内部字段、选择判别符类型,以省空间,但不保证跨编译器稳定。当布局关乎 ABI(FFI 场景)或需精确约束判别符时,用 #[repr(...)] 显式控制。

四种常见 repr 形态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#[repr(u8)]            // 强制判别符为 u8
enum Tag { A, B, C }

#[repr(C)] // C 布局:tag 在前、payload 在后
enum FfiMsg1 {
Quit,
Data(u32),
}

#[repr(C, u32)] // C 布局 + 指定判别符类型
enum FfiMsg2 {
Quit,
Data(u32),
}
  • #[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
2
3
4
5
6
7
8
enum ffi_tag { QUIT, DATA };

struct ffi_msg {
enum ffi_tag tag; // 4 字节
union {
uint32_t data; // 4 字节
} payload;
};

Rust 端用 #[repr(C, u32)] 镜像这个布局,即可零拷贝传递:

1
2
3
4
5
6
7
8
9
10
11
12
#[repr(C, u32)]
enum FfiMsg {
Quit,
Data(u32),
}

// sizeof 与 C struct 一致,可直接传给 C 函数
fn send_to_c(m: &FfiMsg) {
unsafe { ffi_send(m as *const FfiMsg as *const u8) };
}

extern "C" { fn ffi_send(p: *const u8); }

#[repr(C, u32)] 保证判别符是 u32、payload 紧随其后、填充与 C 编译器一致,Rust 的 FfiMsg 与 C 的 struct ffi_msg 在内存里逐字节对齐。这是 Rust 与 C 互操作的基石——更多 FFI 细节见《Unsafe Rust 与常用 trait 详解》

验证布局差异

1
2
3
4
5
6
7
8
9
use std::mem::size_of;

#[repr(Rust)] enum RustMsg { Quit, Data(u32) }
#[repr(C)] enum CMsg { Quit, Data(u32) }
#[repr(C, u8)] enum CUMsg { Quit, Data(u32) }

println!("{}", size_of::<RustMsg>()); // 8(可能)
println!("{}", size_of::<CMsg>()); // 8(4+4)
println!("{}", size_of::<CUMsg>()); // 8(1+3填充+4)

实际数值随平台波动,但 #[repr(C)] 系列的结果可预测且跨编译器一致,#[repr(Rust)] 则允许编译器做更激进的重排。需要 ABI 稳定的库(如发布给第三方 C 绑定的 crate)必须用 #[repr(C)]

⚠️ 注意#[repr(C)] 不等于「最紧凑」。C 布局只保证顺序与填充可预测,有时比 Rust 默认布局更大(因为 Rust 默认会重排字段省填充)。#[repr(C)] 是为 ABI 而非省内存设计的。

递归枚举与 Box

枚举变体可以引用自身所在的类型,构成递归类型——函数式语言的链表、树形结构都靠它表达。但递归枚举有一个硬约束:变体不能直接包含自身,必须用 Box 间接。

为何递归枚举必须用 Box

考虑一个朴素(但编译失败)的链表定义:

1
2
3
4
5
// 编译失败:无限大小的类型
enum List {
Nil,
Cons(i32, List), // List 里又含 List,无穷递归
}

Cons(i32, List) 要求 List 的大小已知,而 List 的大小取决于 ConsCons 又取决于 List——编译器陷入无穷回归。Rust 要求所有类型在定义点大小已知,这条规则拒绝了这个定义。

Box<T> 是指向堆分配 T 的拥有型指针,它本身大小固定(一个指针),把递归的「下一层」推到堆上,打破了大小依赖:

1
2
3
4
enum List {
Nil,
Cons(i32, Box<List>), // Box 是固定大小指针
}

Box<List> 是 8 字节指针,Cons 的大小 = i32 + 指针 + tag,整个 List 大小确定。链表的「下一节点」存在堆上,通过指针访问。

ConsList 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
enum List {
Nil,
Cons(i32, Box<List>),
}

impl List {
fn from_iter<I: IntoIterator<Item = i32>>(it: I) -> List {
let mut list = List::Nil;
for x in it.into_iter().rev() {
list = List::Cons(x, Box::new(list));
}
list
}

fn sum(&self) -> i32 {
match self {
List::Nil => 0,
List::Cons(x, tail) => x + tail.sum(),
}
}
}

let l = List::from_iter([1, 2, 3]);
assert_eq!(l.sum(), 6);

from_iter 逆序遍历构造,把每个元素 Cons 到链表头。sum 递归遍历——但要注意,深度递归在长链表上有栈溢出风险(后文《智能指针、迭代器与闭包》会讲迭代器改写为尾递归/循环)。生产代码里链表通常用 Vecstd::collections::LinkedList,这里仅作递归枚举的示例。

简单 AST:表达式求值

递归枚举最常见的实战场景是抽象语法树(AST)。一个支持加减乘和字面量的表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
enum Expr {
Num(f64),
Add(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>),
}

impl Expr {
fn eval(&self) -> f64 {
match self {
Expr::Num(n) => *n,
Expr::Add(a, b) => a.eval() + b.eval(),
Expr::Mul(a, b) => a.eval() * b.eval(),
}
}
}

// (1 + 2) * 3
let e = Expr::Mul(
Box::new(Expr::Add(
Box::new(Expr::Num(1.0)),
Box::new(Expr::Num(2.0)),
)),
Box::new(Expr::Num(3.0)),
);
assert_eq!(e.eval(), 9.0);

AddMul 各有两个子表达式,每个都是 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
2
3
4
5
6
7
8
9
use std::mem::size_of;

enum Event {
Click, // 0 字节 payload
KeyPress(u32), // 4 字节
Log(String, std::time::Instant), // ~32 字节
}

println!("{}", size_of::<Event>()); // 40 左右

Log 携带 String(24 字节)和 Instant(8 字节左右),是其他变体的数倍。即便程序 99% 的事件是 Click,每个 Event 仍占 40 字节。把 Event 放进 Vec<Event> 缓存百万条时,浪费惊人。

解法:Box 大变体

1
2
3
4
5
6
7
enum Event {
Click,
KeyPress(u32),
Log(Box<(String, std::time::Instant)>),
}

println!("{}", size_of::<Event>()); // 16 左右(tag+指针+填充)

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
2
3
4
5
6
7
8
enum HttpStatus {
Ok = 200,
NotFound = 404,
ServerError = 500,
}

let code = HttpStatus::NotFound as u16;
assert_eq!(code, 404);

as 把变体转成其判别符值。显式赋值时可用任意能容纳的整数类型,位运算风格也合法:

1
2
3
4
5
6
7
8
enum Perm {
Read = 1,
Write = 2,
Exec = 4,
}

let rw = Perm::Read as u8 | Perm::Write as u8;
assert_eq!(rw, 3);

注意位标志风格只是借枚举命名整数常量,真正的「位标志集合」类型应配合 bitflags crate(见《工具链、Cargo 与外部 crate》),它提供类型安全的并集/交集/包含判断。

TryFrom:整数反向转枚举

as 是单向的(枚举 → 整数),反向(整数 → 枚举)要自己实现,因为任意整数可能不对应任何变体。Rust 1.34+ 的 TryFrom 是标准做法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use std::convert::TryFrom;

impl TryFrom<u16> for HttpStatus {
type Error = ();

fn try_from(v: u16) -> Result<Self, Self::Error> {
match v {
200 => Ok(HttpStatus::Ok),
404 => Ok(HttpStatus::NotFound),
500 => Ok(HttpStatus::ServerError),
_ => Err(()),
}
}
}

let s: HttpStatus = HttpStatus::try_from(404).unwrap();
let bad = HttpStatus::try_from(999); // Err(())

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
2
3
4
enum Option<T> {
Some(T), // 有值
None, // 无值
}

十亿美元错误

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 就是个普通枚举,所有处理手段(matchif let、组合子)都是通用机制,而非语言特例。Go 的 nil 仍渗透在指针、slice、map、channel、interface 里,逃不开 Hoare 的诅咒。

构造与判断

1
2
3
4
5
6
let some = Some(5);
let none: Option<i32> = None; // 需类型标注,否则推不出 T

assert!(some.is_some());
assert!(none.is_none());
assert_eq!(some.unwrap(), 5); // 取值,None 时 panic

None 必须带类型标注——Option<T>T 无法从 None 推断。is_some / is_none 返回 boolunwrapNone 时 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fn port_from(config: Option<&str>) -> u16 {
// let-else:缺失就提前返回默认端口
let Some(raw) = config else {
return 80;
};
raw.parse().unwrap_or(80)
}

// ? 运算符:在 Option 上下文里传播 None
fn first_upper(s: &str) -> Option<char> {
let c = s.chars().next()?; // None 时函数返回 None
Some(c.to_ascii_uppercase())
}

// 提供默认值
let port: u16 =
Some(8080u16).unwrap_or(80);
let name: String =
None.unwrap_or_else(|| String::from("匿名"));
let v: Vec<u8> =
None.unwrap_or_default(); // Vec 的 Default 是空

unwrap_or(v) 用固定值,unwrap_or_else(f) 延迟计算(默认值需要格式化或查询时用它,避免成功路径也白算一遍),unwrap_or_default() 要求 T: Defaultexpect(m)unwrap 同样 panic,但消息可定制,调试时更友好。

⚠️ 注意unwrap() 在生产代码里通常是坏味道——它在 None 时 panic,把一个本可在类型层面处理的「缺失」退化成了运行时崩溃。测试代码、不变式保证非空的场景例外。组合子方法(mapand_thenfilter 等)的深入用法见《错误处理与 Panic 恢复》

基础组合子:map、and_then、filter

组合子(combinator)是用已有 Option 构造新 Option 的小函数,让你链式处理而不必每步都 match

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let s = Some("42");
// map:对内部值做变换,None 透传
let n: Option<usize> = s.map(|x| x.len());
assert_eq!(n, Some(2));

// and_then:变换本身返回 Option,避免 Some(Some(..))
let parsed: Option<i32> = s.and_then(|x| x.parse().ok());
assert_eq!(parsed, Some(42));

// filter:保留满足谓词的 Some,否则变 None
let pos = parsed.filter(|&v| v > 0);
assert_eq!(pos, Some(42));

// or:None 时用备选
let fallback = None.or(Some(0));
assert_eq!(fallback, Some(0));

// zip:两个 Option 配对
let pair = Some(1).zip(Some("a"));
assert_eq!(pair, Some((1, "a")));

map 对应函数式语言的 fmapand_then 对应 flatMap/bind——它是 Option 的 monad bind 操作。and_thenmap 的区别在于变换函数的返回类型:map(f)f: T -> Uand_then(f)f: T -> Option<U>。若 f 本身可能失败,用 and_then 避免得到 Option<Option<U>>

1
2
3
4
5
6
// 链式组合:解析配置 → 转大写 → 取首字符
fn label(cfg: Option<&str>) -> Option<char> {
cfg.and_then(|s| s.parse::<String>().ok())
.map(|s| s.to_uppercase())
.and_then(|s| s.chars().next())
}

这条链任一步 None 都短路返回 None,无需层层嵌套 match。组合子是惯用 Rust 处理「可能缺失」的核心武器,深入的错误处理组合子见《错误处理与 Panic 恢复》

与所有权交互:as_ref / as_deref / as_mut

Option<T> 持有内部的 T。有时你只想看一眼内部值而不愿交出所有权,这时用 as_ref 借用(呼应《所有权、借用与生命周期》):

1
2
3
4
5
6
7
8
9
10
11
let opt = Some(String::from("hello"));

// 直接 match 会 move 出 String,之后 opt 不可用
// as_ref 把 Option<T> 变成 Option<&T>
if let Some(s) = opt.as_ref() {
println!("len = {}", s.len());
}
println!("{:?}", opt); // opt 依然有效

// as_deref 把 Option<&String> 转成 Option<&str>
let s: Option<&str> = opt.as_deref();

as_mut 返回 Option<&mut T> 用于可变借用,as_deref_mut 是其 deref 版本。这套方法让你在不消耗 Option 的前提下操作内部值:

1
2
3
4
5
let mut opt = Some(String::from("hi"));
if let Some(s) = opt.as_mut() {
s.push_str(" there");
}
assert_eq!(opt.as_deref(), Some("hi there"));

💡 提示:写 fn(&Option<T>) 参数几乎总是坏味道——应写 fn(&T)fn(Option<&T>)as_ref/as_deref 让你在调用处把 &Option<T> 转成 Option<&T>,函数签名因此更通用。这是写惯用 Rust 的必备习惯。

Result:把「失败」编码进类型

如果说 Option 回答「有没有」,Result 回答「成没成,没成是为什么」:

1
2
3
4
enum Result<T, E> {
Ok(T), // 成功,携带结果
Err(E), // 失败,携带错误信息
}

把失败编码进类型

许多语言用异常表示失败:try/catch 是与正常控制流并行的隐藏通道,调用者不读源码就不知道函数会抛什么。Rust 没有(非 panic 的)异常,失败就是一个普通的返回值 Result<T, E>——错误类型 E 写在签名里,调用者必须处理 Err,无法假装它不存在。

🔄 对比:Java 的 checked exception 同样要求声明,但运行时异常(RuntimeException)和跨层传播让「声明」形同虚设;Go 的 if err != nil 显式但冗长、易遗漏。Rust 的 Result 配合 ? 运算符,既显式又不啰嗦——错误路径写在类型里,传播却只需一个符号。Python 的异常文化与 Java 类似,except Exception 吞掉一切的坏习惯难以根除。

构造与判断

1
2
3
4
5
6
let ok: Result<u32, &str> = Ok(42);
let err: Result<u32, &str> = Err("非法输入");

assert!(ok.is_ok());
assert!(err.is_err());
assert!(ok.is_ok_and(|v| *v > 0)); // 带谓词

? 运算符:错误传播

?Result 的核心人体工学。它在 Ok 时取值继续,Err 时提前返回:

1
2
3
4
5
6
7
8
9
10
use std::num::ParseIntError;

// ? 运算符:Err 时提前返回
fn parse_sum(a: &str, b: &str)
-> Result<i32, ParseIntError>
{
let a: i32 = a.parse()?; // Err 时函数返回该 Err
let b: i32 = b.parse()?;
Ok(a + b)
}

? 还会自动做错误类型转换——若 E 实现了 From<E2>?Err(e2) 转成 Err(E::from(e2))。这让错误在不同层级间翻译变得自然:

1
2
3
4
5
6
7
8
// map_err + ?:把底层错误翻译成更有意义的信息
fn load_port(env: &str) -> Result<u16, String> {
let raw = std::env::var(env)
.map_err(|_| format!("缺少环境变量 {env}"))?;
let port: u16 = raw.parse()
.map_err(|_| format!("{env} 不是合法端口"))?;
Ok(port)
}

map_err 把底层错误(VarErrorParseIntError)翻译成业务可读的 String? 在翻译后传播。这种「层层翻译错误」是 Rust 错误处理的骨架,深入用法——自定义错误类型、thiserror/anyhow、库与应用的错误策略——见《错误处理与 Panic 恢复》

Result 的组合子

Result 的组合子与 Option 高度同构,多了 map_err 处理错误侧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let r: Result<i32, &str> = Ok(5);
assert_eq!(r.map(|v| v * 2), Ok(10)); // 改 Ok
assert_eq!(r.map_err(|e| e.len()), Ok(5)); // 改 Err

// and_then:链式可能失败的操作
fn parse_dbl(s: &str) -> Result<f64, String> {
s.parse::<i32>()
.map_err(|e| e.to_string())
.and_then(|v| if v >= 0 {
Ok(v as f64)
} else {
Err("负数".into())
})
}

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 没有「为什么缺失」的信息,ResultErr 带了原因。ok_or 给缺失补上一个原因,ok 则丢弃原因只留「有没有」。

ok_or / ok_or_else / ok

1
2
3
4
5
6
7
8
fn find_user(id: u64) -> Option<String> { /* ... */ None }

// Option<String> -> Result<String, E>:补上缺失的原因
let user: Result<String, String> =
find_user(id).ok_or_else(|| format!("用户 {id} 不存在"));

// Result<T, E> -> Option<T>:丢弃错误信息
let maybe: Option<i32> = "abc".parse::<i32>().ok();

ok_or(v) 用固定错误值,ok_or_else(f) 延迟计算(错误信息需要格式化时用它,避免成功路径也白算一遍)。ok()Result 转成 Option,丢弃 Err 的具体信息——只关心「有没有」时用它。

选择判据:缺失=正常 vs 缺失=出错

1
2
3
4
5
缺失是「正常情况之一」  => Option
用户没填昵称、查询没命中、可选配置

缺失意味着「出了差错」 => Result
文件打不开、网络断了、解析失败

实务经验:跨函数/跨层边界用 Result(调用方需要知道原因),函数内部的局部「有没有」用 Option。比如内部缓存查询返回 Option(没命中是正常的),对外 API 则把 None 翻译成 Result::Err 附上上下文。

1
2
3
4
5
6
7
8
9
10
11
// 内部:缓存查询,缺失是正常的
fn cache_get(&self, k: &str) -> Option<&Vec<u8>> {
self.map.get(k)
}

// 对外:缺失翻译成业务错误
fn read(&self, k: &str) -> Result<Vec<u8>, ApiError> {
cache_get(self, k)
.ok_or(ApiError::KeyNotFound(k.into()))
.map(Clone::clone)
}

这种分层让内部代码简洁(不背错误上下文),外部接口又有完整错误信息。

? 跨类型传播

? 不能直接在 OptionResult 间混用,但标准库给了搭桥方法。在 Result 函数里处理 Option,用 ok_or 转;在 Option 函数里处理 Result,用 ? 前先 .ok() 丢错误信息:

1
2
3
4
5
6
7
fn first_line_len(path: &str) -> Result<usize, String> {
// Option -> Result,补上原因
let s = std::fs::read_to_string(path)
.ok()
.ok_or(format!("读不到 {path}"))?;
Ok(s.lines().next().map(str::len).unwrap_or(0))
}

更地道的方式是让 read_to_stringio::Error 直接用 ? 传播,这里仅为演示搭桥。生产代码应保留原始错误类型,见《错误处理与 Panic 恢复》

枚举与 trait object:开闭原则的两种走向

枚举与 trait object 是 Rust 表达多态的两种方式,它们对「开闭原则」(OCP)的取舍正好相反。

新增变体 vs 新增行为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 枚举:新增变体容易,新增行为要改所有 match
enum Shape {
Circle { r: f64 },
Rect { w: f64, h: f64 },
}

impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle { r } => std::f64::consts::PI * r * r,
Shape::Rect { w, h } => w * h,
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// trait object:新增行为容易,新增变体要改所有实现
trait Shape {
fn area(&self) -> f64;
}

struct Circle { r: f64 }
struct Rect { w: f64, h: f64 }

impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.r * self.r
}
}
impl Shape for Rect {
fn area(&self) -> f64 { self.w * self.h }
}

let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { r: 1.0 }),
Box::new(Rect { w: 2.0, h: 3.0 }),
];
维度枚举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
2
3
4
5
6
7
8
9
10
11
12
13
// 预告:用类型参数编码状态,第 5/11 章详解
struct Builder<Port> { port: Port }
struct NoPort;
struct WithPort(u16);

impl Builder<NoPort> {
fn port(self, p: u16) -> Builder<WithPort> {
Builder { port: WithPort(p) }
}
}
impl Builder<WithPort> {
fn build(self) -> Server { /* ... */ }
}

Builder<NoPort> 没有 build 方法——未配置端口时编译期就无法 build,比运行时检查更早暴露错误。这是泛型 + 类型状态的高级用法,预告《类型系统:泛型、trait 与多态》《Unsafe Rust 与常用 trait 详解》。枚举版的类型状态(用变体表示阶段)也能做,但不如泛型版能在编译期阻止非法调用。

实战示例:消息分发、状态机与 AST

用一个稍完整的例子把枚举的几个核心用法串起来:一个简易聊天服务器的消息分发、连接状态机、命令解析 AST。

消息枚举与分发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
enum ChatMsg {
Login { name: String },
Text { from: String, body: String },
Logout { name: String },
}

impl ChatMsg {
fn dispatch(&self, out: &mut Vec<String>) {
match self {
ChatMsg::Login { name } => {
out.push(format!("* {} 进入", name));
}
ChatMsg::Text { from, body } => {
out.push(format!("[{}] {}", from, body));
}
ChatMsg::Logout { name } => {
out.push(format!("* {} 离开", name));
}
}
}
}

let msgs = vec![
ChatMsg::Login { name: "alice".into() },
ChatMsg::Text {
from: "alice".into(),
body: "hi".into(),
},
ChatMsg::Logout { name: "alice".into() },
];
let mut log = vec![];
for m in &msgs {
m.dispatch(&mut log);
}

新增 ChatMsg::System { body } 时,dispatchmatch 不完整而报错,逼你补全日志逻辑——穷尽性在多人协作中守住一致性。

连接状态机

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
enum Conn {
Closed,
Connected { user: String },
}

impl Conn {
fn login(self, user: String) -> Conn {
match self {
Conn::Closed => Conn::Connected { user },
Conn::Connected { .. } => self, // 已登录,忽略
}
}

fn logout(self) -> Conn {
match self {
Conn::Connected { .. } => Conn::Closed,
Conn::Closed => self,
}
}

fn user(&self) -> Option<&str> {
match self {
Conn::Connected { user } => Some(user),
Conn::Closed => None,
}
}
}

user 返回 Option<&str>——已连接时有用户名,关闭时无。Option 把「可能没有」编码进类型,调用方必须处理 None。状态机的转移函数返回新 Conn,非法转移原样返回(也可改成 Result 报错)。

stateDiagram-v2
    [*] --> Closed
    Closed --> Connected: login(user)
    Connected --> Closed: logout()

命令解析:Option/Result 串联

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
enum Cmd {
Say(String),
Quit,
Unknown,
}

fn parse(line: &str) -> Result<Cmd, String> {
let mut parts = line.splitn(2, ' ');
let head = parts.next().ok_or("空输入")?;
match head {
"/say" => {
let body = parts.next().ok_or("缺少内容")?;
Ok(Cmd::Say(body.into()))
}
"/quit" => Ok(Cmd::Quit),
_ => Ok(Cmd::Unknown),
}
}

assert!(matches!(parse("/say hi"),
Ok(Cmd::Say(s)) if s == "hi"));
assert!(parse("").is_err());

splitnnext() 返回 Option,用 ok_or 补上原因转成 Result? 传播。parse 内部全程 Result,把所有「缺失=出错」的情况(空输入、缺少内容)编码进返回类型。调用方拿到 Result<Cmd, String> 必须处理 Err

三段合一

把消息分发、状态机、命令解析组合,就是一个微型聊天协议的骨架。枚举让每种「互斥可能」都有名字、有结构、有穷尽性保护;OptionResult 让「缺失」与「失败」从运行时隐患变成类型签名。这三者合起来,就是 Rust 用类型系统建模业务的核心工具箱——后续章节(泛型与 trait错误处理智能指针)会在这个基础上继续深化。

💡 提示:本章只触及模式匹配的皮毛。match 的高级模式(嵌套解构、绑定模式、守卫、范围模式)以及 if let / while let / let else 的完整用法,见《模式匹配》。枚举与模式匹配是一对搭档,分开讲是为了各自充分展开。


读完本章,你应能:用枚举精确建模互斥状态、读懂 size_of 背后的判别符与 payload 布局、判断一个类型是否有 niche 并预测 Option<T> 的大小、用 Box 处理递归类型与大变体、在 OptionResult 间自如转换、并按开闭原则在枚举与 trait object 间做出选择。下一站《模式匹配》会把 match 这把刀磨到最利,让枚举的全部威力发挥出来。