- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 一个没有 null 也没有异常的世界
- 枚举是代数数据类型
- Option — 把 null 变成类型
- match — 穷尽(exhaustively)地拆解
- if let 与 let else — 只关心一种情况时
- Result — 把失败变成值
- ? 运算符 — 错误传播的简洁写法
- unwrap 与 expect — 什么时候可以 panic
- thiserror 与 anyhow — 设计实战中的错误类型
- 动手掌握
- 结语
- 参考资料
引言 — 一个没有 null 也没有异常的世界
托尼·霍尔把发明 null 引用称为他"十亿美元的错误"。大多数主流语言至今仍背着这个错误活着 — 一个值可能有,也可能没有,但类型分不清这两种情况。于是 NullPointerException 在生产环境里等着我们。
异常也有类似的问题。光看函数签名,你根本不知道这个函数会抛出什么异常,甚至不知道它到底抛不抛异常(Java 的受检异常本想修正这一点,结果大多只招来嫌弃)。因为错误处理活在类型系统之外,编译器没法指出"你没处理这个错误"。
Rust 把这两者都彻底去掉了。用 Option 取代 null,用 Result 取代异常。两者都是普通的枚举(enum),它们把值的"缺失"和"失败的可能性"装进了类型里。 而安全打开这些枚举的工具,就是模式匹配(pattern matching)。因为编译器会揪出"你没处理这种情况",所以遗漏变得不可能。
枚举是代数数据类型
大多数语言里的 enum,通常只是一组命名的整数常量。Rust 的 enum 强大得多:每个变体(variant)都可以携带不同种类的数据。
enum Shape {
Circle { radius: f64 }, // 具名字段
Rectangle { w: f64, h: f64 }, // 两个字段
Point, // 没有数据
}
一个 Shape 值,要么是圆,要么是矩形,要么是点 — 三者之中恰好一个。这种"是 A 或者是 B 或者是 C"的类型,在类型理论里叫和类型(sum type),也叫带标签的联合(tagged union)。如果说结构体(struct)是"既是 A 又是 B 又是 C"的积类型(product type),那 enum 就是它的对称面。两者合起来叫代数数据类型(algebraic data type, ADT),这正是 Rust 数据建模的核心。
关键在于每个变体都携带自己的数据。它不像 C 风格的 enum 那样只表示"种类",而是把恰好匹配那个种类的数据一并装了进去。这正是接下来要讲的 Option 和 Result 之所以成立的原因。
Option — 把 null 变成类型
Option<T> 是标准库里表达"有一个 T 值,或者没有"的枚举。它的定义简单得惊人。
enum Option<T> {
Some(T), // 有值,值是 T
None, // 没有值
}
这就是 null 的替代品。在值可能缺失的地方,写的不是 T 而是 Option<T>。这样一来,编译器就会强制你处理"缺失"这种情况。
fn find_user(id: u32) -> Option<String> {
if id == 1 {
Some(String::from("Alice"))
} else {
None
}
}
fn main() {
let user = find_user(1);
// user 是 Option<String> — 不能直接当字符串用
// println!("{}", user.len()); // 编译错误!Option 没有 len
}
与 null 的决定性区别在这里。null 可以偷偷混进任何引用类型,所以光看代码你没法判断"这个值会不会是 null"。相反,Option<T> 在类型里是显式写出来的,要取出里面的 T,就必须先处理 None 的情况。"可能缺失"这件事被写进了类型里,编译器强制你处理它。 十亿美元的错误,被拉进了类型系统内部。
match — 穷尽(exhaustively)地拆解
从 Option 里取出值的正统做法是 match。match 把一个值和一组模式逐一比对,执行匹配上的分支,而 Rust 的决定性安全机制在于,match 必须是穷尽的(exhaustive):不覆盖所有可能的情况,就编译不过。
fn main() {
let user = find_user(1);
match user {
Some(name) => println!("找到了: {name}"), // 如果是 Some,绑定里面的 name
None => println!("没有这个用户"), // None 的情况
}
}
Some(name) 里的 name,就是把 Some 里装的那个 String 取出来绑定的结果。这种一边拆解值的结构、一边把内部值提取到变量里的模式,叫解构(destructuring)。
如果漏掉 None 这一支,会得到这样的报错。
error[E0004]: non-exhaustive patterns: `None` not covered
--> src/main.rs:4:11
|
4 | match user {
| ^^^^ pattern `None` not covered
|
= help: ensure that all possible cases are being handled
这个穷尽性检查是一种安静的超能力。以后要是给某个 enum 新增一个变体,处理这个 enum 的每一处 match 都会报编译错误。编译器等于替你做了一次全面普查,提醒你"去处理新出现的情况"。作为重构安全网,这一点价值巨大 — 添加了新状态却在某处忘了处理、最后在生产环境炸掉的事,从根源上就被堵死了。
不想把每种情况都写出来的话,可以用通配符 _ 捕获"其余全部"。不过 _ 用多了会丧失穷尽性带来的好处,最好只用在那些真正无所谓的剩余情况上。
fn describe(n: i32) -> &'static str {
match n {
0 => "零",
1 | 2 | 3 => "小数字", // 用 | 连接多个模式
4..=9 => "个位数", // 范围模式
_ => "其他", // 剩下的全部
}
}
if let 与 let else — 只关心一种情况时
有时候要求覆盖所有情况显得过头了 — 比如"只有 Some 的时候才做点什么,None 的话就什么都不做"。这种时候 if let 很简洁。
fn main() {
let config = find_user(1);
if let Some(name) = config {
println!("已配置的用户: {name}"); // 只在 Some 时执行
}
// 如果是 None,直接跳过(else 可以省略)
}
if let Some(name) = config 的意思是"如果 config 匹配 Some 这个模式,就绑定 name 并执行代码块"。它是从 match 里单独抽出一个分支的语法糖。也可以再接一个 else。
反过来的场景也很常见:"值必须存在才算正常,不存在就在这里提前退出。" 这种情况用 let else 更优雅(自 Rust 1.65 起可用)。
fn greet(id: u32) -> String {
let Some(name) = find_user(id) else {
return String::from("访客"); // 如果是 None,函数在此结束
};
// 从这里往下,name 已经确定是 String,可以直接使用
format!("欢迎,{name}!")
}
let else 的好处在于,它把成功路径从缩进里解放出来。用 if let 包住成功分支的话,代码会不断向右挤压(箭头型代码);而 let else 先处理失败并退出,余下的主体就能保持平坦。这相当于把"哨兵子句(guard clause)"和 Rust 的类型系统结合了起来。
Result — 把失败变成值
现在来看错误处理。Result<T, E> 是一个装着"成功则为 T,失败则为 E"的枚举。
enum Result<T, E> {
Ok(T), // 成功,结果是 T
Err(E), // 失败,错误是 E
}
与异常的核心区别在这里。异常是从函数里被抛出,要么在某处被捕获,要么让程序崩溃。相反,Result 只是一个返回值。失败的可能性被写进了函数签名里的 -> Result<T, E>,调用方拿到这个值后,必须处理它究竟是 Ok 还是 Err。错误处理不再是控制流里一条隐藏的岔路,而变成了肉眼可见、平平常常的值流动。
use std::num::ParseIntError;
fn parse(s: &str) -> Result<i64, ParseIntError> {
s.parse::<i64>() // parse 返回一个 Result
}
fn main() {
match parse("42") {
Ok(n) => println!("数字: {n}"),
Err(e) => println!("解析失败: {e}"),
}
}
因为 Rust 没有异常,几乎所有可能失败的标准库函数(打开文件、解析字符串、发起网络请求)都返回 Result。而且 Result 被标记了 #[must_use],忽略掉返回的 Result 会让编译器发出警告。它就是被设计成很难悄悄吞掉错误。
? 运算符 — 错误传播的简洁写法
每次都用 match 拆开 Result,代码很快会变得杂乱,尤其是要把多个可能失败的操作串起来的时候。这正是 ? 运算符的用武之地。
? 加在 Result(或 Option)后面,行为是这样的:如果是 Ok(v),就取出里面的 v 作为表达式的值;如果是 Err(e),就立刻从当前函数 return 这个 Err。 换句话说,"成功就继续,失败就把这个错误往上抛"这件事,被压缩成了一个字符。
use std::fs;
use std::io;
// 不用 ? — 冗长
fn read_len_verbose(path: &str) -> Result<usize, io::Error> {
let contents = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return Err(e), // 手动传播失败
};
Ok(contents.len())
}
// 用 ? — 行为相同,精简得多
fn read_len(path: &str) -> Result<usize, io::Error> {
let contents = fs::read_to_string(path)?; // 失败时自动返回 Err
Ok(contents.len())
}
这两个函数做的事情完全一样,? 只是替代了那套"match 之后提前返回"的模式。当你把多个操作串联起来时,它的价值才真正显现。
fn process(path: &str) -> Result<i64, Box<dyn std::error::Error>> {
let text = fs::read_to_string(path)?; // 传播 io::Error
let first_line = text.lines().next().unwrap_or("");
let number: i64 = first_line.trim().parse()?; // 传播 ParseIntError
Ok(number * 2)
}
这里 ? 还多做了一件事:错误类型转换。 read_to_string 产出的是 io::Error,parse 产出的是 ParseIntError,而函数的返回类型是 Box<dyn std::error::Error>。? 通过 From trait,把每种错误自动转换成返回类型,把不同的错误顺畅地汇聚进同一个返回类型里。这一点会和下一节的错误类型设计衔接起来。
提醒一下:? 只能在返回类型是 Result(或 Option)的函数里使用 — 只有返回类型对得上,才能 return 那个 Err。
unwrap 与 expect — 什么时候可以 panic
还有 .unwrap() 和 .expect(),它们会强行从 Option 或 Result 里取出值。碰到 Ok/Some 就给你值;碰到 Err/None 就panic(恐慌)并终止程序。
let n: i64 = "42".parse().unwrap(); // Ok(42) → 42
let m: i64 = "abc".parse().unwrap(); // Err → panic!
let k: i64 = "abc".parse()
.expect("配置值必须是整数"); // panic + 这条消息
unwrap 方便,但危险。在失败真的不可能发生的地方(比如刚验证过的值),或者在原型代码、示例、测试里用它没问题。但不该在生产代码的正常路径里滥用它。 对用户输入、文件、网络这类可能失败的东西调用 unwrap,和放任异常不去捕获没什么两样。这种时候应该用 ? 传播,或者用 match 来处理。expect 至少会在消息里留下"为什么我认为这里不可能失败"的理由,所以比起 unwrap,更推荐用 expect。
概括一下,panic!(以及 unwrap)用于不可恢复的bug 场景,而 Result 用于可恢复的、预期之内的失败。"文件可能不存在"是可恢复的(→ Result),"数组下标是负数"是程序逻辑上的 bug(→ panic)。
thiserror 与 anyhow — 设计实战中的错误类型
光用标准库也能做,但实战项目通常会用到两个 crate,它们的分工很明确。
thiserror 用于库。 库应该暴露具体的错误类型,好让调用方能按种类分别处理错误。thiserror 能零样板地生成那个自定义错误 enum。
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("无法读取配置文件")]
Io(#[from] std::io::Error), // #[from] 自动转换 io::Error
#[error("无效的端口号: {0}")]
InvalidPort(u16), // 携带数据的变体
#[error("缺少必需的键: {key}")]
MissingKey { key: String },
}
#[derive(Error)] 自动生成 std::error::Error trait 的实现,#[error("...")] 自动生成给人看的消息(Display)。带 #[from] 的变体支持从那种错误类型自动转换,所以在函数里只用一个 ?,io::Error 就变成了 ConfigError::Io。库的使用者可以对这个 enum 做 match,区分"是 IO 问题,还是端口不对",并分别应对。
anyhow 用于应用程序。 在最终应用(CLI、服务端)的很多地方,你不需要按种类区分错误,只需要知道"失败了,而且是在这种上下文下失败的"。anyhow::Error 是一个能装下任何错误的单一类型,所以可以用 ? 自由地混合、传播各种不同的错误。
use anyhow::{Context, Result};
fn load_config(path: &str) -> Result<String> { // anyhow::Result
let text = std::fs::read_to_string(path)
.with_context(|| format!("打开配置文件失败: {path}"))?; // 附加上下文
let port_line = text.lines().next()
.context("文件为空")?;
Ok(port_line.to_string())
}
anyhow 的强项是 .context()。它会把"当时在尝试做什么、结果失败了"这样的上下文一层层叠加到错误上,于是最终的错误消息不再是"文件不存在"这种底层信息,而会变成一条可追溯的链条,比如"打开配置文件失败: /etc/app.conf → 文件不存在"。
总结一下:如果你在写库,就用 thiserror 暴露具体的错误类型;如果你在写应用程序,就用 anyhow 附加上下文、轻松地传播错误。这两个 crate 的组合,是当今 Rust 错误处理事实上的标准。
动手掌握
match 的穷尽性、? 的传播行为,只有亲自编译一遍、亲眼碰到那些报错,才能真正理解透彻。在 Rust 学习实验室里,用各种场景实验 Option/Result 的解构和 ? 运算符,你会亲手体会到"编译器在替我盯着,不让我漏掉任何一种情况"是什么感觉。
结语
Rust 错误处理的哲学可以归结成一句话:失败也是一种值。 它关上了 null 和异常这两扇隐藏的侧门,把"缺失"与"失败"装进 Option、Result 这两个普通的值里,交给类型来承载。打开这些值的钥匙是模式匹配,而编译器的穷尽性检查会替你捕捉到"你忘了处理这种情况"。
代价是,代码乍一看可能会显得有点啰嗦。但 ? 运算符扫掉了大部分这种啰嗦,剩下的是一个在哪里、什么会失败都在代码里诚实地摆着的程序。与其在生产环境的凌晨三点盯着 NullPointerException 的堆栈跟踪发呆,不如在编译期就听到"这种情况你没处理" — 这就是这套设计做出的取舍。
参考资料
- The Rust Programming Language, 第 6 章 "Enums and Pattern Matching": https://doc.rust-lang.org/book/ch06-00-enums.html
- The Rust Programming Language, 第 9 章 "Error Handling": https://doc.rust-lang.org/book/ch09-00-error-handling.html
- thiserror crate: https://docs.rs/thiserror/
- anyhow crate: https://docs.rs/anyhow/
- Rust by Example — Error handling: https://doc.rust-lang.org/rust-by-example/error.html