2026最新Rust入门教程[09]

0 阅读25分钟

theme: juejin highlight: github

Rust Book 编译版 09 · 错误处理:panic!、Result 与 ? 运算符

编译自 The Rust Programming Language(2024 edition),在原文基础上做了删减与补充。© The Rust Project Developers,依据 MIT OR Apache-2.0 许可编译转载。

开始之前

错误处理是 Rust 类型系统的延伸。别的语言把「可能失败」藏进控制流:异常(exception)可以在任意一层突然炸开,错误码则把 if err != nil 铺满每一个调用点;Rust 把它编码进返回类型——一个会失败的函数,签名里就明写着 Result<T, E>,编译器不处理完这个「可能」就不放行。于是错误处理从「运行时祈祷」变成「编译期类型检查」的一部分,这是 Rust 程序健壮性的主要来源:很多错误在你部署到生产环境之前就已被发现并处理。

具体到机制,Rust 把错误分成可恢复错误(recoverable error)与不可恢复错误(unrecoverable error)两类。文件不存在这类可恢复错误,多半报告用户、重试一下就好;数组越界这类不可恢复错误往往是 bug 的征兆,该做的是立即停下。多数语言不区分这两类,用异常一视同仁——Rust 没有异常,而是给两类错误各配了机制:处理可恢复错误的 Result<T, E> 类型,和让程序停止执行的 panic! 宏。全章结构:

错误的两条通道
├─ 不可恢复(bug、契约被破坏):panic! 宏
│    打印失败信息 → 栈展开清理(或直接中止)→ 进程退出
└─ 可恢复(预期中的失败):Result<T, E>
     ├─ Ok(T)   → 取出值,继续执行
     └─ Err(E)  → 当场处理,或用 ? 运算符传播给调用方

前置知识:第 8 章「常用集合」。

你会学到:

  • 触发并解剖 panic:读 panic 信息与 RUST_BACKTRACE 回溯,理解栈展开(unwinding)与中止(aborting)的取舍
  • 用 match 精确处理 Result、按错误类型分流,掌握 unwrap / expect / unwrap_or_else 各自的适用场景
  • 用 ? 运算符向上传播错误,弄清它在 Result、Option 与 main 上的使用边界
  • 判断 panic 与 Result 的取舍:坏状态(bad state)、契约(contract),以及把验证编码进类型

panic! 与不可恢复错误

代码有时会陷入你无能为力的坏局面,这时 Rust 给你 panic! 宏。实践中引发 panic 有两条路:执行一个必然 panic 的操作(比如访问数组末尾之后的元素),或者显式调用 panic!。默认情况下,panic 会打印一条失败信息、展开并清理栈,然后退出;设一个环境变量,还能让它显示调用栈,方便定位源头。

panic 时选择栈展开还是中止

「展开并清理栈」的正式名字是栈展开(unwinding):Rust 沿着栈往回走,逐一清理经过的每个函数中的数据。往回走加清理是不小的工作量,所以 Rust 也允许选择直接中止(aborting)程序——不做清理、立即结束,内存交还操作系统。如果项目需要尽可能小的二进制,可以在 Cargo.toml 对应的 [profile] 段切换行为,比如让 release 模式下 panic 直接中止:

[profile.release]
panic = 'abort'

编译注: 栈展开还有一层意义:多线程程序里,一个线程 panic 默认只终结那个线程,进程可能继续运行;切到 abort 后,任何 panic 都直接带走整个进程。展开代码本身也会让二进制变大,所以体积敏感的部署常关掉它。第 21 章写多线程 Web 服务器时,多个 worker 线程共享一个进程,届时回头看这个选择会更有体感。

在一个简单的程序里试一次:

fn main() {
    panic!("crash and burn");
}

运行输出:

cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.25s
     Running `target/debug/panic`

thread 'main' (6018279) panicked at src/main.rs:2:5:
crash and burn
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

最后两行就是 panic! 的效果:panic 消息加上发生位置——src/main.rs:2:5 指 src/main.rs 第 2 行第 5 个字符。这个例子指向的是我们自己的代码;另一些情况下,panic! 调用发生在我们调用的库代码里,报出的文件名和行号就是别人的代码了。要定位「我们自己代码里引发问题的那一行」,靠的是栈回溯(backtrace)。示例 9-1 演示这种情形:panic! 不是我们直接调用,而是代码中的 bug 从库中触发。

