Rust之错误处理(二):带结果信息的可恢复错误

开发环境

  • Windows 10
  • Rust 1.67.1

 

  •  VS Code 1.75.1

Rust之错误处理(二):带结果信息的可恢复错误_第1张图片

   项目工程

这里继续沿用上次工程rust-demo

带结果信息的可恢复错误

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

回顾前面章节中的 "用结果处理潜在的失败",结果枚举被定义为有两个变体,OkErr,如下例所示。

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

TE是通用类型参数:我们将在后面章节中详细讨论通用类型。你现在需要知道的是,T代表在Ok变量中成功情况下将被返回的值的类型,E代表在Err变量中失败情况下将被返回的错误类型。因为Result有这些通用的类型参数,我们可以在许多不同的情况下使用Result类型和定义在它上面的函数,我们想返回的成功值和错误值可能不同。

让我们调用一个返回结果值的函数,因为这个函数可能会失败。在下例3中,我们尝试打开一个文件。

例3:文件名: src/main.rs

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");  // hello.txt是否存在
    println!("greeting_file_result = {:?}", greeting_file_result )
}

编译运行

cargo run

 File::open 的返回类型是一个 Result。通用参数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的实例,包含关于发生的错误类型的更多信息。

我们需要在上例2中的代码中添加一些内容,以便根据File::open返回的值采取不同的行动。下例3显示了一种使用基本工具处理结果的方法,即我们在之前章节讨论过的match表达式。

例4:文件名: src/main.rs

use std::fs::File;

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

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

请注意,和Option枚举一样,Result枚举和它的变体已经被前奏带入了范围,所以我们不需要在匹配类中的OkErr变体之前指定Result::

当结果为Ok时,该代码将从Ok变量中返回内部文件值,然后我们将该文件句柄值赋给变量greeting_filematch后,我们可以使用文件句柄来读或写。

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

编译运行

cargo run

Rust之错误处理(二):带结果信息的可恢复错误_第2张图片

像往常一样,这个输出告诉我们到底哪里出错了。

不同错误的匹配

上例中的代码会panic!不管为什么File::open失败。但是,我们希望针对不同的失败原因采取不同的操作:如果File::open因为文件不存在而失败,我们希望创建该文件并将句柄返回给新文件。如果File::open由于任何其他原因失败—例如,因为我们没有打开文件的权限—我们仍然希望代码panic!就像上例中一样。为此,我们添加了一个内部match表达式,如下例5所示。 

例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() {    // match错误类型
            ErrorKind::NotFound => match File::create("hello.txt") {   // 创建hello,ext
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {:?}", e),
            },
            other_error => {
                panic!("Problem opening the file: {:?}", other_error);
            }
        },
    };
}

编译运行

cargo run

Rust之错误处理(二):带结果信息的可恢复错误_第3张图片 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的第二个分支保持不变,因此除了丢失文件错误之外,程序还会对任何错误产生恐慌。

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

match的多了去了!match表达式非常有用,但也非常原始。在后面章节中,你将学习闭包,它与Result上定义的许多方法一起使用。在代码中处理Result值时,这些方法比使用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);
        }
    });
}

尽管这段代码的行为与上例相同,但它不包含任何match表达式,读起来更清晰。在你读完后面章节后回到这个例子,在标准库文档中查找unwrap_or_else方法。当您处理错误时,更多的这些方法可以清理大量嵌套的match表达式。

出错时死机的快捷方式:unwrap和expect

使用match可以很好地工作,但是它可能有点冗长,并且不总是很好地传达意图。Result类型定义了许多方法来完成各种更具体的任务。unwrap方法是一个快捷方法,就像我们在上例中编写的match表达式一样。如果Result值是Ok变量,unwrap将返回Ok变量中的值。如果ResultErr变量,unwrap将调用panic!宏观对我们来说。下面是一个实际展开的例子:

文件名:src/main.rs

use std::fs::File;

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

运行

cargo run

 如果我们在没有hello.txt文件的情况下运行这段代码,我们将会看到一条来自panic!的错误消息!unwrap方法进行的调用:

同样,expect方法也让我们选择panic!错误消息。使用expect而不是unwrap并提供良好的错误消息可以传达您的意图,并使跟踪死机的根源变得更容易。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");
    println!("greeting_file = {:?}", greeting_file);
}

