09-错误处理

上一篇:08-常用集合(容器)


在软件中,错误是一个不争的事实,因此 Rust 提供了许多功能来处理出错的情况。在许多情况下,Rust 要求您在编译代码之前承认出错的可能性并采取一些措施。这一要求可确保您在将代码部署到生产环境之前发现错误并进行适当处理,从而使您的程序更加健壮!

Rust 将错误分为两大类:++可恢复错误和不可恢复错误++。对于可恢复的错误,例如找不到文件的错误,我们很可能只想向用户报告问题并重试操作。不可恢复的错误总是错误的症状,比如试图访问超出数组末尾的位置,因此我们希望立即停止程序。

大多数语言并不区分这两种错误,而是使用异常等机制以同样的方式处理这两种错误。++Rust 没有异常。相反,它有用于可恢复错误的类型 Result<T, E> 和当程序遇到不可恢复错误时停止执行的宏 panic!++。本章首先介绍调用 panic! ,然后讨论返回 Result<T, E> 的值。此外,我们还将探讨在决定是尝试从错误中恢复还是停止执行时的注意事项。

9.1 无法恢复的错误 panic!

有时,代码中会发生一些糟糕的事情,而你却无能为力。在这种情况下,Rust 提供了 panic! 宏。在实践中,有两种方法可以导致恐慌:++一种是采取会导致代码恐慌的操作(例如在数组结束后访问数组),另一种是显式调用 panic! 宏++。在这两种情况下,我们都会在程序中引起 panic。默认情况下,这些恐慌会打印一条失败消息,然后解卷、清理堆栈并退出。还可以通过环境变量,让 Rust 在恐慌发生时显示调用堆栈,以便于追踪恐慌的源头。

在出现恐慌时释放堆栈或终止运行

默认情况下,当恐慌发生时,程序会开始解卷,这意味着 Rust 会返回堆栈并清理遇到的每个函数的数据。然而,这种回退和清理工作非常繁重。因此,++Rust 允许你选择立即终止,即在不进行清理的情况下结束程序++。

++程序使用的内存将由操作系统清理++。如果在您的项目中需要使生成的二进制文件尽可能小,您可以在 Cargo.toml 文件中的 [profile] 部分添加 panic = 'abort' ,以便在出现 panic 时从释放模式切换为终止模式。例如,如果您想在释放模式下当出现恐慌时终止,请添加以下内容:

[profile.release]
panic = 'abort'

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

rust 复制代码
fn main() {
    panic!("crash and burn");
}

运行程序时,您将看到如下内容:

bash 复制代码
 cargo.exe run   
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `target\debug\error_demo.exe`
thread 'main' panicked at src\main.rs:2:5:
crash and burn
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
error: process didn't exit successfully: `target\debug\error_demo.exe` (exit code: 101)

对 panic! 的调用导致了最后两行中的错误信息。第一行显示了恐慌信息和发生恐慌的源代码位置:src/main.rs:2:5 表示这是 src/main.rs 文件的第二行第五个字符。

在这种情况下,所示行是我们代码的一部分,如果我们转到该行,就会看到 panic! 宏调用。在其他情况下, panic! 调用可能在我们的代码中,而错误信息报告的文件名和行号将是调用 panic! 宏的其他代码,而不是最终导致 panic! 调用的我们的代码行。我们可以使用 panic! 调用所来自函数的回溯,找出导致问题的代码部分。接下来我们将详细讨论回溯。

9.1.1 使用 panic! 回溯

让我们来看另一个示例,看看由于代码中的错误而不是直接调用宏时,来自库的 panic! 调用会是什么样子。清单 9-1 中的一些代码试图访问一个向量中超出有效索引范围的索引。

rust 复制代码
fn main() {
    let v = vec![1, 2, 3];

    v[99];
}

(清单 9-1:试图访问超出向量末尾的元素,这将导致调用 panic!)

