键盘快捷键

按 ← 或 → 在章节间切换

按 S 或 / 搜索本书

按 ? 显示此帮助

按 Esc 隐藏此帮助

使用 Result 处理可恢复错误

大多数错误并不严重到需要彻底停止程序。有时函数失败的原因很容易理解和处理。例如,尝试打开文件时,如果文件不存在导致操作失败,你可能会想创建该文件,而不是终止进程。

回顾第 2 章“使用 Result 处理潜在失败”一节,Result 枚举定义了两个变体:Ok 和 Err,如下所示:

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

T 和 E 是泛型类型参数:我们会在第 10 章更详细地讨论泛型。现在你只需知道,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>。File::open 的实现将泛型参数 T 填充为成功值的类型 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 展示了一种使用基本工具 match 表达式处理 Result 的方式;我们在第 6 章讨论过 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 分支中的 Ok 和 Err 前指定 Result::。

当结果为 Ok 时,这段代码会取出 Ok 变体中的 file 值,然后将该文件句柄赋给变量 greeting_file。执行完 match 后,我们就可以使用文件句柄进行读取或写入。

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' (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

和往常一样,这段输出准确地告诉了我们出了什么问题。

匹配不同的错误

无论 File::open 因何失败,示例 9-4 中的代码都会调用 panic!。然而,我们希望根据不同的失败原因采取不同操作。如果 File::open 因文件不存在而失败,我们想要创建文件并返回新文件的句柄。如果它因为其他原因失败,例如我们没有打开文件的权限,我们仍然希望像示例 9-4 一样调用 panic!。为此,我们添加一个内部 match 表达式,如示例 9-5 所示。

文件名: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,这是标准库提供的结构体。该结构体有一个 kind 方法,调用它可以得到 io::ErrorKind 值。标准库提供的 io::ErrorKind 枚举包含多个变体,分别表示 io 操作可能产生的不同错误类型。我们要使用的变体是 ErrorKind::NotFound,它表示要打开的文件尚不存在。因此,我们先匹配 greeting_file_result,同时在内部对 error.kind() 进行匹配。

内部 match 要检查的条件是:error.kind() 返回的值是否为 ErrorKind 枚举的 NotFound 变体。如果是,我们就尝试使用 File::create 创建文件。不过,File::create 也可能失败,因此内部 match 表达式还需要第二个分支。如果文件无法创建,就会打印不同的错误消息。外层 match 的第二个分支保持不变,因此除了文件缺失之外的任何错误都会导致程序 panic。

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

这里用了很多 match!match 表达式非常有用,但也属于较底层的基础工具。第 13 章会介绍闭包,Result<T, E> 定义的许多方法都可以与闭包配合使用。在代码中处理 Result<T, E> 值时,这些方法可能比使用 match 更简洁。

例如,下面使用闭包和 unwrap_or_else 方法,以另一种方式实现示例 9-5 中相同的逻辑:

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 方法。处理错误时,还有许多类似方法可以简化庞大而嵌套的 match 表达式。

错误时触发 panic 的快捷方法

使用 match 足以处理这些情况,但代码可能有些冗长,而且不总能清晰表达意图。Result<T, E> 类型定义了许多辅助方法,用来完成各种更具体的任务。unwrap 方法是一种快捷方式,其实现与我们在示例 9-4 中编写的 match 表达式类似。如果 Result 值是 Ok 变体,unwrap 会返回其中的值;如果 Result 是 Err 变体,unwrap 就会替我们调用 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 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! 的错误消息。使用 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");
}

我们以与 unwrap 相同的方式使用 expect:要么返回文件句柄,要么调用 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" }

在面向生产环境的代码中,大多数 Rustacean 会选择 expect 而不是 unwrap,并提供更多上下文,说明为什么预期该操作总能成功。这样,如果假设最终被证明有误,你就能获得更多调试信息。

传播错误

如果函数实现中调用了可能失败的操作,你可以不在函数内部处理错误,而是将错误返回给调用代码,由调用方决定如何处理。这称为传播错误。调用代码可能掌握更多信息或逻辑,能够决定如何处理错误;传播错误就能让调用方拥有更大的控制权。

例如,示例 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 替代。

如果函数顺利执行,调用方会收到一个包含 String 值的 Ok,也就是函数从文件读取的 username。如果函数遇到问题,调用方会收到一个包含 io::Error 实例的 Err,其中包含更多错误信息。我们选择 io::Error 作为函数的返回错误类型,是因为函数体中两个可能失败的操作(File::open 函数和 read_to_string 方法)返回的错误值恰好都是这种类型。

函数体首先调用 File::open 函数,然后使用类似示例 9-4 中的 match 处理 Result 值。如果 File::open 成功,模式变量 file 中的文件句柄会成为可变变量 username_file 的值,函数随后继续执行。如果结果是 Err,我们不调用 panic!,而是使用 return 关键字提前退出整个函数,并将 File::open 返回的错误值(现在位于模式变量 e 中)作为函数的错误值传回调用方。