运行

cargo run

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

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

传播误差

当函数的实现调用可能失败的东西时,您可以将错误返回给调用代码,以便它可以决定做什么,而不是在函数本身中处理错误。这就是所谓的传播错误,并给予调用代码更多的控制,其中可能有比代码上下文中可用的更多的信息或逻辑来指示应该如何处理错误。

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

例6:文件名:src/main.rs

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

fn read_username_from_file() -> Result {
    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),
    }
}

这个函数可以用更短的方式编写,但是为了探索错误处理,我们将从手工做大量的工作开始;最后,我们将展示更短的方法。我们先来看看函数的返回类型:Result。这意味着该函数正在返回类型Result< T,E >的值,其中泛型参数T已经用具体类型String填充,泛型类型E已经用具体类型io::Error填充。

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

函数体通过调用File::open函数开始。然后我们用类似于之前例子中的匹配来处理Result值。如果File::open成功,模式变量file中的文件句柄将变成可变变量username_file中的值,函数将继续运行。在Err的情况下,而不是叫painc!,我们使用return关键字从函数中提前返回,并将File::open中的错误值(现在在模式变量e中)作为该函数的错误值传递回调用代码。

因此,如果我们在username_file中有一个文件句柄,该函数就会在变量username中创建新String,并在username_file中的文件句柄上调用read_to_string方法,将文件内容读入usernameread_to_string方法也返回一个Result,因为它可能会失败,即使File::open成功了。所以我们需要另一个匹配来处理这个结果:如果read_to_string成功了,那么我们的函数就成功了,我们从文件中返回username,这个文件现在包含在一个Ok中。如果read_to_string失败,我们返回错误值的方式与我们在处理File::open返回值的match中返回错误值的方式相同。但是,我们不需要显式地说return,因为这是函数中的最后一个表达式。

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

这种传播错误的模式在Rust中非常普遍,以至于Rust提供了问号运算符为了让事情变得简单

传播错误的快捷方式 :? 操作符

下例7显示了read_username_from_file的一个实现,它具有与上例相同的功能,但是这个实现使用了操作符。 

例7:文件名:src/main.rs

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

fn read_username_from_file() -> Result {
    let mut username_file = File::open("hello.txt")?;   // ? 操作符
    let mut username = String::new();
    username_file.read_to_string(&mut username)?;       // ? 操作符
    Ok(username)
}

放置在Result值定义之后,其工作方式与我们在上例中定义的处理结果值的match表达式几乎相同。如果Result的值是OkOk中的值将从这个表达式返回,程序将继续。如果值是一个ErrErr将从整个函数中返回,就像我们使用return关键字一样,因此错误值将传播到调用代码。

上例中的match表达式和? 操作符所做的事情是有区别的:被调用了? 操作符的错误值会经过标准库中的From特性中定义的from函数,该函数用于将值从一种类型转换成另一种类型。当? 操作符调用from函数时,收到的错误类型被转换为当前函数的返回类型中定义的错误类型。当一个函数返回一个错误类型时,这很有用,它代表了一个函数可能失败的所有方式,即使部分函数可能因为许多不同的原因而失败。

例如,我们可以改变上例7中的 read_username_from_file 函数来返回一个我们定义的名为 OurError 的自定义错误类型。如果我们也为OurError定义 impl From for io::Error构造一个OurError的实例,那么read_username_from_file正文中的? 操作符调用将调用from并转换错误类型,而不需要在函数中添加任何代码。

在上例的上下文中,File::open调用的结尾处的? 将返回Ok内的值到变量username_file。如果发生错误,? 操作符将提前从整个函数中返回,并将任何Err值交给调用代码。同样的事情也适用于read_to_string调用结束时的?

? 运算符消除了大量的模板,使这个函数的实现更加简单。我们甚至可以通过在"? "后面紧跟的方法调用链来进一步缩短这段代码,如下例8中所示。

例8:文件名:src/main.rs

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

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

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

    Ok(username)
}

