使用 Result 处理可恢复错误

RustRustBeginner
立即练习

This tutorial is from open-source community. Access the source code

💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版

简介

欢迎来到「使用 Result 处理可恢复错误」。本实验是 《Rust 程序设计语言》 的一部分。你可以在 LabEx 中练习 Rust 技能。

在本实验中,我们将学习如何使用 Rust 中的 Result 枚举来处理可恢复错误,这使我们能够在不终止程序的情况下解释和响应错误。


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL rust(("Rust")) -.-> rust/BasicConceptsGroup(["Basic Concepts"]) rust(("Rust")) -.-> rust/DataTypesGroup(["Data Types"]) rust(("Rust")) -.-> rust/FunctionsandClosuresGroup(["Functions and Closures"]) rust(("Rust")) -.-> rust/DataStructuresandEnumsGroup(["Data Structures and Enums"]) rust(("Rust")) -.-> rust/ErrorHandlingandDebuggingGroup(["Error Handling and Debugging"]) rust(("Rust")) -.-> rust/AdvancedTopicsGroup(["Advanced Topics"]) rust/BasicConceptsGroup -.-> rust/variable_declarations("Variable Declarations") rust/BasicConceptsGroup -.-> rust/mutable_variables("Mutable Variables") rust/DataTypesGroup -.-> rust/string_type("String Type") rust/FunctionsandClosuresGroup -.-> rust/function_syntax("Function Syntax") rust/FunctionsandClosuresGroup -.-> rust/expressions_statements("Expressions and Statements") rust/DataStructuresandEnumsGroup -.-> rust/method_syntax("Method Syntax") rust/ErrorHandlingandDebuggingGroup -.-> rust/panic_usage("panic! Usage") rust/AdvancedTopicsGroup -.-> rust/operator_overloading("Traits for Operator Overloading") subgraph Lab Skills rust/variable_declarations -.-> lab-100410{{"使用 Result 处理可恢复错误"}} rust/mutable_variables -.-> lab-100410{{"使用 Result 处理可恢复错误"}} rust/string_type -.-> lab-100410{{"使用 Result 处理可恢复错误"}} rust/function_syntax -.-> lab-100410{{"使用 Result 处理可恢复错误"}} rust/expressions_statements -.-> lab-100410{{"使用 Result 处理可恢复错误"}} rust/method_syntax -.-> lab-100410{{"使用 Result 处理可恢复错误"}} rust/panic_usage -.-> lab-100410{{"使用 Result 处理可恢复错误"}} rust/operator_overloading -.-> lab-100410{{"使用 Result 处理可恢复错误"}} end

使用 Result 处理可恢复错误

大多数错误并不严重到需要程序完全停止。有时,当一个函数失败时,原因是你可以轻松解释和应对的。例如,如果你尝试打开一个文件,但由于文件不存在而导致操作失败,你可能想要创建该文件,而不是终止进程。

回顾「使用 Result 处理潜在失败」,Result 枚举被定义为有两个变体,OkErr,如下所示:

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

TE 是泛型类型参数:我们将在第 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>。泛型参数 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 展示了一种使用基本工具 match 表达式来处理 Result 的方法,我们在第 6 章中讨论过这个表达式。

文件名: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::

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

match 的另一个分支处理我们从 File::open 获得 Err 值的情况。在这个例子中,我们选择调用 panic! 宏。如果我们当前目录中没有名为 hello.txt 的文件,并且我们运行这段代码,我们将从 panic! 宏中看到以下输出:

thread'main' panicked at 'Problem opening the file: Os { code:
 2, kind: NotFound, message: "No such file or directory" }',
src/main.rs:8:23

像往常一样,这个输出准确地告诉我们哪里出了问题。

匹配不同的错误

无论 File::open 为何失败,清单 9-4 中的代码都会调用 panic!。然而,我们希望针对不同的失败原因采取不同的操作。如果 File::open 因为文件不存在而失败,我们想要创建该文件并返回新文件的句柄。如果 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
                    ),
                }
            }
            other_error => {
                panic!(
                    "Problem opening the file: {:?}",
                    other_error
                );
            }
        },
    };
}

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

File::openErr 变体中返回的值的类型是 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 的第二个分支保持不变,所以除了文件缺失错误之外的任何错误都会使程序调用 panic!

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

这么多 match 表达式啊!match 表达式非常有用,但也是一种非常原始的方式。在第 13 章中,你将学习闭包,它们会与许多在 Result<T, E> 上定义的方法一起使用。当你在代码中处理 Result<T, E> 值时,这些方法可能比使用 match 更简洁。