如果 username_file 中有文件句柄,函数接下来会在变量 username 中创建一个新的 String,并对 username_file 中的文件句柄调用 read_to_string 方法,将文件内容读入 username。即使 File::open 成功,read_to_string 仍可能失败,因此它也会返回一个 Result。所以我们需要另一个 match 来处理它:如果 read_to_string 成功,函数也就成功了,我们会将现在存储在 username 中的用户名包装在 Ok 中返回。如果 read_to_string 失败,我们就像处理 File::open 返回值时那样返回错误值。不过,由于这是函数中的最后一个表达式,不需要显式写出 return。

调用方随后会处理包含用户名的 Ok 值,或包含 io::Error 的 Err 值。调用方可以自行决定如何处理这些值。例如,如果得到 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 中的值,程序继续执行。如果值是 Err,它就会像使用 return 关键字一样从整个函数返回 Err,从而将错误值传播给调用方。

? 运算符与示例 9-6 中的 match 表达式有一点不同:对错误值使用 ? 时,会调用标准库 From trait 中定义的 from 函数,将一种类型的值转换为另一种类型。? 运算符调用 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:在 ? 运算符后链式调用方法

我们将创建新的 String 并存入 username 的操作移到了函数开头,这部分没有变化。我们不再创建 username_file 变量,而是将 read_to_string 调用直接链接到 File::open("hello.txt")? 的结果上。read_to_string 调用末尾仍然有一个 ?;当 File::open 和 read_to_string 都成功时,我们仍然会返回一个包含 username 的 Ok 值,而不是返回错误。功能与示例 9-6 和示例 9-7 相同,只是写法更符合习惯,也更方便。

示例 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,最后返回它。当然,使用 fs::read_to_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

这个错误指出,我们只能在返回 Result、Option 或其他实现了 FromResidual 的类型的函数中使用 ? 运算符。

要修复错误,有两种选择。如果没有其他限制,可以将函数的返回类型改为与 ? 所作用的值兼容;另一种选择是使用 match 或 Result<T, E> 的某个方法,以适当方式处理 Result<T, E>。

错误消息还提到,? 也可以用于 Option<T> 值。与在 Result 上使用 ? 一样,只有返回 Option 的函数才能在 Option 上使用 ?。? 作用于 Option<T> 时的行为与作用于 Result<T, E> 时类似:如果值是 None,函数会在此处提前返回 None;如果值是 Some,表达式的结果就是 Some 中的值,函数继续执行。示例 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 字符串切片参数,并调用它的 lines 方法,得到遍历字符串各行的迭代器。由于函数要检查第一行,它会对迭代器调用 next,获取迭代器中的第一个值。如果 text 是空字符串,next 会返回 None;此时我们使用 ? 停止执行,并从 last_char_of_first_line 返回 None。如果 text 不是空字符串,next 就会返回一个包含 text 第一行字符串切片的 Some 值。

? 会取出字符串切片,我们可以对该切片调用 chars,得到遍历其字符的迭代器。我们想要第一行的最后一个字符,因此调用 last 返回迭代器中的最后一项。这里返回的是 Option,因为第一行可能是空字符串;例如,text 以空行开头,但其他行有字符,如 "\nhi"。不过,如果第一行存在最后一个字符,它就会包含在 Some 变体中。中间的 ? 运算符让我们可以简洁地表达这一逻辑,因此整个函数只需一行。如果不能在 Option 上使用 ?,我们就必须通过更多方法调用或 match 表达式实现这一逻辑。

请注意,你可以在返回 Result 的函数中对 Result 使用 ?,也可以在返回 Option 的函数中对 Option 使用 ?,但不能混用。? 运算符不会自动将 Result 转换为 Option,也不会反向转换;遇到这种情况,可以显式使用转换方法,例如对 Result 调用 ok,或对 Option 调用 ok_or。

到目前为止,我们使用的所有 main 函数都返回 ()。main 函数比较特殊,因为它是可执行程序的入口和退出点;为了让程序按预期运行,它的返回类型受到一些限制。

幸运的是,main 也可以返回 Result<(), E>。示例 9-12 使用了示例 9-10 中的代码,但将 main 的返回类型改为 Result<(), Box<dyn Error>>,并在末尾添加了返回值 Ok(())。现在这段代码可以编译了。

文件名:src/main.rs
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 章“使用 trait 对象抽象共享行为”一节中讨论它。目前可以将 Box<dyn Error> 理解为“任意类型的错误”。当 main 函数的错误类型是 Box<dyn Error> 时,可以对 Result 值使用 ?,因为它允许提前返回任意 Err 值。虽然这个 main 函数体目前只会返回 std::io::Error 类型的错误,但指定为 Box<dyn Error> 后,即使以后向 main 函数体中添加了会返回其他错误的代码,这个签名仍然有效。

当 main 函数返回 Result<(), E> 时,如果 main 返回 Ok(()),可执行程序会以 0 退出;如果 main 返回 Err 值,则会以非零值退出。C 语言编写的可执行程序退出时会返回整数:成功退出的程序返回整数 0,发生错误的程序则返回其他整数。Rust 的可执行程序也返回整数,以兼容这一约定。

main 函数可以返回任何实现了 std::process::Termination trait 的类型。该 trait 包含一个返回 ExitCode 的 report 函数。关于如何为自定义类型实现 Termination trait 的更多信息,请查阅标准库文档。

现在我们已经讨论了调用 panic! 或返回 Result 的细节,接下来回到如何根据不同情况决定使用哪一种方式。