学习Rust中的错误处理

72 阅读9分钟

让不可能的状态变得不可能#

在Rust中,没有像undefinednull ,也没有像你在Java或C#等编程语言中知道的异常。相反,你使用内置的枚举来模拟状态。

  • Option<T> 用于可能没有值的绑定(例如: 或 )Some(x) None
  • Result<T, E> 用于可能出错的操作结果(例如: 对 )。Ok(val) Err(error)

两者之间的区别是非常细微的,在很大程度上取决于你的代码的语义。不过这两个枚举的工作方式是非常相似的。在我看来,最重要的是这两种类型都要求你去处理它们。要么明确地处理所有状态,要么明确地忽略它们

在这篇文章中,我想把重点放在Result<T, E> ,因为这其中实际上包含了错误。

Result<T, E> 是一个有两个变体的枚举。

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

T E T 可以是任何值, 可以是任何错误。两个变体 和 是全局可用的。E Ok Err

当你有可能出错的事情时,使用Result<T, E> 。一个预计会成功的操作,但可能会有不成功的情况。一旦你有了一个Result 的值,你就可以做以下事情。

  • 处理好这些状态!
  • 忽略它
  • 惊慌失措!
  • 使用回退
  • 传播错误

让我们看看我的详细意思。

处理错误状态#

让我们写一个小作品,我们想从一个文件中读取一个字符串。它要求我们

  1. 读取一个文件
  2. 从这个文件中读取一个字符串

这两个操作都可能导致std::io::Error ,因为可能会发生一些不可预见的事情(文件不存在,或者不能从文件中读出,等等)。所以我们要写的函数可以返回一个String ,也可以返回一个io::Error

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