fn main() {
    let v = vec![1, 2, 3];

    v[99];
}

示例 9-1:尝试访问 vector 中末尾之后的元素,这将引发一次 panic! 调用

这里试图访问 vector 的第 100 个元素(索引从零开始,所以是 99),但它只有 3 个元素。[] 索引本应返回一个元素,索引无效时不存在「可以正确返回」的值,于是 Rust panic。

对照 C:读数据结构末尾之外属于未定义行为(undefined behavior),你可能读到不属于这块结构的内存——这叫缓冲区越界读取(buffer overread);攻击者一旦能操纵索引,就能读走存储在后面的本不该读的数据,直接构成安全漏洞。为了堵住这类漏洞,Rust 在你试图读取不存在的索引时停止执行、拒绝继续:

cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/panic`

thread 'main' (6017887) panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

错误指向 main.rs 第 4 行,正是 v[99]。note: 行提示设置 RUST_BACKTRACE 环境变量可以拿到回溯——它列出 panic 发生前所有被调用过的函数。读法:从最顶端往下读,直到第一个你自己编写的文件,那里就是问题源头;它上面是你的代码调用的代码,下面是调用你的代码的代码,中间可能混着 Rust 核心代码、标准库或某个 crate。把它设为除 0 外的任意值再跑一次,输出类似示例 9-2。

RUST_BACKTRACE=1 cargo run
thread 'main' panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
stack backtrace:
   0: rust_begin_unwind
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/std/src/panicking.rs:692:5
   1: core::panicking::panic_fmt
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/core/src/panicking.rs:75:14
   2: core::panicking::panic_bounds_check
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/core/src/panicking.rs:273:5
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/slice/index.rs:274:10
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/slice/index.rs:16:9
   5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/alloc/src/vec/mod.rs:3361:9
   6: panic::main
             at ./src/main.rs:4:6
   7: core::ops::function::FnOnce::call_once
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

示例 9-2:设置环境变量 RUST_BACKTRACE 后显示的、由 panic! 调用生成的栈回溯

具体输出因操作系统和 Rust 版本而异;要拿到这些信息需要调试符号(debug symbol),不带 --release 的 cargo build / cargo run 默认启用。输出中第 6 行 panic::main 指向我们项目的 src/main.rs 第 4 行,排查就从这个位置开始——这次 panic 的修复方法,就是别去请求越界的索引。将来你的代码 panic 时,要弄清的是:代码当时在对什么值做什么操作才触发了 panic,以及代码本应做什么。

何时该 panic、何时不该,本章末尾「panic! 还是不 panic!」会正面回答。先看多数错误的归宿:用 Result 恢复。

Result 与可恢复错误

大多数错误没严重到要停掉整个程序:失败原因明确,你可以解读并响应。比如打开文件因为文件不存在而失败,此时也许想创建它,而不是终止进程。第 2 章见过 Result 枚举的两个变体:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

T 和 E 是泛型类型参数(第 10 章详谈),现在只需知道:T 是成功时装进 Ok 的值的类型,E 是失败时装进 Err 的错误的类型。有这两个参数,各种「成功值、错误值各不相同」的场景都能用同一个类型表达。

示例 9-3 调用一个可能失败、因而返回 Result 的函数:

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");
}

示例 9-3:打开一个文件

File::open 的返回类型是 Result<T, E>:T 被实现填充为成功值类型 std::fs::File(文件句柄),E 被填充为 std::io::Error。意思是:调用可能成功,返回可供读写的句柄;也可能失败——文件不存在、没有权限。Result 把两种结果连同各自的载荷一起交到你手里:成功时 greeting_file_result 里是装着句柄的 Ok 实例;失败时是装着错误详情的 Err 实例。接下来要根据它采取不同行动——基本工具是第 6 章的 match 表达式,见示例 9-4。

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {error:?}"),
    };
}

示例 9-4:使用 match 表达式处理可能返回的 Result 变体

和 Option 一样,Result 及其变体也由 prelude 引入作用域,match 分支里直接写 Ok / Err,不用 Result:: 前缀。结果是 Ok 时取出里面的 file 赋给 greeting_file,之后就能用它读写;是 Err 时这里选择 panic!。当前目录没有 hello.txt 的话,运行会看到:

cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/error-handling`