我们把创建用username中的新String移到了函数的开头;这部分没有改变。我们没有创建一个变量username_file,而是将对read_to_string的调用直接链接到File::open("hello.txt")的结果上。我们在 read_to_string 调用的最后仍然有一个 ? ,而且当 File::openread_to_string 都成功时,我们仍然返回一个包含用户名的 Ok 值,而不是返回错误。其功能与例6和例7中的相同;这只是一种不同的、更符合人体工程学的写法而已。

例9显示了一种使用fs::read_to_string使其更短的方法。

例9:文件名:src/main.rs

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

fn read_username_from_file() -> Result {
    fs::read_to_string("hello.txt")
}

将文件读入字符串是一个相当常见的操作,所以标准库提供了方便的fs::read_to_string函数,该函数打开文件,创建一个新的String,读取文件的内容,将内容放入该String中,并返回它。当然,使用fs::read_to_string并没有给我们解释所有错误处理的机会,所以我们先用较长的方法来做。

哪些地方可以使用? "操作符

? 操作符只能在返回类型与操作符所使用的值兼容的函数中使用。这是因为?操作符被定义为执行从函数中提前返回一个值,其方式与我们在例6 中定义的match表达式相同。在清例6中,match使用的是一个Result值,而提前返回了一个Err(e)值。函数的返回类型必须是一个 Result,这样才能与这个Return兼容。

在例10中,让我们看看如果我们在一个main函数中使用? 操作符,其返回类型与我们使用的值的类型不兼容,我们会得到什么错误。

例10:文件名:src/main.rs

use std::fs::File;

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

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

Rust之错误处理(二):带结果信息的可恢复错误_第4张图片

这个错误指出,我们只允许在返回 ResultOption 或其他实现 FromResidual 的类型的函数中使用 ? 操作符。

要解决这个错误,你有两个选择。一种选择是改变你的函数的返回类型,使之与你使用的? 运算符的值兼容,只要你没有限制阻止这样做。另一种技术是使用matchResult方法之一,以任何适当的方式处理Result

该错误信息还提到,? 也可以用于 Option值。与在Result上使用 ? 一样,你只能在一个返回 Option 的函数中对 Option 使用 ? 。在 Option上调用 ? 操作符的行为与在 Result上调用的行为类似:如果值是 NoneNone将在此时从函数中提前返回。如果值是SomeSome里面的值就是表达式的结果值,函数继续。例11中有一个函数的例子,它可以找到给定文本中第一行的最后一个字符。

例11

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

这个函数返回Option,因为那里有可能有一个字符,但也有可能没有。这段代码接收了文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使用 ? 操作符,也可以在一个返回 Option 的函数中对一个 Option 使用 ? 操作符,但是你不能混合匹配。运算符不会自动将一个Result转换为一个Option ,反之亦然;在这种情况下,你可以使用像Resultok方法或Option ok_or方法来进行明确的转换。

到目前为止,我们所使用的所有main函数都返回()main函数很特别,因为它是可执行程序的进入和退出点,它的返回类型是有限制的,以使程序的行为符合预期。

幸运的是,mainmain返回一个Result<(), E>。清例12中有例子10的代码,但我们将main的返回类型改为Result<(), Box>,并在结尾处添加了一个返回值Ok(())。这段代码现在可以编译了。

例12

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

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

    Ok(())
}

Rust之错误处理(二):带结果信息的可恢复错误_第5张图片 Box类型是一个特质对象,我们将在后面章节的 "使用允许不同类型的值的特质对象 "一节中讨论这个问题。现在,你可以把Box理解为 "任何类型的错误"。在错误类型为Boxmain函数中对Result值使用? 是允许的,因为它允许任何Err值被提前返回。即使这个main函数的主体只返回std::io::Error类型的错误,通过指定Box,即使有更多返回其他错误的代码被添加到main的主体中,这个签名仍然是正确的。

main函数返回一个Result<(), E>时,如果main返回Ok(()),可执行程序将以0的值退出,如果main返回Err值,则以非零值退出。用C语言编写的可执行文件在退出时返回整数:成功退出的程序返回整数0,出错的程序返回0以外的某个整数。

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

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

本章重点

  • 带结果的可恢复错误概念和使用
  • 不同错误的匹配
  • 程序死掉的方式:unwrap和expect
  • 传播误差的概念
  • ?操作符的使用
  • ?操作符的使用场景

你可能感兴趣的:(Rust,rust,开发语言,后端)