fn read_username_from_file(path: &str) -> Result<String, io::Error> {
    let f = File::open(path);

    /* 1 */
    let mut f = match f {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut s = String::new();

    /* 2 */
    match f.read_to_string(&mut s) {
        Ok(_) => Ok(s),
        Err(err) => Err(err),
    }
}

这就是所发生的事情。

  1. 当我们从path 中打开一个文件时,它要么返回一个文件柄来与Ok(file) ,要么导致一个错误Err(e) 。通过match f ,我们不得不处理这两种可能的状态。要么我们把文件柄分配给f (注意f 的阴影),要么我们通过返回错误从该函数中返回。这里的return 语句很重要,因为我们要退出该函数。
  2. 然后我们要把内容读到s ,即我们刚刚创建的字符串中。这同样可以成功或抛出一个错误。函数f.read_to_string 返回读取的字节长度,所以我们可以安全地忽略这个值,并返回一个带有读取的字符串的Ok(s) 。在其他情况下,我们只是返回同样的错误。注意,我没有在match 表达式的末尾写上分号。由于它是一个表达式,这就是我们此时从函数中返回的东西。

这可能看起来非常冗长(确实如此......),但你看到了错误处理的两个非常重要的方面。

  1. 在这两种情况下,你都要处理这两种可能的状态。如果不做什么,你就不能继续了
  2. 影子(将一个值绑定到一个现有的名字上)和表达式这样的特性使得即使是冗长的代码也容易阅读和使用

我们刚才的操作通常被称为解包。因为你把包裹在枚举中的值解开了。

说到解包...

忽略那些错误#

如果你非常确信你的程序不会失败,你可以简单地使用内置函数.unwrap() 你的值。

fn read_username_from_file(path: &str) -> Result<String, io::Error> {
    let mut f = File::open(path).unwrap(); /* 1 */
    let mut s = String::new();
    f.read_to_string(&mut s).unwrap(); /* 1 */
    Ok(s) /* 2 */
}

下面是发生的情况。

  1. 在所有可能导致错误的情况下,我们都会调用unwrap() ,以获得该值
  2. 我们把结果包在一个Ok 变体中,然后返回。我们可以直接返回s ,并在我们的函数签名中删除Result<T, E> 。我们保留它是因为我们在其他例子中也会用到它。

unwrap() 函数本身很像我们在第一步中处理所有状态的做法。

// result.rs

impl<T, E: fmt::Debug> Result<T, E> {
    // ...

    pub fn unwrap(&self) -> T {
        match self {
            Ok(t) => t,
            Err(e) => unwrap_failed("called `Result::unwrap()` on an `Err` value", &e),
        }
    }

    // ...
}

unwrap_failed 是 宏的一个快捷方式。这意味着如果你使用 ,而你没有一个成功的结果,你的软件就会崩溃。😱panic! .unwrap()

你可能会问自己。这与其他编程语言中直接使软件崩溃的错误有什么不同?答案很简单:你必须明确这一点。Rust要求你做一些事情,即使是明确地允许恐慌。

有很多不同的.unwrap_ ,你可以在各种情况下使用这些函数。我们进一步看一下其中的一两个。

恐慌#

说到恐慌,你也可以用你自己的恐慌信息进行恐慌。

fn read_username_from_file(path: &str) -> Result<String, io::Error> {
    let mut f = File::open(path).expect("Error opening file");
    let mut s = String::new();
    f.read_to_string(&mut s).unwrap("Error reading file to string");
    Ok(s) 
}

.expect(...) 所做的事情与之非常相似unwrap()

impl<T, E: fmt::Debug> Result<T, E> {
    // ...
    pub fn expect(self, msg: &str) -> T {
        match self {
            Ok(t) => t,
            Err(e) => unwrap_failed(msg, &e),
        }
    }
}

但是,你的恐慌信息在你手中,你可能会喜欢!

但是,即使我们在任何时候都是明确的,我们可能希望我们的软件不要一遇到错误状态就恐慌和崩溃。我们可能想做一些有用的事情,比如提供回退或者......好吧......实际处理错误。

回退值#

Rust有可能在其Result (和Option )枚举上使用默认值。

fn read_username_from_file(path: &str) -> Result<String, io::Error> {
    let mut f = File::open(path).expect("Error opening file");
    let mut s = String::new();
    f.read_to_string(&mut s).unwrap_or("admin"); /* 1 */
    Ok(s) 
}
  1. "admin" 对于一个用户名来说,默认值可能不是最好的回退,但你会明白这个意思。在出现错误结果的情况下,我们不会崩溃,而是返回一个默认值。 方法为更复杂的默认值采取了一个闭包。.unwrap_or_else

这就更好了!不过,到目前为止,我们所学到的是在非常冗长、或允许明确的崩溃、或可能有回退值之间进行权衡。但我们能不能两者兼得?简洁的代码和错误安全?我们可以!

传播错误#

我最喜欢Rust的Result 类型的一个特点是可以传播错误。可能导致错误的两个函数都有相同的错误类型:io::Error 。我们可以在每个操作后使用问号操作符,为快乐的路径编写代码(只有成功的结果),如果出错,则返回错误结果。

fn read_username_from_file(path: &str) -> Result<String, io::Error> {
    let mut f = File::open(path)?;
    let mut s = String::new();
    f.read_to_string(&mut s)?;
    Ok(s) 
}

在这块,f 是一个文件处理程序,f.read_to_string 保存到s 。如果出了问题,我们用Err(io::Error) 从函数返回。简洁的代码,但我们处理了上面一层的错误。

fn main() {
    match read_username_from_file("user.txt") {
        Ok(username) => println!("Welcome {}", username),
        Err(err) => eprintln!("Whoopsie! {}", err)
    };
}

它的好处是什么?

  1. 我们仍然是明确的,我们必须要做一些事情!你仍然可以找到所有可能发生错误的地方!
  2. 我们可以写出简洁的代码,就好像错误不会存在一样。错误还是要处理的!要么来自我们,要么来自我们函数的用户。

问号运算符也可以在Option<T> ,这也可以写出一些非常漂亮和优雅的代码

传播不同的错误#

但问题是,像这样的方法只有在错误类型相同的情况下才起作用。如果我们有两种不同类型的错误,我们就必须要有创造性。看看这个稍作修改的函数,我们打开并读取文件,但随后将读取的内容解析成一个u64

fn read_number_from_file(filename: &str) -> Result<u64, ???> {
    let mut file = File::open(filename)?; /* 1 */

    let mut buffer = String::new();
    file.read_to_string(&mut buffer)?; /* 1 */

    let parsed: u64 = buffer.trim().parse()?; /* 2 */

    Ok(parsed)
}
  1. 这两个点可以引起io::Error ,正如我们在前面的例子中知道的那样
  2. 这个操作却会引起一个ParseIntError

问题是,我们不知道在编译时得到哪个错误。这完全取决于我们的代码运行。我们可以通过match 表达式来处理每个错误,并返回我们自己的错误类型。这是有道理的,但使我们的代码又变得冗长。或者我们为 "在运行时发生的事情 "做准备!

看看我们稍微改变的函数

use std::error;

fn read_number_from_file(filename: &str) -> Result<u64, Box<dyn error::Error>> {
    let mut file = File::open(filename)?; /* 1 */

    let mut buffer = String::new();
    file.read_to_string(&mut buffer)?; /* 1 */

    let parsed: u64 = buffer.trim().parse()?; /* 2 */

    Ok(parsed)
}

这就是所发生的事情。

  • 我们没有返回一个错误实现,而是告诉Rust,实现Error 错误特性的东西正在出现。
  • 由于我们在编译时不知道这可能是什么,我们必须把它变成一个特质对象dyn std::error::Error
  • 因为我们不知道这个对象会有多大,所以我们用一个Box 。一个智能指针,指向最终会在堆上的数据。

Box<dyn Trait> 在Rust中实现了动态调度。可以动态地调用一个在编译时不知道的函数。为此,Rust引入了一个vtable,用来保存指向实际实现的指针。在运行时,我们使用这些指针来调用适当的函数实现。

Memory layout of Box and Box

而现在,我们的代码又变得简洁了,而我们的用户则要处理最终的错误。

当我在课程中向人们展示这一点时,我得到的第一个问题是。但我们最终能不能检查出哪种类型的错误?我们可以downcast_ref() 方法允许我们回到原来的类型。

fn main() {
    match read_number_from_file("number.txt") {
        Ok(v) => println!("Your number is {}", v),
        Err(err) => {
            if let Some(io_err) = err.downcast_ref::<std::io::Error>() {
                eprintln!("Error during IO! {}", io_err)
            } else if let Some(pars_err) = err.downcast_ref::<ParseIntError>() {
                eprintln!("Error during parsing {}", pars_err)
            }
        }
    };
}

Groovy!

自定义错误#

如果你想为你的操作创建自定义错误,情况会变得更好,更灵活。要使用自定义错误,你的错误结构必须实现std::error::Error 特质。这可以是一个经典结构,一个元组结构,甚至是一个单元结构。

你不需要实现std::error::Error 的任何功能,但是你需要实现DebugDisplay 特质。理由是错误要被打印在某个地方。下面是一个例子的样子。

#[derive(Debug)] /* 1 */
pub struct ParseArgumentsError(String); /* 2 */

impl std::error::Error for ParseArgumentsError {} /* 3 */

/* 4 */
impl Display for ParseArgumentsError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}
  1. 我们派生出Debug 特质。
  2. 我们的ParseArgumentsError 是一个有一个元素的元组结构。一个自定义的消息
  3. 我们为ParseArgumentsError 实现std::error::Error 。不需要实现其他东西
  4. 我们实现Display ,在这里我们打印出我们元组的单个元素。

就这样吧!