thread 'main' (6018048) panicked at src/main.rs:8:23:
Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

匹配不同的错误

示例 9-4 不论 File::open 因何失败都会 panic。更合理的策略是按原因分流:因为文件不存在而失败,就创建它并返回新句柄;因为其他原因(比如没有权限)失败,才 panic。为此在 Err 分支里再嵌一个 match,见示例 9-5。

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {e:?}"),
            },
            _ => {
                panic!("Problem opening the file: {error:?}");
            }
        },
    };
}

示例 9-5:以不同方式处理不同类型的错误

Err 里的错误类型是标准库结构体 io::Error,它的 kind 方法返回 io::ErrorKind 枚举——变体代表 io 操作可能产生的各种错误,这里要用的是 ErrorKind::NotFound。内层匹配检查 error.kind() 是否为 NotFound:是就用 File::create 尝试创建——它同样可能失败,所以还要一层分支在创建失败时 panic;其他任何错误落到 _ 分支,照旧 panic。

在 Result<T, E> 上使用 match 的替代方案

match 很有用,但也很「原始」,示例 9-5 这种层层嵌套读起来费劲。第 13 章学到闭包(closure)后你会发现,Result<T, E> 上定义的许多方法都能写得比 match 简洁。比如用 unwrap_or_else 改写同样的逻辑:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Problem creating the file: {error:?}");
            })
        } else {
            panic!("Problem opening the file: {error:?}");
        }
    });
}

行为与示例 9-5 完全相同,但一个 match 都没有。这类方法还有不少,读完第 13 章后建议回头再看这个例子,并到标准库文档里查一查 unwrap_or_else。

出错时 panic 的快捷方式

Result<T, E> 上定义了许多辅助方法。unwrap 就是示例 9-4 那个 match 的快捷方式:值是 Ok 就返回里面的值,是 Err 就替你调用 panic!:

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap();
}

没有 hello.txt 时运行,会看到:

thread 'main' panicked at src/main.rs:4:49:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }

expect 则允许自定义 panic 信息。用它代替 unwrap 并写清「为什么这里预期能成功」,既表达意图,也让日后追踪 panic 源头更容易:

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")
        .expect("hello.txt should be included in this project");
}

输出:

thread 'main' panicked at src/main.rs:5:10:
hello.txt should be included in this project: Os { code: 2, kind: NotFound, message: "No such file or directory" }

生产级代码中,多数 Rustacean 会选 expect 而非 unwrap,并给出说明成功预期的上下文——这样一旦假设被证伪,手头有更多信息可用于调试。

错误的传播

函数实现里调用了可能失败的操作时,与其就地处理,不如把错误返回给调用方,让它决定怎么办。这叫错误的传播(propagate):调用方那里往往有更多信息或逻辑来处置这个错误。示例 9-6 是一个从文件读取用户名的函数,文件不存在或读不了就把错误返回给调用者:

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let username_file_result = File::open("hello.txt");

    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}

示例 9-6:使用 match 把错误返回给调用方代码的函数

先看返回类型 Result<String, io::Error>:T 是 String,E 是 io::Error——函数体里两个可能失败的操作(File::open 与 read_to_string)的错误类型恰好都是它。函数体先 File::open,用与示例 9-4 类似的 match 处理:成功则拿到句柄继续;失败则用 return 提前返回,把错误原样交还调用方。之后新建 String、调用 read_to_string 把内容读进去——它也可能失败,所以还有第二个 match:成功就把 username 包进 Ok 返回;失败就返回错误(这里是函数最后一个表达式,不必显式写 return)。

调用方拿到的要么是装着用户名的 Ok,要么是装着 io::Error 的 Err。怎么处置由它定:panic、用默认用户名、换个地方找用户名——我们没有足够的信息替它决定,所以把成功与错误统统上交。

这种「match、失败就 return」的传播模式在 Rust 里太常见,于是有了 ? 运算符。

? 运算符快捷方式

示例 9-7 与示例 9-6 功能完全相同,只是用了 ?:

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username_file = File::open("hello.txt")?;
    let mut username = String::new();
    username_file.read_to_string(&mut username)?;
    Ok(username)
}

示例 9-7:使用 ? 运算符把错误返回给调用方代码的函数

? 放在 Result 值后面,行为与刚才那些 match 几乎一致:值是 Ok 就取出里面的值继续执行;是 Err 就像写了 return 一样从整个函数提前返回,把错误传播给调用方。

