进阶 Steve Klabnik / Carol Nichols 著,KaiserY 中译 2026-09-13 14:34:36 · 0 阅读

第9章 错误处理

错误是软件中的常态,因此 Rust 提供了许多特性来处理某些事情出错的情况。在很多场景下,Rust 会要求你先承认错误发生的可能性,并在代码能够通过编译之前采取一些行动。这项要求会让程序更健壮,因为它确保你会在把代码部署到生产环境之前就发现错误,并妥善处理它们。

Rust 将错误分成两大类:可恢复的recoverable)和不可恢复的unrecoverable)错误。对于可恢复错误,例如“文件未找到”这样的错误,我们多半只想把问题报告给用户,然后重试这次操作。不可恢复错误则总是 bug 的征兆,比如试图访问数组末尾之外的位置,因此我们希望立刻停止程序。

大多数语言并不区分这两类错误,而是使用诸如异常(exception)之类的机制以相同方式处理它们。Rust 没有异常。相反,它使用 Result<T, E> 类型来处理可恢复错误,使用 panic! 宏在程序遇到不可恢复错误时停止执行。本章会先介绍如何调用 panic!,然后再讲如何返回 Result<T, E> 值。此外,我们还会探讨:在决定是尝试从错误中恢复,还是直接停止执行时,需要考虑哪些因素。

用 panic! 处理不可恢复的错误

panic! 处理不可恢复的错误

有时,你的代码里会发生一些糟糕的事情,而且你对此无能为力。在这种情况下,Rust 提供了 panic! 宏。实际中有两种方式会导致 panic:一种是执行了会让代码 panic 的操作,比如访问超出数组结尾的位置;另一种是显式调用 panic! 宏。这两种情况都会让程序 panic。默认情况下,这些 panic 会打印失败信息、展开栈、清理栈数据,然后退出。你还可以通过环境变量,让 Rust 在 panic 发生时显示调用栈(call stack),以便更容易追踪 panic 的来源。

响应 panic 时的栈展开或终止

当出现 panic 时,程序默认会开始 展开unwinding),这意味着 Rust 会回溯栈并清理它遇到的每一个函数的数据,不过这个回溯并清理的过程有很多工作。另一种选择是直接 终止abort),这会不清理数据就退出程序。

那么程序所使用的内存需要由操作系统来清理。如果你需要项目的最终二进制文件越小越好,panic 时通过在 Cargo.toml[profile] 部分增加 panic = 'abort',可以由展开切换为终止。例如,如果你想要在 release 模式中 panic 时直接终止,可添加:

[profile.release]
panic = 'abort'

让我们在一个简单的程序中调用 panic!

文件名:src/main.rs

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' panicked at src/main.rs:2:5:
crash and burn
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

最后两行包含 panic! 调用造成的错误信息。第一行显示了 panic 提供的信息并指明了源码中 panic 出现的位置:src/main.rs:2:5 表明这是 src/main.rs 文件的第二行第五个字符。

在这个例子中,被指明的那一行是我们代码的一部分,如果跳转到该行,就会发现 panic! 宏的调用。在其它情况下,panic! 可能会出现在我们的代码所调用的代码中。错误信息报告的文件名和行号可能指向别人代码中的 panic! 宏调用,而不是我们代码中最终导致 panic! 的那一行。

使用 panic! 的 backtrace

我们可以利用触发 panic! 的函数 backtrace,找出代码里到底是哪一部分出了问题。为了理解如何使用 panic! 的 backtrace,让我们再看一个例子:这次 panic! 调用不是来自我们直接调用宏,而是因为我们代码里的 bug 触发了库中的 panic!。示例 9-1 展示了一段尝试访问 vector 有效索引范围之外元素的代码:

文件名:src/main.rs

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

    v[99];
}

示例 9-1:尝试访问超越 vector 结尾的元素,这会造成 panic!

