卷 III · 用类型说话CH 12深度 12/24

Result 与 ?:错误是值,不是异常

ResultOption 的孪生兄弟:Option 说「可能没有」,Result 说「可能失败,失败时还带上原因」。Rust 没有异常 —— 错误就是一个普通的返回值,你必须处理或明确往上抛。这看起来啰嗦,直到你遇见 ?:一个字符,把「失败就提前返回」的四行样板压回一个问号。

Result? 运算符错误即值panic

又一个普通枚举

Option 一样,Result 也只是标准库里一个普通枚举 —— 无非多带了「失败的原因」:

enum Result<T, E> {
    Ok(T),     // 成功,带着结果 T
    Err(E),    // 失败,带着错误 E
}

凡是可能失败的操作,返回值都是 Result:读文件(可能不存在)、解析数字(可能不是数字)、发网络请求(可能超时)。函数签名里的 -> Result<Config, ParseError> 就是一句诚实的话:「我可能给你一个 Config,也可能给你一个 ParseError,你两种都得准备好」。

和 Option 一样,你也绕不过失败那一半 —— 想拿到 Ok 里的值,就得处理 Err

match "42".parse::<i32>() {
    Ok(n) => println!("解析出 {}", n),
    Err(e) => println!("解析失败:{}", e),
}

没有异常,意味着什么

这是 Result 和你熟悉的 try/catch 之间,最本质的差别。想清楚它,你就懂了 Rust 错误处理的全部哲学:

◆ 异常 vs 错误即值

异常是一条隐藏的控制流:任何一行代码都可能突然抛出,跳过后面所有语句,一路窜到某个不知道在哪的 catch。你看一个函数签名,看不出它会不会抛、抛什么 —— 除非去读实现、读文档、或者等它半夜炸给你看。

错误即值:失败就是一个普通返回值,明明白白写在签名的 -> Result<T, E> 里。控制流没有任何隐藏跳转,函数会不会失败、失败是什么类型,一眼看得见,且编译器强制你面对

代价看起来是啰嗦 —— 每个可能失败的调用后面,好像都要跟一段处理。如果真是这样,没人受得了。所以 Rust 给了 Result 一个杀手锏。

? :把样板压成一个字符

绝大多数时候,你对错误的处理其实是「这一步要是失败了,我这个函数也没法继续,就把这个错原样往上抛给我的调用者」。这个模式太常见了,Rust 给它一个专门的运算符 ?

fn read_config() -> Result<Config, Error> {
    let text = std::fs::read_to_string("config.toml")?;  // 读失败?直接 return Err
    let config = parse(&text)?;                            // 解析失败?直接 return Err
    Ok(config)                                             // 全过了,返回 Ok
}

每个 ? 的含义是:如果是 Ok(v),就取出 v 继续往下;如果是 Err(e),就立刻 return Err(e),函数到此为止。它其实就是下面这坨 match 的语法糖:

let text = match std::fs::read_to_string("config.toml") {
    Ok(v) => v,
    Err(e) => return Err(e),      // ← ? 帮你写的就是这一行
};

于是你得到了两全:错误处理像异常一样不啰嗦(一个问号),又像返回值一样显式可见(问号就摆在那,你知道这里会短路)。这可能是 Rust 语言设计里最讨人喜欢的一笔。

▶ 看 ? 在哪一步短路

下面这个函数解析 "端口,主机",中间有四个可能失败的 ?。换不同的输入,看它在哪一步失败、然后后面的步骤根本不执行、直接返回那一步的 Err。解析是真跑的。

玩一玩你就体会到 ? 的精髓:"abc,localhost" 在第 ② 步(解析数字)就短路了,③④ 根本没跑;"80,localhost" 一路过到第 ④ 步(范围检查)才失败。错误在哪产生、在哪终止、带什么信息,全程可见 —— 没有一处是「不知从哪飞来的异常」。

不同的错误类型怎么统一:? 会帮你转换

上面 read_config 里,read_to_string 失败给的是 io::Errorparse 失败给的是 ParseError,两种不同的错误,怎么能都被同一个 ? 抛成函数声明的那个 Error?因为 ? 在抛出前,会自动尝试类型转换(通过一个叫 From 的 trait,下一章讲)。你只要让你的错误类型「能从各种子错误转换过来」,? 就能把五花八门的底层错误,统一收敛成你函数对外承诺的那一个错误类型。这套机制怎么优雅地搭起来,是第 22 章「工程化」的活(thiserror / anyhow 两个库让它几乎零样板)。

那 panic 是什么:不可恢复的错误

Rust 其实有两套错误机制,别搞混:

  • Result —— 可恢复的错误。「文件没找到」「用户输入不合法」这类预期之内、调用者有办法应对的失败。用返回值表达,让调用者决定怎么办。这是你 99% 的时候该用的。
  • panic! —— 不可恢复的错误。「数组越界」「断言失败」「程序进入了逻辑上不可能的状态」这类bug 级的失败。它会展开栈、终止当前线程。unwrap / expect(上一章那颗自装炸弹)触发的就是 panic。

判断标准:「调用者拿到这个失败,有合理的办法处理吗?」有 → Result;没有、这纯粹是个 bug → panic。把「文件没找到」写成 panic 是坏味道(那是正常情况,该让调用者处理);把「我代码里这个 index 逻辑上不可能越界,越了就是我写错了」交给 panic 则很合理。

回流

⟲ 回流 · 显式如 Go,优雅如异常

Java / Kotlin / Python:它们用异常。好处是主流程干净(错误处理被挪到 catch);坏处正是那份「隐藏」—— 一个方法会抛什么,签名基本不说(Java 的 checked exception 想解决这个,但因为太烦被大家用 throws Exception 或直接吞掉给绕过了)。你有没有见过 catch (Exception e) {} 这种把错误默默吃掉的代码?Rust 的 Result 让「吞掉错误」变成一件你必须显式做的事(比如 let _ = ...),从而扎眼、可审查。

Go:Go 也是「错误即值」—— if err != nil { return err } 满地都是。思想和 Rust 完全一致,都是显式、无隐藏跳转。但 Go 缺一个 ?,所以那三行样板得手写无数遍,是 Go 程序员公认的痛点。Rust = Go 的「错误即值」哲学 + 一个 ? 的人体工学。你要是写过 Go,会对 ? 一见钟情。

这一章的一句话

错误是普通返回值 Result<T, E>,不是从天而降的异常;你必须处理或用 ? 显式上抛。? 把「失败就提前返回」压成一个字符,让错误处理既显式又不啰嗦 —— 失败在哪产生、往哪走,全程看得见。

卷 III 已经给了你两样超能力:枚举让非法状态无法表达,Option/Result 让「空」和「错」无处遁形。还剩两章,讲 Rust 怎么做抽象 —— 而且是不花运行时成本的抽象。下一章:trait