编译注: 写过 Go 的话会立刻认出这个问题的形状:if err != nil { return err } 在每个调用点重复四行,一个函数读下来一半是错误检查。? 把同样的语义压成一个字符,并且只在返回 Result 的函数里才允许用——错误传播路径由函数签名保证,而不是靠纪律。另一个差别:Go 里包装错误要显式 fmt.Errorf("...: %w", err),而 ? 会自动调用 From::from 做类型转换(下一段正文就讲到)。

说「几乎一致」,因为有一处差别:被 ? 作用的错误值会经过标准库 From trait 的 from 函数,转换成当前函数返回类型中定义的错误类型。当一个函数想用统一的错误类型表示所有失败方式时,这非常有用——比如让 read_username_from_file 返回自定义的 OurError,再实现 impl From<io::Error> for OurError,函数体里的 ? 就会自动完成转换,无需额外代码。

? 省掉了样板,代码还能更短:在 ? 之后直接链式调用,如示例 9-8。

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();

    File::open("hello.txt")?.read_to_string(&mut username)?;

    Ok(username)
}

示例 9-8:在 ? 运算符之后链式调用方法

新建 String 挪到函数开头,然后不再引入中间变量,把 read_to_string 直接链在 File::open("hello.txt")? 的结果上。功能与示例 9-6、9-7 相同,写法更称手。示例 9-9 则是标准库给出的最终答案:

use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}

示例 9-9:使用 fs::read_to_string 代替先打开文件再读取的操作

「打开文件读成字符串」太常用了,标准库直接提供了 fs::read_to_string:打开、新建 String、读入、返回,一步到位。当然,直接用它就没机会讲这么多错误处理了,所以书里先绕了远路。

在哪里可以使用 ? 运算符

? 被定义为「从函数中提前返回一个值」,因此只能用在返回类型与其作用的值兼容的函数里——对 Result 用 ?,函数必须返回 Result。示例 9-10 故意在返回 () 的 main 里用 ?,看看报什么错:

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")?;
}

示例 9-10:在返回 () 的 main 函数中尝试使用 ?,无法通过编译。

编译错误如下:

cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> src/main.rs:4:48
  |