在这里,我们正试图访问向量的第 100 个元素(因为索引从 0 开始,所以它位于索引 99 处),但向量只有 3 个元素。在这种情况下,Rust 会惊慌失措。使用 [] 本应返回一个元素,但如果你传递了一个无效的索引,Rust 在这里就无法返回正确的元素。

在 C 语言中,试图读取超过数据结构末尾的内容是未定义的行为。你可能会读取内存中与数据结构中该元素相对应位置的任何内容,即使内存并不属于该结构。这就是所谓的++缓冲区溢出++,如果攻击者能够以这样的方式操作索引,读取数据结构后存储的本不允许读取的数据,就会导致安全漏洞。

为了防止程序出现此类漏洞,如果试图读取不存在的索引中的元素,Rust 会停止执行并拒绝继续。让我们试试看:

bash 复制代码
cargo.exe run
   Compiling error_demo v0.1.0 (E:\rustProj\error_demo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42s
     Running `target\debug\error_demo.exe`
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
error: process didn't exit successfully: `target\debug\error_demo.exe` (exit code: 101)

该错误发生在 main.rs 的第 4 行,在该行中我们试图访问索引 99。下一条注释告诉我们,++可以设置 RUST_BACKTRACE 环境变量,以获取导致错误发生的具体原因的回溯信息++ 。回溯是到此为止所调用的所有函数的列表。Rust 中的回溯与其他语言中的回溯一样:++读取回溯的关键是从顶部开始读取,直到看到自己编写的文件++。这就是问题的源头。++该点上方的行是你的代码调用的代码;下方的行是调用你的代码的代码++。这些前后的行可能包括 Rust 核心代码、标准库代码或你正在使用的板块。让我们试着将 RUST_BACKTRACE 环境变量设置为 0 以外的任意值,以获取回溯。清单 9-2 显示了与你将看到的类似的输出。

bash 复制代码
$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
stack backtrace:
   0: rust_begin_unwind
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/std/src/panicking.rs:584:5
   1: core::panicking::panic_fmt
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/panicking.rs:142:14
   2: core::panicking::panic_bounds_check
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/panicking.rs:84:5
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/slice/index.rs:242:10
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/slice/index.rs:18:9
   5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/alloc/src/vec/mod.rs:2591:9
   6: panic::main
             at ./src/main.rs:4:5
   7: core::ops::function::FnOnce::call_once
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/ops/function.rs:248:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

(清单 9-2:环境变量 RUST_BACKTRACE 设置后显示的调用 panic! 生成的回溯轨迹)

这是一个很大的输出!根据操作系统和 Rust 版本的不同,您看到的具体输出可能会有所不同。++要获得包含这些信息的回溯,必须启用调试符号++ 。++在使用 cargo build 或 cargo run 时,默认情况下调试符号是启用的,但不带 --release 标志++。

在清单 9-2 的输出中,反向跟踪的第 6 行指向项目中导致问题的行,即 src/main.rs 的第 4 行。如果我们不想让程序慌乱,就应该从我们编写的文件的第一行所指向的位置开始调查。在清单 9-1 中,我们故意编写了会引起程序宕机的代码,解决宕机的方法是不请求超出向量索引范围的元素。当你的代码在将来出现恐慌时,你需要弄清楚代码对哪些值采取了哪些操作导致了恐慌,以及代码应该做什么。

在本章后面的 "要 panic! 还是不要 panic! "部分,我们将再次讨论 panic! 以及何时应该和何时不应该使用 panic! 来处理错误情况。接下来,我们将了解如何使用 Result 从错误中恢复。

9.2 可恢复错误 Result

大多数错误都不会严重到要求程序完全停止。有时,当函数发生故障时,其原因可以很容易地解释和应对。例如,如果你试图打开一个文件,但因为文件不存在而操作失败,你可能会想创建文件,而不是终止进程。

回顾第 2 章中的 "使用 Result 处理潜在故障", Result 枚举被定义为有两个变体,即 Ok 和 Err ,如下所示:

rust 复制代码
enum Result<T, E> {
    Ok(T),
    Err(E),
}

T 和 E 是泛型类型参数:我们将在第 10 章详细讨论泛型。现在我们需要知道的是, T 代表 Ok 变体中成功情况下返回值的类型,而 E 代表 Err 变体中失败情况下返回错误值的类型。由于 Result 具有这些通用类型参数,因此我们可以在许多不同的情况下使用 Result 类型及其定义的函数,在这些情况下,我们希望返回的成功值和错误值可能会有所不同。

让我们调用一个返回 Result 值的函数,因为该函数可能会失败。在清单 9-3 中,我们尝试打开一个文件。

rust 复制代码
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 的方法,即我们在第 6 章中讨论过的 match 表达式。

rust 复制代码
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 枚举及其变体已被前奏纳入作用域,因此我们不需要在 match 中的 Ok 和 Err 变体之前指定 Result::++。

当结果为 Ok 时,该代码将从 Ok 变量中返回 file 的内部值,然后我们将该文件句柄值赋值给变量 greeting_file 。在 match 之后,我们就可以使用该文件句柄进行读写操作了。

match 的另一端处理从 File::open 获取 Err 值的情况。在本例中,我们选择调用 panic! 宏。如果当前目录下没有名为 hello.txt 的文件,运行该代码后,我们将看到 panic! 宏的以下输出:

bash 复制代码
cargo.exe run
   Compiling error_demo v0.1.0 (E:\rustProj\error_demo)
warning: unused variable: `greeting_file`
 --> src\main.rs:6:9
  |
6 |     let greeting_file = match greeting_file_result {
  |         ^^^^^^^^^^^^^ help: if this is intentional, prefix it with an underscore: `_greeting_file`
  |
  = note: `#[warn(unused_variables)]` on by default

warning: `error_demo` (bin "error_demo") generated 1 warning (run `cargo fix --bin "error_demo"` to apply 1 suggestion)
    Finished dev [unoptimized + debuginfo] target(s) in 0.40s
     Running `target\debug\error_demo.exe`
thread 'main' panicked at src\main.rs:8:23:
Problem opening the file: Os { code: 2, kind: NotFound, message: "系统找不到指定的文件。" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
error: process didn't exit successfully: `target\debug\error_demo.exe` (exit code: 101)

9.2.1 不同错误的匹配

无论 File::open 为何失败,清单 9-4 中的代码都将 panic! 。但是,我们希望针对不同的失败原因采取不同的操作:如果 File::open 因文件不存在而失败,我们希望创建文件并返回新文件的句柄。如果 File::open 因任何其他原因失败,例如,因为我们没有打开文件的权限,我们仍然希望代码以与清单 9-4 相同的方式 panic! 。为此,我们添加了一个内部 match 表达式,如清单 9-5 所示。

rust 复制代码
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 create the file: {:?}", e),
            },
            other_error => {
                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() 上进行了内部匹配。

在内部匹配中,我们要检查的条件是 error.kind() 返回的值是否是 ErrorKind 枚举的 NotFound 变体。如果是,我们就尝试用 File::create 创建文件。但是,由于 File::create 也可能失败,因此我们需要在内部 match 表达式中加入第二臂。当文件无法创建时,就会打印出不同的错误信息。外层 match 的第二个臂保持不变,因此除了文件丢失错误外,程序在出现任何错误时都会慌乱。

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

match ! match 表达式非常有用,但也非常原始。在第 13 章中,您将学习闭包,它与 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 表达式,阅读起来更简洁。阅读完第 13 章后,再来看这个示例,并在标准库文档中查找 unwrap_or_else 方法。在处理错误时,还有更多这样的方法可以清理嵌套的庞大 match 表达式。

9.2.2 出错时惊慌失措的快捷方式: unwrap 和 expect

使用 match 可以很好地工作,但可能有点啰嗦,而且并不总能很好地传达意图。 Result<T, E> 类型定义了许多辅助方法,用于完成各种更具体的任务。 unwrap 方法是一种快捷方法,其实现方式与我们在清单 9-4 中编写的 match 表达式一样。++如果 Result 值是 Ok 变体, unwrap 将返回 Ok 内的值。如果 Result 是 Err 变体, unwrap 将为我们调用 panic! 宏++。下面是 unwrap 的运行示例:

rust 复制代码
use std::fs::File;

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

如果我们在没有 hello.txt 文件的情况下运行这段代码,就会看到 unwrap 方法调用 panic! 时产生的错误信息:

bash 复制代码
cargo.exe run 
   Compiling error_demo v0.1.0 (E:\rustProj\error_demo)
warning: unused variable: `greeting_file`
 --> src\main.rs:4:9
  |
4 |     let greeting_file = File::open("hello.txt").unwrap();
  |         ^^^^^^^^^^^^^ help: if this is intentional, prefix it with an underscore: `_greeting_file`
  |
  = note: `#[warn(unused_variables)]` on by default

warning: `error_demo` (bin "error_demo") generated 1 warning (run `cargo fix --bin "error_demo"` to apply 1 suggestion)
    Finished dev [unoptimized + debuginfo] target(s) in 0.46s
     Running `target\debug\error_demo.exe`
thread 'main' panicked at src\main.rs:4:49:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "系统找不到指定的文件。" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
error: process didn't exit successfully: `target\debug\error_demo.exe` (exit code: 101)

同样, expect 方法也允许我们选择 panic! 错误信息。++使用 expect 而不是 unwrap 并提供良好的错误信息,可以传达您的意图,并使追踪恐慌源变得更容易++。 expect 的语法如下:

rust 复制代码
use std::fs::File;

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

我们使用 expect 的方式与 unwrap 相同:返回文件句柄或调用 panic! 宏。 expect 在调用 panic! 时使用的错误信息将是我们传递给 expect 的参数,而不是 unwrap 使用的默认 panic! 信息。如下所示:

bash 复制代码
cargo.exe run
   Compiling error_demo v0.1.0 (E:\rustProj\error_demo)
warning: unused variable: `greeting_file`
 --> src\main.rs:4:9
  |
4 |     let greeting_file = File::open("hello.txt")
  |         ^^^^^^^^^^^^^ help: if this is intentional, prefix it with an underscore: `_greeting_file`
  |
  = note: `#[warn(unused_variables)]` on by default

warning: `error_demo` (bin "error_demo") generated 1 warning (run `cargo fix --bin "error_demo"` to apply 1 suggestion)
    Finished dev [unoptimized + debuginfo] target(s) in 0.35s
     Running `target\debug\error_demo.exe`
thread 'main' panicked at src\main.rs:5:10:
hello.txt should be included in this project: Os { code: 2, kind: NotFound, message: "系统找不到指定的文件。" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
error: process didn't exit successfully: `target\debug\error_demo.exe` (exit code: 101)

++在生产质量的代码中,大多数 Rustaceans 都会选择 expect 而不是 unwrap ,并给出更多关于为什么操作有望总是成功的上下文++。这样,如果你的假设被证明是错误的,你就有更多的信息可以用于调试。

9.2.3 传播错误

++当函数的实现调用可能失败的内容时,与其在函数本身内部处理错误,不如将错误返回给调用代码,让它决定如何处理。这就是所谓的传播错误,并将更多控制权交给调用代码,因为调用代码中可能有比代码上下文中可用的更多信息或逻辑来决定如何处理错误++。

例如,清单 9-6 显示了一个从文件中读取用户名的函数。如果文件不存在或无法读取,该函数将把这些错误返回给调用该函数的代码。

rust 复制代码
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 。

如果该函数成功执行且未出现任何问题,调用该函数的代码将收到一个 Ok 值,其中包含 String:该函数从文件中读取的用户名。如果该函数遇到任何问题,调用代码将收到一个 Err 值,其中包含一个 io::Error 实例,该实例包含更多关于问题所在的信息。我们选择 io::Error 作为该函数的返回类型,是因为在该函数的主体中,我们调用的两个操作( File::open 函数和 read_to_string 方法)都可能失败,而该函数恰好是返回错误值的类型。

函数的主体首先调用 File::open 函数。然后,我们用类似于清单 9-4 中 match 的 match 处理 Result 的值。如果 File::open 成功,模式变量 file 中的文件句柄就会变成可变变量 username_file 中的值,函数继续执行。在 Err 的情况下,我们不调用 panic! ,而是使用 return 关键字提前完全返回函数,并将 File::open 中的错误值(现在在模式变量 e 中)作为该函数的错误值传回调用代码。

因此,如果我们在 username_file 中有一个文件句柄,函数就会在变量 username 中创建一个新的 String ,然后调用 username_file 中文件句柄的 read_to_string 方法,将文件内容读入 username 。 read_to_string 方法也会返回一个 Result ,因为它可能会失败,即使 File::open 成功了。因此,我们需要另一个 match 来处理 Result :如果 read_to_string 成功了,那么我们的函数就成功了,我们将返回文件中的用户名,该用户名现在在 username 中,用 Ok 封装。如果 read_to_string 失败,我们返回错误值的方式与处理 File::open 返回值的 match 返回错误值的方式相同。不过,我们不需要明确说明 return ,因为这是函数中的最后一个表达式。

调用此代码的代码将处理获取包含用户名的 Ok 值或包含 io::Error 的 Err 值。调用代码将自行决定如何处理这些值。如果调用代码得到的是 Err 值,它可能会调用 panic! 并导致程序崩溃、使用默认用户名或从文件以外的其他地方查找用户名等。我们没有足够的信息来了解调用代码究竟要做什么,因此我们会向上传播所有成功或错误信息,以便它进行适当处理。

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

9.2.4 传播错误的捷径: ? 运算符

清单 9-7 显示了 read_username_from_file 的一个实现,其功能与清单 9-6 中的相同,但该实现使用了 ? 操作符。

rust 复制代码
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 ,则 Err 将从整个函数中返回,就像我们使用 return 关键字一样,因此错误值将传播给调用代码++。

清单 9-6 中 match 表达式的操作与 ? 操作符的操作有所不同:++调用 ? 操作符的错误值会经过 from 函数,该函数定义在标准库的 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 所示,++我们甚至可以在 ? 之后立即调用链式方法,从而进一步缩短代码++。

rust 复制代码
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 移到了函数的开头;这部分没有改变。我们没有创建变量 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 使其更加简短的方法。

rust 复制代码
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.2.5 ? 操作器的使用范围

? 操作符只能用于返回类型与 ? 值兼容的函数中。这是因为 ? 操作符被定义为执行函数值的提前返回,与我们在清单 9-6 中定义的 match 表达式的方式相同。在清单 9-6 中, match 使用的是 Result 值,而提前返回臂返回的是 Err(e) 值。函数的返回类型必须是 Result ,这样才能与 return 兼容。

在清单 9-10 中,如果我们在 main 函数中使用 ? 操作符,而该函数的返回类型与我们在 ? 上使用的值的类型不兼容,我们将看到一个错误:

rust 复制代码
use std::fs::File;

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

(清单 9-10:试图在返回 () 的 main 函数中使用 ? 将无法编译)

这段代码打开了一个文件,可能会失败。 ? 操作符跟随 File::open 返回的 Result 值,但这个 main 函数的返回类型是 () ,而不是 Result 。当我们编译这段代码时,会得到以下错误信息:

bash 复制代码
 cargo.exe run
   Compiling error_demo v0.1.0 (E:\rustProj\error_demo)
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: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `error_demo` (bin "error_demo") due to 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 是一个查找给定文本中第一行最后一个字符的函数示例:

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

(清单 9-11:在 Option<T> 值上使用 ? 运算符)

该函数返回 Option<char> ,因为有可能存在一个字符,但也有可能不存在。这段代码接收 text 字符串片参数并调用 lines 方法,该方法返回字符串中各行的迭代器。由于该函数要检查第一行,因此它调用了迭代器上的 next ,以获取迭代器中的第一个值。如果 text 是空字符串,对 next 的调用将返回 None ,在这种情况下,我们使用 ? 来停止并从 last_char_of_first_line 返回 None 。如果 text 不是空字符串, next 将返回一个 Some 值,其中包含 text 中第一行的字符串片段。

? 可提取字符串片段,我们可以在该字符串片段上调用 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(()) 。这段代码现在可以编译了:

rust 复制代码
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> 类型是特质对象,我们将在第 17 章 "使用允许不同类型值的特质对象 "一节中讨论。目前,您可以将 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 的值,可执行文件将以非零值退出。用 C 语言编写的可执行文件在退出时返回整数:成功退出的程序返回整数 0 ,出错的程序返回 0 以外的某个整数。Rust 也从可执行文件中返回整数,以便与这一约定兼容。

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

既然我们已经讨论了调用 panic! 或返回 Result 的细节,让我们回到如何决定在何种情况下使用哪种方法合适的话题上来。

9.3 panic! 还是不 panic!

那么,如何决定何时应该调用 panic! ,何时应该返回 Result 呢?当代码崩溃时,是没有办法恢复的。您可以在任何错误情况下调用 panic! ,无论是否有可能恢复,但这样您就代表调用代码做出了无法恢复的决定。当你选择返回一个 Result 值时,你就给了调用代码一些选择。调用代码可以选择以适合其情况的方式尝试恢复,也可以决定在这种情况下 Err 值是不可恢复的,因此它可以调用 panic! ,将可恢复的错误变成不可恢复的错误。因此,在定义可能失败的函数时,返回 Result 是一个不错的默认选择。

在示例、原型代码和测试等情况下,编写 "慌乱 "代码而不是返回 Result 更为合适。让我们探讨一下原因,然后讨论在哪些情况下编译器无法判断失败是不可能的,但作为人却可以。本章最后将介绍一些关于如何决定是否在库代码中 panic 的一般指导原则。

9.3.1 示例、原型代码和测试

++在编写示例以说明某些概念时,如果同时包含健壮的错误处理代码,会使示例变得不那么清晰++。在示例中,可以理解的是,调用像 unwrap 这样可能会引起恐慌的方法只是一个占位符,代表你希望应用程序处理错误的方式,而这种方式可能会根据代码的其他部分而有所不同。

同样,在您准备决定如何处理错误之前, unwrap 和 expect 方法在原型开发时非常方便。它们会在代码中留下清晰的标记,以便您在准备好让程序更健壮时使用。

++如果测试中的方法调用失败,即使该方法不是被测试的功能,也会导致整个测试失败++。由于 panic! 是标记测试失败的方式,因此调用 unwrap 或 expect 正是应该发生的事情。

9.3.2 您比编译器掌握更多信息的情况

调用 unwrap 或 expect 时,如果有其他逻辑确保 Result 会有一个 Ok 值,但编译器并不理解这些逻辑,那么调用也是合适的。您仍然需要处理 Result 值:无论您调用的是什么操作,在一般情况下都有可能失败,尽管在您的特定情况下,这在逻辑上是不可能的。如果您可以通过手动检查代码来确保永远不会出现 Err 变体,那么调用 unwrap 是完全可以接受的,最好在 expect 文本中记录下您认为永远不会出现 Err 变体的原因。下面是一个例子:

rust 复制代码
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 值,编译器仍然会让我们处理 Result ,就好像 Err 变体是可能的一样,因为编译器还不够聪明,无法发现这个字符串始终是一个有效的 IP 地址。如果 IP 地址字符串来自用户,而不是硬编码到程序中,因此确实存在失败的可能性,那么我们肯定希望以更稳健的方式来处理 Result 。如果将来我们需要从其他来源获取 IP 地址,提及该 IP 地址是硬编码的假设将促使我们将 expect 更改为更好的错误处理代码。

9.3.3 错误处理指南

当你的代码有可能陷入糟糕的状态时,最好让你的代码惊慌失措。在这种情况下,糟糕的状态是指某些假设、保证、契约或不变式被破坏,例如当无效值、矛盾值或缺失值被传递给代码时,再加上以下一种或多种情况:

①. 坏的状态是指意想不到的情况,而不是偶尔会发生的情况,比如用户输入的数据格式错误。

②. 在此之后,您的代码需要依赖于不处于这种不良状态,而不是在每一步都检查问题。

③. 在您使用的类型中没有很好的方法来编码这些信息。我们将在第 17 章 "将状态和行为编码为类型 "一节中举例说明。

++如果有人调用您的代码并传入了不合理的值,最好的办法是尽可能返回错误,这样库的用户就可以决定他们在这种情况下要做什么++。但是,在继续使用可能不安全或有害的情况下,最好的选择可能是调用 panic! ,并提醒使用你的库的人他们代码中的错误,以便他们在开发过程中修复。同样,如果您正在调用不受您控制的外部代码,而该代码返回了您无法修复的无效状态,那么 panic! 通常也是合适的选择。

++不过,当预计会失败时,返回 Result 比调用 panic! 更合适++。例如,解析器收到畸形数据,或 HTTP 请求返回的状态表明您已达到速率限制。在这些情况下,返回 Result 表示预期可能会失败,调用代码必须决定如何处理。

当您的代码执行操作时,如果调用的是无效值,可能会给用户带来风险。这主要是出于安全考虑:尝试对无效数据进行操作可能会使代码暴露于漏洞之中。这也是标准库会在你尝试越界内存访问时调用 panic! 的主要原因:尝试访问不属于当前数据结构的内存是一个常见的安全问题。函数通常都有契约:只有当输入满足特定要求时,函数的行为才会得到保证。++当违反契约时惊慌失措是有道理的,因为违反契约总是表明调用方出现了错误,而这并不是你希望调用代码必须显式处理的错误++。事实上,调用代码没有合理的恢复方法;调用程序员需要修复代码。++函数的合同,尤其是当违反合同会导致恐慌时,应在函数的 API 文档中加以说明++。

然而,在所有函数中进行大量错误检查会显得冗长而烦人。幸运的是,您可以使用 Rust 的类型系统(以及编译器进行的类型检查)来为您进行许多检查。如果你的函数有一个特定类型的参数,你就可以继续你的代码逻辑,因为编译器已经确保了你有一个有效的值。例如,如果您的参数是一个类型而不是 Option ,那么您的程序就会希望参数是有而不是无。这样,您的代码就不必处理 Some 和 None 变体的两种情况:只需处理肯定有值的一种情况。试图向函数传递空值的代码甚至无法编译,因此函数不必在运行时检查这种情况。另一个例子是使用无符号整数类型,如 u32 ,这样可以确保参数永远不会为负值。

9.3.4 创建用于验证的自定义类型

让我们把使用 Rust 的类型系统来确保我们拥有一个有效值的想法向前推进一步,看看如何创建一个用于验证的自定义类型。回想一下第 2 章中的猜谜游戏,我们的代码要求用户猜一个 1 到 100 之间的数字。在将用户的猜测与我们的秘密数字进行核对之前,我们从未验证过用户的猜测是否介于这两个数字之间;我们只验证了用户的猜测是肯定的。在这种情况下,后果并不严重:我们输出的 "太高 "或 "太低 "仍然是正确的。但是,如果能引导用户进行有效的猜测,并在用户猜测的数字超出范围时与用户输入字母等数字时采取不同的行为,这将是一个非常有用的增强功能。

一种方法是将猜测值解析为 i32 ,而不是 u32 ,以允许可能出现的负数,然后添加一个数字在范围内的检查,就像这样:

rust 复制代码
loop {
    // --snip--

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

if 表达式会检查我们的值是否超出范围,告诉用户问题所在,并调用 continue 开始下一次循环迭代,要求用户再次猜测。在 if 表达式之后,我们可以继续在 guess 和秘密数字之间进行比较,因为我们知道 guess 在 1 和 100 之间。

然而,这并不是一个理想的解决方案:++如果程序只对 1 到 100 之间的数值进行操作是绝对必要的,而且程序中有许多函数都有此要求,那么在每个函数中都进行这样的检查就会很繁琐(而且可能会影响性能)++。

++相反,我们可以创建一个新类型,并将验证放在函数中,以创建该类型的实例,而不是到处重复验证++。这样,函数就可以安全地在其签名中使用新类型,并放心地使用接收到的值。清单 9-13 展示了定义 Guess 类型的一种方法,只有当 new 函数接收到介于 1 和 100 之间的值时,才会创建 Guess 的实例。

rust 复制代码
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 之间时继续运行)

首先,我们定义一个名为 Guess 的结构体,该结构体有一个名为 value 的字段,用于保存 i32 。数字将存储在这个字段中。

然后,我们在 Guess 上实现一个名为 new 的关联函数,该函数创建 Guess 值的实例。 new 函数有一个名为 value 的参数,其类型为 i32 ,返回值为 Guess 。 new 函数主体中的代码会测试 value ,以确保它介于 1 和 100 之间。如果 value 没有通过测试,我们就会调用 panic! ,这将提醒编写调用代码的程序员,他们有一个需要修正的错误,因为使用超出此范围的 value 创建 Guess 会违反 Guess::new 所依赖的契约。 Guess::new 可能发生慌乱的情况应在其面向公众的 API 文档中讨论;我们将在第 14 章中介绍在您创建的 API 文档中指明 panic! 可能性的文档惯例。如果 value 确实通过了测试,我们将创建一个新的 Guess ,并将其 value 字段设置为 value 参数,然后返回 Guess 。

接下来,我们实现一个名为 value 的方法,该方法借用 self ,不带任何其他参数,并返回 i32 。这种方法有时被称为 getter,因为它的目的是从字段中获取一些数据并返回。由于 Guess 结构的 value 字段是私有的,因此必须使用这种公共方法。 ++value 字段必须是私有的,这样使用 Guess 结构的代码就不能直接设置 value++ :模块外的代码必须使用 Guess::new 函数来创建 Guess 的实例,从而确保 Guess 的 value 不可能未经 Guess::new 函数中的条件检查。

如果一个函数的参数或返回值仅为 1 到 100 之间的数字,则可以在其签名中声明它接收或返回的是 Guess ,而不是 i32 ,这样就不需要在其正文中进行任何额外的检查。


下一篇: 10-通用类型、特质和生命周期

相关推荐
monkey_meng1 分钟前
【Rust Iterator 之 fold,map,filter,for_each】
开发语言·后端·rust
运维&陈同学4 分钟前
【kafka01】消息队列与微服务之Kafka详解
运维·分布式·后端·微服务·云原生·容器·架构·kafka
Vae_Mars5 分钟前
QT-protected
开发语言·qt
JosieBook14 分钟前
【面试题】2025年百度校招Java后端面试题
java·开发语言·网络·百度
Moment18 分钟前
毕业半年,终于拥有了两个近 500 star 的开源项目了 🤭🤭🤭
前端·后端·开源
计算机毕设指导621 分钟前
基于SpringBoot共享汽车管理系统【附源码】
java·spring boot·后端·mysql·spring·汽车·intellij idea
wjs202430 分钟前
CentOS Docker 安装
开发语言
阿熊不会编程1 小时前
【计网】自定义协议与序列化(一) —— Socket封装于服务器端改写
linux·开发语言·网络·c++·设计模式
小牛itbull1 小时前
Mono Repository方案与ReactPress的PNPM实践
开发语言·前端·javascript·reactpress
jokerest1231 小时前
pwn——test_your_nc1——测试
开发语言·php