例如,这是另一种编写与清单 9-5 中相同逻辑的方式,这次使用闭包和 unwrap_or_else 方法:

// src/main.rs
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! 的快捷方式:unwrapexpect

使用 match 已经足够好用了,但它可能会有点冗长,并且并不总是能很好地传达意图。Result<T, E> 类型定义了许多辅助方法来执行各种更具体的任务。unwrap 方法是一个快捷方法,其实现方式与我们在清单 9-4 中编写的 match 表达式类似。如果 Result 值是 Ok 变体,unwrap 将返回 Ok 内部的值。如果 ResultErr 变体,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 '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");
}

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

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

在生产质量的代码中,大多数 Rust 开发者会选择 expect 而不是 unwrap,并给出更多关于为什么该操作预期总是会成功的上下文信息。这样,如果你的假设被证明是错误的,你在调试时就有更多信息可供使用。

传播错误

当一个函数的实现调用了可能失败的操作时,你可以将错误返回给调用代码,而不是在函数内部处理错误,这样调用代码就能决定如何处理。这被称为传播错误,它能给调用代码更多控制权,因为在调用代码的上下文中,可能有更多信息或逻辑来决定如何处理错误,而不仅仅是你在自己代码中所拥有的。

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

文件名:src/main.rs

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

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

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

  6 let mut username = String::new();

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

清单 9-6:使用 match 将错误返回给调用代码的函数

这个函数可以用更简短的方式编写,但为了探索错误处理,我们先手动完成很多步骤;最后,我们会展示更简短的方式。让我们先看看函数的返回类型:Result<String, io::Error> [1]。这意味着函数返回一个 Result<T, E> 类型的值,其中泛型参数 T 被具体类型 String 填充,泛型类型 E 被具体类型 io::Error 填充。

如果这个函数成功且没有任何问题,调用这个函数的代码将收到一个包含 StringOk 值 —— 即这个函数从文件中读取的 username [8]。如果这个函数遇到任何问题,调用代码将收到一个包含 io::Error 实例的 Err 值,该实例包含有关问题的更多信息。我们选择 io::Error 作为这个函数的返回类型,是因为这恰好是这个函数体中调用的两个可能失败的操作返回的错误值的类型:File::open 函数 [2] 和 read_to_string 方法 [7]。

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

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

调用这段代码的代码然后将处理获取到的包含用户名的 Ok 值或包含 io::ErrorErr 值。由调用代码决定如何处理这些值。如果调用代码得到一个 Err 值,它可以调用 panic! 并使程序崩溃,使用默认用户名,或者从文件以外的其他地方查找用户名,例如。我们没有足够的信息了解调用代码实际要做什么,所以我们向上传播所有的成功或错误信息,以便它能适当地处理。

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

传播错误的快捷方式:问号运算符(? 运算符)

清单 9-7 展示了 read_username_from_file 的另一种实现,它与清单 9-6 具有相同的功能,但此实现使用了 ? 运算符。

文件名:src/main.rs

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

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::openread_to_string 都成功时,我们仍然返回一个包含 usernameOk 值,而不是返回错误。功能再次与清单 9-6 和清单 9-7 相同;这只是一种不同的、更符合人体工程学的编写方式。

清单 9-9 展示了一种使用 fs::read_to_string 使代码更简短的方法。

文件名:src/main.rs

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。当我们编译这段代码时,会得到以下错误信息:

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() {
4 | |     let greeting_file = File::open("hello.txt")?;
  | |                                                ^ cannot use the `?`
operator in a function that returns `()`
5 | | }
  | |_- this function should return `Result` or `Option` to accept `?`
  |
  = help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not
implemented for `()`

这个错误指出我们只被允许在返回 ResultOption 或另一个实现了 FromResidual 的类型的函数中使用 ? 运算符。

要修复这个错误,你有两个选择。一个选择是将函数的返回类型更改为与你正在使用 ? 运算符的值兼容,只要你没有阻止这样做的限制。另一个选择是使用 matchResult<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()
}

清单 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 对象**,我们将在“使用允许不同类型值的 trait 对象”中讨论它。目前,你可以将 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 trait 的类型,该 trait 包含一个返回 ExitCode 的函数 report。有关为你自己的类型实现 Termination trait 的更多信息,请查阅标准库文档。

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

总结

恭喜你!你已经完成了“使用 Result 处理可恢复错误”实验。你可以在 LabEx 中练习更多实验来提升你的技能。