3 | fn main() {
  | --------- this function should return `Result` or `Option` to accept `?`
4 |     let greeting_file = File::open("hello.txt")?;
  |                                                ^ cannot use the `?` operator in a function that returns `()`
  |
help: consider adding return type
  |
3 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
4 |     let greeting_file = File::open("hello.txt")?;
5 +     Ok(())
  |

For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling` (bin "error-handling") due to 1 previous error

错误说得很明白:? 只能用在返回 Result、Option 或其他实现了 FromResidual 的类型的函数中。修复有两条路:把函数返回类型改成兼容的类型,或者用 match / Result<T, E> 上的方法就地处理。

错误信息里还提到 ? 可用于 Option<T>:同理,只有返回 Option 的函数里才能对 Option 用 ?,行为类似——值是 None 就提前返回 None,是 Some 就取出值继续。示例 9-11 找出给定文本第一行的最后一个字符:

fn last_char_of_first_line(text: &str) -> Option<char> {
    text.lines().next()?.chars().last()
}

示例 9-11:在 Option<T> 值上使用 ? 运算符

lines 返回逐行迭代器,next 取第一行:text 是空字符串时得到 None,? 就此提前返回 None;否则 ? 取出第一行的字符串切片,chars().last() 拿到最后一个字符——它也是 Option,因为第一行可能是空的(比如 "\nhi",以空行开头)。没有 ?,这段逻辑得靠更多方法调用或一个 match 才能写下。

注意 Result 与 Option 不能混用:? 不会自动在两者之间转换,这类场景可以用 Result 上的 ok 方法或 Option 上的 ok_or 方法显式完成转换。

到目前为止我们写过的 main 都返回 ()。main 是可执行程序的入口和出口,返回类型受限制——但幸运的是,它也可以返回 Result<(), E>。示例 9-12 沿用示例 9-10,把返回类型改为 Result<(), Box<dyn Error>>、末尾补上 Ok(()),就能编译了:

use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let greeting_file = File::open("hello.txt")?;

    Ok(())
}

示例 9-12:把 main 改为返回 Result<(), E>,就可以在 Result 值上使用 ? 运算符。

Box<dyn Error> 是一种 trait 对象(第 18 章讨论),眼下把它理解成「任何一种错误」即可:任何 Err 值都允许被 ? 提前返回。当前函数体只会产生 std::io::Error 类型的错误,但写成 Box<dyn Error> 后,日后往 main 里添加返回其他错误的代码,这个签名依然正确。

编译注: 第一次见 Box<dyn Error> 不用慌,这是示例和小工具程序里最常见的「万能错误类型」写法。两处拆解:dyn Error 是 trait 对象,第 18 章「使用 trait 对象抽象共享行为」细讲;Box 是指向堆的智能指针,第 15 章细讲;第 20 章讲高级类型时还会再遇到 dyn。现在先记住这个写法就好。

main 返回 Result<(), E> 时,Ok(()) 对应退出码 0,Err 对应非零退出码——沿用 C 程序的惯例。更进一步,main 可以返回任何实现了 std::process::Termination trait 的类型(该 trait 有一个返回 ExitCode 的 report 函数),想为自己的类型实现它可查阅标准库文档。

panic! 还是不 panic!

正面回答那个问题:什么时候 panic,什么时候返回 Result?代码一旦 panic 就再无恢复的可能。你可以对任何错误都调用 panic!,但那等于替调用方做了「此情况不可恢复」的决定;返回 Result 则把选择权交还调用方——它可以酌情尝试恢复,也可以认定不可恢复、自己调用 panic!,把可恢复错误变成不可恢复错误。所以,定义可能失败的函数时,返回 Result 是不错的默认选择。

也有些场景反过来,直接 panic 比返回 Result 更合适:示例、原型、测试,以及你比编译器掌握更多信息的情况。下面依次看。

示例、原型代码与测试

演示概念的示例若塞进一套健壮的错误处理,示例本身就不清晰了——示例里的 unwrap 应被理解为占位符,代表「你希望应用程序处理错误的方式」,具体怎么做取决于其余代码。原型开发同理:还没想好怎么处理错误时,unwrap 和 expect 顺手,而且在代码里留下清晰的标记,等程序要变健壮时再回头处理。测试中则希望任何调用失败都让整个测试失败——而 panic 正是测试被标记为失败的方式,所以测试里用 unwrap / expect 恰如其分。

当你比编译器掌握更多信息时

有时你手里有编译器理解不了的逻辑,能确保 Result 一定是 Ok。操作总体上仍可能失败,只是在你这个特定场景逻辑上不可能——人工检查确认不会出现 Err 后,调用 expect 并在参数文本里写明理由,是完全可接受的做法:

use std::net::IpAddr;

let home: IpAddr = "127.0.0.1"
    .parse()
    .expect("Hardcoded IP address should be valid");

127.0.0.1 显然是合法 IP,这里用 expect 没问题。但硬编码合法字符串并不改变 parse 的返回类型:拿到的仍是 Result,编译器依然要求你像对待可能失败的调用那样处理它,因为它看不出这个字符串永远有效。要当心场景切换:如果 IP 字符串改从用户输入来、失败成为真实可能,就必须换更健壮的方式处理——把「该地址是硬编码的」这一假设写进 expect 的消息,将来改从其他来源取值时,它会提醒你换掉 expect。

错误处理指南

当你的代码可能进入某种坏状态(bad state)——假设、保证、契约或不变量被打破,比如传来了无效值、相互矛盾的值或缺失的值——且满足下列一条或几条时,让它 panic 是明智的:

  • 这种坏状态是意料之外的,而不像「用户输入格式不对」那种时不时就会发生的情况
  • 从这一点开始,代码需要默认自己不处于坏状态,而不是每一步都重新检查
  • 没有好办法把这些信息编码进所用的类型里(第 18 章「把状态和行为编码为类型」会举例说明)

如果有人调用你的代码并传来了讲不通的值,尽量返回错误,让库的使用者自己决定怎么办;但若继续执行可能不安全或有害,panic! 往往是更好的选择——它提醒使用者「你的代码里有 bug」,让他们在开发阶段就修掉。调用不受你控制的外部代码、而它返回了你无法修复的无效状态时,panic 通常也合适。

反过来,失败在意料之中时,返回 Result:解析器收到格式错误的数据、HTTP 请求返回表明触发限流的状态码——这些失败是预期中的可能,必须由调用方决定如何处理。

还有一条安全准则:操作在收到无效值时可能使用户面临风险,代码就应该先验证、无效则 panic。标准库对越界内存访问调用 panic!,主要原因正在于此。函数往往带有契约(contract):输入满足特定要求,行为才有保证;违约即 panic 是合理的,因为违约总意味着调用方有 bug——调用方代码没有任何合理的恢复办法,需要修代码的是写它的程序员。会导致 panic 的契约,应在函数的 API 文档中写清楚(第 14 章讲文档规范)。

在所有函数里塞满检查既啰嗦又烦人,好在可以让类型系统代劳:函数以特定类型为参数,编译器就已确保拿到有效值。手里是具体类型而不是 Option,就只剩「确定有值」一种情况,不必同时处理 Some 和 None——试图「什么都不传」的代码根本编译不过;参数用 u32 这样的无符号整数,就永远不可能是负数。检查发生在编译期,而不是散落各处的运行时。

用于验证的自定义类型

再进一步:为验证创建自定义类型。回忆第 2 章猜数字游戏——我们请用户猜 1 到 100 的数,却从未验证猜测是否落在范围内,只验证了它是正数。后果不算严重(「太大了/太小了」的提示仍然是对的),但若能区分「超出范围」与「输入了字母」,会更实用。一种做法是解析成 i32 并加范围检查:

loop {
    // --省略--

    let guess: i32 = match guess.trim().parse() {
        Ok(num) => num,
        Err(_) => continue,
    };

    if guess < 1 || guess > 100 {
        println!("The secret number will be between 1 and 100.");
        continue;
    }

    match guess.cmp(&secret_number) {
        // --省略--
}

if 检查超出范围就告知用户并 continue 请求新输入;过了这道检查,guess 就确定在 1 到 100 之间。但如果「只允许 1 到 100」是硬性要求、很多函数都有这条要求,每个函数里都写一遍检查就繁琐了(还可能影响性能)。更好的办法:在一个专门的模块中创建新类型,把验证放进创建实例的函数里,而不是到处重复;各函数在签名里直接用它,就能放心地使用收到的值。示例 9-13 定义了 Guess 类型——只有值在 1 到 100 之间,new 才会创建实例:

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}

示例 9-13:只接受 1 到 100 之间取值的 Guess 类型

这段代码位于 src/guessing_game.rs,并依赖在 src/lib.rs 中添加 mod guessing_game; 模块声明(这里未展示)。逐段看:Guess 结构体有一个存放 i32 的 value 字段;关联函数 new 接收 i32、测试范围,越界就 panic!——提醒调用方程序员「你们有一个需要修复的 bug」,因为用范围外的值创建 Guess 违反了 Guess::new 依赖的契约(new 会在哪些条件下 panic,应写进 API 文档,第 14 章讲文档规范);通过测试就构造 Guess 并返回。

value 字段是私有的,取值靠 value 方法——这类getter(取值方法) 借用 self、返回 i32。字段保持私有很关键:模块外的代码无法直接设置 value,必须走 Guess::new 创建实例,于是 Guess 的值绝无可能绕过范围检查。此后,只接受 1 到 100 数字的函数在签名里声明 Guess 而不是 i32,函数体里无需任何额外检查。

小结与下一步

  • Rust 把错误分成两条通道:不可恢复错误走 panic!(打印消息、栈展开或中止、退出),可恢复错误走 Result<T, E>——「可能失败」被编码进返回类型,由编译器把关
  • 处理 Result 的梯度:match 精确分流 → unwrap_or_else 等方法去掉样板 → unwrap / expect 一行了断;生产代码选 expect 并写明理由,示例、原型、测试里 unwrap 是合理占位
  • ? 运算符把「Ok 取值、Err 提前返回」压成一个字符,还会经 From 自动转换错误类型;只能用在返回兼容类型的函数里——包括返回 Result<(), Box<dyn Error>> 的 main
  • 选择准则:bug、坏状态、契约被违反 → panic;预期中的失败 → 返回 Result 交给调用方
  • 更进一步:把验证编码进类型(Guess),让检查发生在编译期,而不是每个函数的运行时

下一篇:第 10 章「泛型、trait 与生命周期」——Rust 的三大复用机制,抽象不牺牲性能的底气所在。