Skip to content

필사 모드: Rust 枚举、模式匹配与错误处理

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 一个没有 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 那样只表示"种类",而是把恰好匹配那个种类的数据一并装了进去。这正是接下来要讲的 OptionResult 之所以成立的原因。

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 里取出值的正统做法是 matchmatch 把一个值和一组模式逐一比对,执行匹配上的分支,而 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(),它们会强行从 OptionResult 里取出值。碰到 Ok/Some 就给你值;碰到 Err/Nonepanic(恐慌)并终止程序。

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 和异常这两扇隐藏的侧门,把"缺失"与"失败"装进 OptionResult 这两个普通的值里,交给类型来承载。打开这些值的钥匙是模式匹配,而编译器的穷尽性检查会替你捕捉到"你忘了处理这种情况"。

代价是,代码乍一看可能会显得有点啰嗦。但 ? 运算符扫掉了大部分这种啰嗦,剩下的是一个在哪里、什么会失败都在代码里诚实地摆着的程序。与其在生产环境的凌晨三点盯着 NullPointerException 的堆栈跟踪发呆,不如在编译期就听到"这种情况你没处理" — 这就是这套设计做出的取舍。

参考资料

현재 단락 (1/153)

托尼·霍尔把发明 null 引用称为他"十亿美元的错误"。大多数主流语言至今仍背着这个错误活着 — 一个值可能有,也可能没有,但类型分不清这两种情况。于是 `NullPointerException`...

작성 글자: 0원문 글자: 7,422작성 단락: 0/153