这里尝试访问 vector 的第 100 个元素(这里的索引是 99 因为索引从 0 开始),不过它只有三个元素。这种情况下 Rust 会 panic。[] 应当返回一个元素,不过如果传递了一个无效索引,就没有可供 Rust 返回的正确元素。

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' 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 环境变量来得到 backtrace。backtrace 是一份到达当前执行点之前所有被调用函数的列表。Rust 中的 backtrace 和其他语言里的工作方式一样:阅读 backtrace 的关键,是从最上面开始往下读,直到看到你自己写的文件。那一处就是问题开始的地方。它上面的那些行,是你的代码调用过的代码;下面的那些行,则是调用了你代码的代码。这些前前后后的行,可能包括 Rust 核心代码、标准库代码,或你正在使用的 crate。现在把 RUST_BACKTRACE 环境变量设成除 0 之外的任意值,来看看 backtrace。示例 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! 调用所生成的 backtrace 信息

这里的输出很多!你实际看到的内容可能会因为操作系统和 Rust 版本不同而有所区别。要获得带有这些信息的 backtrace,必须启用调试符号(debug symbols)。当像这里这样,不带 --release 参数运行 cargo buildcargo run 时,调试符号默认就是启用的。

示例 9-2 的输出中,backtrace 的第 6 行指向了我们项目中造成问题的行:src/main.rs 的第 4 行。如果你不希望程序 panic,就应当从第一个提到我们自己编写的文件的那一行开始调查。在示例 9-1 中,我们故意编写了会导致 panic 的代码,修复这个 panic 的方法就是不要尝试在一个只包含三个项的 vector 中请求索引是 100 的元素。当将来你的代码出现了 panic,你需要搞清楚在这特定的场景下代码中执行了什么操作和什么值导致了 panic,以及应当如何处理才能避免该问题。

本章后面的小节 “要不要 panic!” 会再次回到 panic! 并讲解何时应该、何时不应该使用 panic! 来处理错误情况。接下来,我们来看看如何使用 Result 来从错误中恢复。

用 Result 处理可恢复的错误

Result 处理可恢复的错误

大部分错误并没有严重到需要程序完全停止执行。有时函数失败的原因很容易理解并加以处理。例如,如果因为打开一个并不存在的文件而失败,此时我们可能想要创建这个文件,而不是终止进程。

回忆一下第二章 “使用 Result 类型来处理潜在的错误” 部分中的那个 Result 枚举,它定义有如下两个变体,OkErr

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

TE 是泛型类型参数;第十章会详细介绍泛型。现在你需要知道的就是 T 代表成功时返回的 Ok 变体中的数据的类型,而 E 代表失败时返回的 Err 变体中的错误的类型。因为 Result 有这些泛型类型参数,我们可以将 Result 类型和标准库中为其定义的函数用于很多不同的场景,这些情况中需要返回的成功值和失败值可能会各不相同。

让我们调用一个返回 Result 的函数,因为它可能会失败:如示例 9-3 所示尝试打开一个文件:

文件名:src/main.rs

use std::fs::File;

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

示例 9-3:打开文件

File::open 的返回值是 Result<T, E>。泛型参数 T 会被 File::open 的实现放入成功返回值的类型 std::fs::File,这是一个文件句柄。错误返回值使用的 E 的类型是 std::io::Error。这些返回类型意味着 File::open 调用可能成功并返回一个可以读写的文件句柄。这个函数调用也可能会失败:例如,也许文件不存在,或者可能没有权限访问这个文件。File::open 函数需要一个方法在告诉我们成功与否的同时返回文件句柄或者错误信息。这些信息正好是 Result 枚举所代表的。

File::open 成功时,greeting_file_result 变量将会是一个包含文件句柄的 Ok 实例。当失败时,greeting_file_result 变量将会是一个包含了更多关于发生了何种错误的信息的 Err 实例。

我们需要在示例 9-3 的代码中增加根据 File::open 返回值进行不同处理的逻辑。示例 9-4 展示了一个使用基本工具处理 Result 的例子,第六章学习过的 match 表达式。

文件名:src/main.rs

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 分支中的 OkErr 之前指定 Result::

这里我们告诉 Rust 当结果是 Ok 时,返回 Ok 变体中的 file 值,然后将这个文件句柄赋值给变量 greeting_filematch 之后,我们可以利用这个文件句柄来进行读写。

match 的另一个分支处理从 File::open 得到 Err 值的情况。在这种情况下,我们选择调用 panic! 宏。如果当前目录没有一个叫做 hello.txt 的文件,当运行这段代码时会看到如下来自 panic! 宏的输出:

$ 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' 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!。我们真正希望的是对不同的错误原因采取不同的行为:如果 File::open 因为文件不存在而失败,我们希望创建这个文件并返回新文件的句柄。如果 File::open 因为任何其他原因失败 – 例如没有打开文件的权限 – 我们仍然希望像示例 9-4 那样 panic!。为此,我们在示例 9-5 中添加了一个内部 match 表达式,如下所示:

文件名:src/main.rs

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:使用不同的方式处理不同类型的错误

File::open 返回的 Err 变体中的值类型 io::Error,它是一个标准库中提供的结构体。这个结构体有一个返回 io::ErrorKind 值的 kind 方法可供调用。io::ErrorKind 是一个标准库提供的枚举,它的变体对应 io 操作可能导致的不同错误类型。我们感兴趣的变体是 ErrorKind::NotFound,它代表尝试打开的文件并不存在。这样,match 就匹配完 greeting_file_result 了,不过对于 error.kind() 还有一个内层 match

我们希望在内层 match 中检查的条件是 error.kind() 的返回值是否为 ErrorKindNotFound 变体。如果是,则通过 File::create 尝试创建该文件。然而因为 File::create 也可能会失败,还需要在内层 match 表达式中增加了第二个分支。当文件不能被创建,会打印出一个不同的错误信息。外层 match 的最后一个分支保持不变,这样对任何除了文件不存在的错误会使程序 panic。

使用 match 处理 Result<T, E> 的替代方案

这里有好多 matchmatch 确实很强大,不过也非常的原始。第十三章我们会介绍闭包(closure),它会和定义在 Result<T, E> 中的很多方法一起使用。在处理代码中的 Result<T, E> 值时,使用这些方法往往比直接写 match 更简洁。

例如,这是另一个编写与示例 9-5 逻辑相同但是使用闭包和 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 表达式且更容易阅读。在阅读完第十三章后再回到这个例子,并查看标准库文档 unwrap_or_else 方法都做了什么操作。在处理错误时,还有很多这类方法可以消除大量嵌套的 match 表达式。

失败时 panic 的快捷方式

match 能够胜任它的工作,不过它可能有点冗长并且不总是能很好的表明其意图。Result<T, E> 类型定义了很多辅助方法来处理各种更为特定的任务。unwrap 方法是一个快捷方式,其内部实现与我们在 Listing 9-4 中编写的 match 表达式相同。如果 Result 值是变体 Okunwrap 会返回 Ok 中的值。如果 Result 是变体 Errunwrap 会为我们调用 panic!。这里是一个实践 unwrap 的例子:

文件名:src/main.rs

use std::fs::File;

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

如果调用这段代码时不存在 hello.txt 文件,我们将会看到一个 unwrap 调用 panic! 时提供的错误信息:

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

同样,expect 方法也允许我们自定义 panic! 的错误信息。使用 expect 而不是 unwrap 并提供一个好的错误信息可以表明你的意图并更易于追踪 panic 的根源。expect 的语法看起来像这样:

文件名:src/main.rs

use std::fs::File;

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

expectunwrap 的使用方式一样:返回文件句柄或调用 panic! 宏。expect 在调用 panic! 时使用的错误信息将是我们传递给 expect 的参数,而不像 unwrap 那样使用默认的 panic! 信息。它看起来像这样:

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" }

在生产级别的代码中,大部分 Rustaceans 选择 expect 而不是 unwrap 并提供更多关于为何操作期望是一直成功的上下文。如此如果该假设真的被证明是错的,你也有更多的信息来用于调试。

传播错误

当函数的实现中调用了可能会失败的操作时,除了在这个函数中处理错误外,还可以选择让调用者知道这个错误并决定该如何处理。这被称为传播propagating)错误,这样能更好的控制代码调用,因为比起你代码所拥有的上下文,调用者可能拥有更多信息或逻辑来决定应该如何处理错误。

例如,示例 9-6 展示了一个从文件中读取用户名的函数。如果文件不存在或不能读取,这个函数会将这些错误返回给调用它的代码:

文件名:src/main.rs

#![allow(unused)]
fn main() {
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>。这意味着函数返回一个 Result<T, E> 类型的值,其中泛型参数 T 的具体类型是 String,而 E 的具体类型是 io::Error

如果这个函数没有出任何错误成功返回,函数的调用者会收到一个包含 StringOk 值 —— 函数从文件中读取到的用户名。如果函数遇到任何错误,函数的调用者会收到一个 Err 值,它储存了一个包含更多这个问题相关信息的 io::Error 实例。这里选择 io::Error 作为函数的返回值是因为它正好是函数体中那两个可能会失败的操作的错误返回值:File::open 函数和 read_to_string 方法。

函数体以调用 File::open 函数开始。接着使用 match 处理返回值 Result,类似示例 9-4,如果 File::open 成功了,模式变量 file 中的文件句柄就变成了可变变量 username_file 中的值,接着函数继续执行。在 Err 的情况下,我们没有调用 panic!,而是使用 return 关键字提前结束整个函数,并将来自 File::open 的错误值(现在在模式变量 e 中)作为函数的错误值传回给调用者。

所以,如果在 username_file 中有一个文件句柄,该函数随后会在变量 username 中创建一个新的 String 并调用文件句柄 username_file 上的 read_to_string 方法,以将文件的内容读入 usernameread_to_string 方法也返回一个 Result,因为它可能会失败,哪怕是 File::open 已经成功了。因此,我们需要另一个 match 来处理这个 Result:如果 read_to_string 执行成功,那么这个函数也就成功了,我们将从文件中读取的用户名返回,此时用户名位于被封装进 Okusername 中。如果 read_to_string 执行失败,则像之前处理 File::open 的返回值的 match 那样返回错误值。然而,我们无需显式写出 return,因为这是函数的最后一个表达式。

调用这个函数的代码最终会得到一个包含用户名的 Ok 值,或者一个包含 io::ErrorErr 值。我们无从得知调用者会如何处理这些值。例如,如果他们得到了一个 Err 值,他们可能会选择 panic! 并使程序崩溃、使用一个默认的用户名或者从文件之外的地方寻找用户名。我们没有足够的信息知晓调用者具体会如何尝试,所以将所有的成功或失败信息向上传播,让他们选择合适的处理方法。

这种传播错误的模式在 Rust 中太常见了,因此 Rust 提供了问号运算符 ? 来简化这一过程。

? 运算符快捷方式

示例 9-7 展示了一个 read_username_from_file 的实现,它实现了与示例 9-6 中的代码相同的功能,不过这个实现使用了 ? 运算符:

文件名:src/main.rs

#![allow(unused)]
fn main() {
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 值后面的 ?,其定义的工作方式与我们在示例 9-6 中编写的处理 Result 值的 match 表达式几乎完全相同。如果 Result 的值是 Ok,这个表达式就会返回 Ok 中的值,程序继续执行。如果值是 ErrErr 就会像使用了 return 关键字一样,作为整个函数的返回值提前返回,这样错误值就被传播给了调用者。

示例 9-6 中的 match 表达式和 ? 运算符还有一点不同:被 ? 作用的错误值会经过 from 函数。这个函数定义在标准库的 From trait 中,用于把一种类型的值转换成另一种类型。当 ? 运算符调用 from 函数时,接收到的错误类型会被转换成当前函数返回类型里定义的错误类型。当一个函数用单一错误类型来表示它所有可能的失败方式时,这会非常有用,即使函数内部的不同部分可能会因为很多不同的原因而失败。

例如,我们可以将示例 9-7 中的 read_username_from_file 函数修改为返回一个自定义的 OurError 错误类型。如果我们也定义了 impl From<io::Error> for OurError 来从 io::Error 构造一个 OurError 实例,那么 read_username_from_file 函数体中的 ? 运算符调用会调用 from 并转换错误而无需在函数中增加任何额外的代码。

在示例 9-7 的上下文中,File::open 调用结尾的 ? 会将 Ok 中的值返回给变量 username_file。如果发生了错误,? 运算符会使整个函数提前返回并将任何 Err 值返回给调用代码。同理也适用于 read_to_string 调用结尾的 ?

? 运算符消除了大量样板代码并使得函数的实现更简单。我们甚至可以在 ? 之后直接使用链式方法调用来进一步简化代码,如示例 9-8 所示:

文件名:src/main.rs

#![allow(unused)]
fn main() {
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:问号运算符之后的链式方法调用

username 中创建新的 String 被放到了函数开头;这一部分没有变化。我们对 File::open("hello.txt")? 的结果直接链式调用了 read_to_string,而不再创建变量 username_file。仍然需要 read_to_string 调用结尾的 ?,而且当 File::openread_to_string 都成功没有失败时返回包含用户名 usernameOk 值,而不是返回错误。其功能再一次与示例 9-6 和示例 9-7 保持一致,不过这是一个与众不同且更符合工程学(ergonomic)的写法。

示例 9-9 展示了一个使用 fs::read_to_string 的更为简短的写法:

文件名:src/main.rs

#![allow(unused)]
fn main() {
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、读取文件的内容,并将内容放入 String,接着返回它。当然,这样做就没有展示所有这些错误处理的机会了,所以我们最初就选择了艰苦的道路。

哪里可以使用 ? 运算符

? 运算符只能被用于返回值与 ? 作用的值相兼容的函数。因为 ? 运算符被定义为从函数中提早返回一个值,这与示例 9-6 中的 match 表达式有着完全相同的工作方式。示例 9-6 中 match 作用于一个 Result 值,提早返回的分支返回了一个 Err(e) 值。函数的返回值必须是 Result 才能与这个 return 相兼容。

在示例 9-10 中,让我们看看在返回值不兼容的 main 函数中使用 ? 运算符会得到什么错误:

文件名:src/main.rs

use std::fs::File;

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

示例 9-10: 尝试在返回 ()main 函数中使用 ? 的代码不能编译

这段代码打开一个文件,这可能会失败。? 运算符作用于 File::open 返回的 Result 值,不过 main 函数的返回类型是 () 而不是 Result。当编译这些代码,会得到如下错误信息:

$ 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

这个错误指出只能在返回 ResultOption 或者其它实现了 FromResidual 的类型的函数中使用 ? 运算符。

为了修复这个错误,有两个选择。一个是,如果没有限制的话将函数的返回值改为与你在 ? 运算符所作用的值兼容的类型。另一个是使用 match 或者 Result<T, E> 类型的方法,以适当的方式处理 Result<T, E>

错误信息也提到 ? 也可用于 Option<T> 值。如同对 Result 使用 ? 一样,只能在返回 Option 的函数中对 Option 使用 ?。在 Option<T> 上调用 ? 运算符的行为与 Result<T, E> 类似:如果值是 None,此时 None 会从函数中提前返回。如果值是 SomeSome 中的值作为表达式的返回值同时函数继续。示例 9-11 中有一个从给定文本中返回第一行最后一个字符的函数的例子:

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

fn main() {
    assert_eq!(
        last_char_of_first_line("Hello, world\nHow are you today?"),
        Some('d')
    );

    assert_eq!(last_char_of_first_line(""), None);
    assert_eq!(last_char_of_first_line("\nhi"), None);
}

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

这个函数返回 Option<char> 因为它可能会在这个位置找到一个字符,也可能没有字符。这段代码获取 text 字符串 slice 作为参数并调用其 lines 方法,这会返回一个字符串中每一行的迭代器。因为函数希望检查第一行,所以调用了迭代器 next 来获取迭代器中第一个值。如果 text 是空字符串,next 调用会返回 None,此时我们可以使用 ? 来停止并从 last_char_of_first_line 返回 None。如果 text 不是空字符串,next 会返回一个包含 text 中第一行的字符串 slice 的 Some 值。

? 会提取这个字符串 slice,然后可以在字符串 slice 上调用 chars 来获取字符的迭代器。我们感兴趣的是第一行的最后一个字符,所以可以调用 last 来返回迭代器的最后一项。这是一个 Option,因为有可能第一行是一个空字符串;例如 text 以一个空行开头而后面的行有文本,像是 "\nhi"。不过,如果第一行有最后一个字符,它会返回在一个 Some 变体中。? 运算符作用于其中给了我们一个简洁的表达这种逻辑的方式。如果我们不能在 Option 上使用 ? 运算符,则不得不使用更多的方法调用或者 match 表达式来实现这些逻辑。

注意你可以在返回 Result 的函数中对 Result 使用 ? 运算符,可以在返回 Option 的函数中对 Option 使用 ? 运算符,但是不可以混合搭配。? 运算符不会自动将 Result 转化为 Option,反之亦然;在这些情况下,可以使用类似 Resultok 方法或者 Optionok_or 方法来显式转换。

目前为止,我们所使用的所有 main 函数都返回 ()main 函数是特殊的因为它是可执行程序的入口点和退出点,为了使程序能正常工作,其可以返回的类型是有限制的。

幸运的是 main 函数也可以返回 Result<(), E>,示例 9-12 中的代码来自示例 9-10 不过修改了 main 的返回值为 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 对象trait object),第十八章的“使用 trait object 来抽象出共享行为”部分会介绍它。现在可以把 Box<dyn Error> 理解为“任何类型的错误”。在返回错误类型 Box<dyn Error>main 函数中,对 Result 使用 ? 是被允许的,因为它允许任何 Err 值提前返回。即便 main 函数体现在只会返回 std::io::Error 错误类型,通过指定 Box<dyn Error>,这个签名仍然是正确的;即使以后在 main 函数体中加入返回其他错误类型的代码,这个函数签名依然保持正确。

main 函数返回 Result<(), E>,如果 main 返回 Ok(()) 可执行程序会以 0 值退出,而如果 main 返回 Err 值则会以非零值退出;成功退出的程序会返回整数 0,运行错误的程序会返回非 0 的整数。Rust 也会从二进制程序中返回与这个惯例相兼容的整数。

main 函数也可以返回任何实现了 std::process::Termination trait 的类型,它包含了一个返回 ExitCodereport 函数。请查阅标准库文档了解更多为自定义类型实现 Termination trait 的细节。

现在我们讨论过了调用 panic! 或返回 Result 的细节,让我们回到在不同场景下如何决定使用哪种方式的问题。

要不要 panic!

要不要 panic!

那么,该如何决定何时应该 panic! 以及何时应该返回 Result 呢?如果代码 panic,就没有恢复的可能。你可以选择对任何错误场景都调用 panic!,不管是否有可能恢复,不过这样就是你代替调用者决定了这是不可恢复的。选择返回 Result 值的话,就将选择权交给了调用者,而不是代替他们做出决定。调用者可能会选择以符合他们场景的方式尝试恢复,或者也可能干脆就认为 Err 是不可恢复的,所以他们也可能会调用 panic! 并将可恢复的错误变成了不可恢复的错误。因此返回 Result 是定义可能会失败的函数的一个好的默认选择。

在一些类似示例、原型代码(prototype code)和测试中,panic 比返回 Result 更为合适,下文中会讨论合适的原因,紧接着讨论另外一种特殊情况,即有些场景编译器无法认识这个分支代码是不可能走到的,但是人类可以判断出来的,这种场景也可以用 panic!。另外章节最后会总结一些在库代码中如何决定是否要 panic 的通用指导原则。

示例、代码原型和测试

当你编写一个示例来展示一些概念时,在拥有健壮的错误处理代码的同时也会使得例子不那么明确。例如,调用一个类似 unwrap 这样可能 panic! 的方法可以被理解为一个你实际希望程序处理错误方式的占位符,它根据其余代码运行方式可能会各不相同。

类似地,在我们准备好决定如何处理错误之前,unwrapexpect方法在原型设计时非常方便。当我们准备好让程序更加健壮时,它们会在代码中留下清晰的标记。

如果方法调用在测试中失败了,我们希望这个测试都失败,即便这个方法并不是需要测试的功能。因为 panic! 会将测试标记为失败,此时调用 unwrapexpect 是恰当的。

当你比编译器知道更多时

当你有某些其他逻辑来确保 Result 一定会是 Ok 值,而编译器又无法理解这套逻辑时,调用 expect 也是合适的。你依然拿到的是一个必须处理的 Result 值:一般来说,你调用的这个操作仍然有失败的可能,尽管在你的这个具体场景里,逻辑上它不可能失败。如果你通过人工检查代码,能够确保绝不会出现 Err 变体,那么调用 expect 完全可以接受,同时最好在 expect 的提示文本里记录下你为什么认为这里永远不会出现 Err。下面是一个例子:

fn main() {
    use std::net::IpAddr;

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

我们通过解析一个硬编码的字符串来创建 IpAddr 实例。我们看得出来,127.0.0.1 是一个有效的 IP 地址,所以这里使用 expect 是可以接受的。不过,即便这个字符串是硬编码且有效的,也不会改变 parse 方法的返回类型:它仍然返回一个 Result 值,编译器也仍然要求我们像对待可能出现 Err 变体一样去处理这个 Result,因为编译器还没有聪明到能看出这个字符串总是一个有效 IP 地址。如果 IP 地址字符串来自用户输入,而不是硬编码在程序里,那么它就确实存在失败的可能性,这时我们肯定会希望用更健壮的方式处理 Result。在 expect 中写明这个 IP 地址是硬编码的这一假设,也会提醒我们:如果将来需要从其他来源获取 IP 地址,就该把 expect 改成更合适的错误处理代码。

错误处理指导原则

在当有可能会导致有害状态(bad state)的情况下建议使用 panic! —— 在这里,有害状态bad state)是指当一些假设、保证、协议或不可变性被打破的状态,例如无效的值、自相矛盾的值或者被传递了不存在的值 —— 外加如下几种情况:

  • 有害状态是非预期的行为,与偶尔会发生的行为相对,比如用户输入了错误格式的数据。
  • 在此之后代码的运行依赖于不处于这种有害状态,而不是在每一步都检查是否有问题。
  • 没有可行的手段来将有害状态信息编码进所使用的类型中的情况。我们会在第十八章“将状态和行为编码为类型”部分通过一个例子来说明我们的意思。

如果别人调用你的代码并传递了一个没有意义的值,尽最大可能返回一个错误,如此库的用户就可以决定在这种情况下该如何处理。然而在继续执行代码是不安全或有害的情况下,最好的选择可能是调用 panic! 并警告库的用户他们的代码中有 bug,这样他们就会在开发时进行修复。类似的,如果你正在调用不受你控制的外部代码,并且它返回了一个你无法修复的无效状态,那么 panic! 往往是合适的。

然而当错误预期会出现时,返回 Result 仍要比调用 panic! 更为合适。这样的例子包括解析器接收到格式错误的数据,或者 HTTP 请求返回了一个表明触发了限流的状态。在这些例子中,应该通过返回 Result 来表明失败预期是可能的,而调用者就必须决定该如何处理这个问题。

当你的代码在进行一个使用无效值进行调用时可能将用户置于风险中的操作时,代码应该首先验证值是有效的,并在其无效时 panic!。这主要是出于安全的原因:尝试操作无效数据会暴露代码漏洞,这就是标准库在尝试越界访问数组时会 panic! 的主要原因:尝试访问不属于当前数据结构的内存是一个常见的安全隐患。函数通常都遵循契约contracts):它们的行为只有在输入满足特定条件时才能得到保证。当违反契约时 panic 是有道理的,因为这通常代表调用方的 bug,而且这也不是那种你希望所调用的代码必须处理的错误。事实上所调用的代码也没有合理的方式来恢复,而是需要调用方的开发者修复其代码。函数的契约,尤其是当违反它会造成 panic 的契约,应该在函数的 API 文档中进行说明。

虽然在所有函数中都拥有许多错误检查是冗长而烦人的。幸运的是,可以利用 Rust 的类型系统(以及编译器的类型检查)为你进行很多检查。如果函数有一个特定类型的参数,可以在知晓编译器已经确保其拥有一个有效值的前提下进行你的代码逻辑。例如,如果你使用了一个并不是 Option 的类型,则程序期望它是有值的并且不是空值。你的代码无需处理 SomeNone 这两种情况,它只会有一种情况就是绝对会有一个值。尝试向函数传递空值的代码甚至根本不能编译,所以你的函数在运行时没有必要判空。另外一个例子是使用像 u32 这样的无符号整型,也会确保它永远不为负。

为验证创建自定义类型

让我们使用 Rust 类型系统的思想来进一步确保值的有效性,并尝试创建一个自定义类型以进行验证。回忆一下第二章的猜猜看游戏,我们的代码要求用户猜测一个 1 到 100 之间的数字,在将其与秘密数字做比较之前我们从未验证用户的猜测是位于这两个数字之间的,我们只验证它是否为正。在这种情况下,其影响并不是很严重:“Too high” 或 “Too low” 的输出仍然是正确的。但是这是一个很好的引导用户得出有效猜测的辅助,例如当用户猜测一个超出范围的数字或者输入字母时采取不同的行为。

一种实现方式是将猜测解析成 i32 而不仅仅是 u32,来默许输入负数,接着检查数字是否在范围内,像这样:

文件名:src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        // --snip--

        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        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) {
            // --snip--
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

if 表达式检查了值是否超出范围,告诉用户出了什么问题,并调用 continue 开始下一次循环,请求另一个猜测。if 表达式之后,就可以在知道 guess 在 1 到 100 之间的情况下与秘密数字作比较了。

然而,这并不是一个理想的解决方案:如果让程序仅仅处理 1 到 100 之间的值是一个绝对需要满足的要求,而且程序中的很多函数都有这样的要求,在每个函数中都有这样的检查将是非常冗余的(并可能潜在地影响性能)。

相反我们可以在一个专用的模块中创建一个新类型来将验证放入创建其实例的函数中,而不是到处重复这些检查。这样就可以安全地在函数签名中使用新类型并相信它们接收到的值。示例 9-13 中展示了一个定义 Guess 类型的方法,只有在 new 函数接收到 1 到 100 之间的值时才会创建 Guess 的实例:

文件名:src/guessing_game.rs

#![allow(unused)]
fn main() {
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:一个 Guess 类型,它只在值位于 1 和 100 之间时才继续

注意,这段位于 src/guessing_game.rs 的代码依赖于在 src/lib.rs 中添加 mod guessing_game; 模块声明,不过这里没有把那部分展示出来。在这个新模块文件里,我们定义了一个名为 Guess 的结构体,它有一个名为 value 的字段,用来保存 i32 类型的数字。

接着,我们在 Guess 上实现了一个名为 new 的关联函数,用来创建 Guess 的实例。new 接收一个名为 value、类型为 i32 的参数,并返回一个 Guessnew 函数体中的代码会检查 value 是否位于 1 到 100 之间。如果 value 没有通过这个测试,我们就调用 panic!,提醒调用这段代码的程序员:他们的代码里有一个需要修复的 bug,因为创建一个 value 超出这个范围的 Guess,会违反 Guess::new 所依赖的契约。Guess::new 在什么条件下可能 panic,应该写进它面向外部的 API 文档中;第十四章会讲到如何在 API 文档里表明 panic! 的可能性。如果 value 通过了测试,我们就创建一个新的 Guess,把它的 value 字段设置为参数 value 的值,然后返回这个 Guess

接着,我们实现了一个名为 value 的方法:它借用 self,没有其他参数,并返回一个 i32。这种方法有时被称为 getter,因为它的目的就是从字段中取出一些数据并返回它。这个公有方法是必要的,因为 Guess 结构体的 value 字段是私有的。让 value 保持私有非常重要,因为这样使用 Guess 结构体的代码就不能直接设置 valueguessing_game 模块之外的代码必须通过 Guess::new 来创建 Guess 实例,从而保证 Guess 不可能拥有一个未经 Guess::new 条件检查的 value

于是,一个接收或返回 1 到 100 之间数字的函数就可以声明为接收(或返回) Guess 的实例,而不是 i32,同时其函数体中也无需进行任何额外的检查。

总结

Rust 的错误处理功能被设计为帮助你编写更加健壮的代码。panic! 宏代表一个程序无法处理的状态,并停止执行而不是使用无效或不正确的值继续处理。Rust 类型系统的 Result 枚举代表操作可能会在一种可以恢复的情况下失败。可以使用 Result 来告诉代码调用者他需要处理潜在的成功或失败。在适当的场景使用 panic!Result 将会使你的代码在面对不可避免的错误时显得更加可靠。

现在我们已经见识过了标准库中 OptionResult 泛型枚举的能力了,在下一章让我们聊聊泛型是如何工作的,以及如何在你的代码中使用它们。

评论 (0)