Rust:如何用枚举来包装多个错误

823 阅读8分钟

当我们想对不同的错误类型使用错误传播时,我们必须依靠特质对象与Box<dyn Error> ,这意味着我们将很多信息从编译时推迟到运行时,以便于错误处理。

你可能会认为这一点都不方便,因为这涉及到一些下转换,以获得原始的错误,而我们依靠特质对象和动态调度来沿着我们的代码库携带类似错误的东西。我宁愿在编译时删除这些信息。

Memory layout of Box and Box<dyn Trait>

Box和Box的内存布局

有一个非常好的模式来处理涉及枚举的多个错误。这就是我今天要和大家分享的。它需要设置更多的模板(肯定可以通过某种方式进行宏化),但最终我发现它更好用,而且可以说在运行时也有一些好处。

以前是这样:特质对象#

让我们快速回顾一下在上一个例子中我们最终得到的结果。

use std::error;

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

    let mut buffer = String::new();

    /* 1: std::io::Error */
    file.read_to_string(&mut buffer)?;

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

    Ok(parsed)
}

这个函数会导致两种不同的错误类型。

  1. 当我们打开文件或从文件中读取时,一个std::io::Error
  2. 当我们试图将字符串解析成一个std::num::ParseIntErroru64

由于两者都实现了std::error::Error 特质,我们可以使用一个盒式特质对象Box<dyn Error> 来传播错误,并根据我们程序中发生的情况得到一个动态结果。再说一遍。重要的是要反复强调,这定义了运行时的动态行为,而在所有其他情况下,Rust试图在编译时尽可能多地找出问题。

使用枚举#

我们准备一个包含所有可能的错误的Error枚举,而不是有一个动态的返回结果。在我们的例子中,这就是一个ParseIntError ,以及一个std::io::Error

enum NumFromFileErr {
    ParseError(ParseIntError),
    IoError(std::io::Error),
}

为了使用这个枚举作为错误,我们需要为它实现std:error::Error 特质。正如我们在上一篇文章中所知道的,Error 特质本身不需要任何额外的实现,但是我们需要实现DebugDisplay

Debug 是很容易推导出来的...

#[derive(Debug)]
enum NumFromFileErr {
    ParseError(ParseIntError),
    IoError(std::io::Error),
}

Display ,主要是把我们每个错误的错误信息写进一个格式化器。

impl Display for NumFromFileErr {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NumFromFileErr::ParseError(parse_int_error) => 
                write!(f, "{}", parse_int_error),
            NumFromFileErr::IoError(io_error) => 
                write!(f, "{}", io_error),
        }
    }
}

// Make it an error!
impl std::error::Error for NumFromFileErr {}

你已经可以感觉到重复的到来。如果我们的函数可能会返回第三个错误类型,那么NumFromFileErr 枚举以及Display 的实现都需要调整。

传播方面呢?#

有了这个,我们已经可以在Result<T, E> 中使用我们的自定义错误了。如果我们改变它(就像下面例子中的第一行),我们会得到几个错误,虽然。

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

    let mut buffer = String::new();

    file.read_to_string(&mut buffer)?; // Error

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

    Ok(parsed)
}

发生了什么?read_number_from_file 中的三个方法仍然导致std::io::Errorstd::num::ParseIntError 。当我们使用问号运算符? 来传播它们时,它们与NumFromFileErr 不兼容。Rust编译器会准确地告诉我们哪里出了问题(这个是要滚动的)。

error[E0277]: `?` couldn't convert the error to `NumFromFileErr`
  --> src/main.rs:34:40
   |
33 | fn read_number_from_file(filename: &str) -> Result<u64, NumFromFileErr> {
   |                                             --------------------------- expected `NumFromFileErr` because of this
34 |     let mut file = File::open(filename)?;
   |                                        ^ the trait `From` is not implemented for `NumFromFileErr`

让我们专注于第一行。问号运算符无法将错误转换为NumberFromFileError 。所以让我们自己来做这件事。匹配每个错误,如果操作成功,则返回值,如果不成功,则返回一个错误,从NumFromFileError

fn read_number_from_file(filename: &str) -> Result<u64, NumFromFileErr> {
    let mut file = match File::open(filename) {
        Ok(file) => file,
        Err(err) => return Err(NumFromFileErr::IoError(err)), // 👀
    };

    let mut buffer = String::new();

    match file.read_to_string(&mut buffer) {
        Ok(_) => {}
        Err(err) => return Err(NumFromFileErr::IoError(err)), // 👀
    };

    let parsed: u64 = match buffer.trim().parse() {
        Ok(parsed) => parsed,
        Err(err) => return Err(NumFromFileErr::ParseError(err)), // 👀
    };

    Ok(parsed)
}

哇,这太繁琐了!我们的甜蜜传播发生了什么?好吧,这些错误是不兼容的,所以我们必须让它们兼容。但是有一个更好的方法。一个更习惯的方法,并且在错误信息的第二部分中暗示了这一点。the trait From<std::io::Error> is not implemented for NumFromFileErr

From特质#

From 特质允许你定义如何一个类型转到另一个类型。这是一个通用的特质,你指定你要转换的类型,然后为你自己的类型实现它。因为我们已经在枚举本身中定义了如何处理ParseIntErrorstd::io::Error ,所以转换的实现是非常直接的。

impl From<ParseIntError> for NumFromFileErr {
    fn from(err: ParseIntError) -> Self {
        NumFromFileErr::ParseError(err)
    }
}

impl From<std::io::Error> for NumFromFileErr {
    fn from(err: std::io::Error) -> Self {
        NumFromFileErr::IoError(err)
    }
}

哦......你能闻到重复的美吗?还有一种将一种类型转换为另一种类型的方法,即通过实现Into 特质。如果你需要实现这种转换,一定要选择From 。由于Rust核心库中的这种美感,反向Into 特质是免费出现的。

impl<T, U> Into<U> for T
where
    U: From<T>,
{
    fn into(self) -> U {
        U::from(self)
    }
}

Into 这实现了对通用的T 的转换,在这里我们要把T 转换为U 。如果U 实现了trait边界所定义的From<T> ,我们只需调用相应的from 方法。正是这样的美丽使Rust成为一种优雅的语言,并显示了traits的真正力量。

基本上就是这样了。随着从两个错误到我们自定义定义的错误的转换,错误传播又开始工作了!

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

    let mut buffer = String::new();

    file.read_to_string(&mut buffer)?;

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

    Ok(parsed)
}

很好!很好有一点额外的模板,但没有trait对象。堆上没有东西。没有vtable ,用于动态查找。更少的运行时代码。还有一些额外的好处...

匹配枚举分支与下划线#

有一件事让我很不爽,那就是将trait对象下移到一个真正的结构。对我来说,这感觉很像在和热煤球打交道,因为你永远不知道哪些错误会真的发生。我认为如果没有很好的文档记录,这就是猜测。这里的这个。

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::<ParseFloatError>() {
                eprintln!("Error during parsing {}", pars_err)
            }
        }
    };
}

完美地编译,尽管我的函数从未返回错误结果ParseFloatError 。如果我们使用枚举,工具和枚举本身会告诉我们有哪些可能的错误。同时,处理这些错误又变得非常优雅。

fn main() {
    match read_number_from_file("number.txt") {
        Ok(v) => println!("Your number is {}", v),
        Err(err) => match err {
            NumFromFileErr::IoError(_) => println!("Error from IO!"),
            NumFromFileErr::ParseError(_) => println!("Error from Parsing!"),
        },
    };
}

这也是Rust的美丽之处之一。它是一种允许你从非常低级的编程风格到非常高级的编程风格而不牺牲优雅性的语言

重复性#

Box<dyn Error>相比,我们唯一牺牲的是我们需要创建的模板数量。特质对象实在是太方便了,不是吗?但是,所有这些看起来像重复和模板的东西,看起来也像我们可以用宏来帮助我们生成代码。而对于Rust来说,你可以非常肯定有人已经做到了这一点。

我发现的一个箱子是thiserror,它可以帮助你避免重复,并允许非常复杂的自定义错误情况。

如果我自己也能创造出这样的东西,可能也是一种有趣的练习。

底线#

Boxed trait对象有它的用途,对于处理只有在运行时才知道的情况来说,它确实是一种很好的方法。Box<dyn Error> ,这也是一种看起来很常见的东西。然而,尽管枚举版本创造了更多的代码,但对我来说感觉也没有那么复杂。枚举比特质对象处理起来要简单得多。它们如何影响内存在编译时就已经知道了。而且,枚举可以准确地告诉我我的选择是什么。

每当我遇到可以传播各种错误的函数时,Enums作为错误是我处理它们的首选方式。

还有来自David Tolnay的观点,他同时创造了thiserroranyhow如果你关心设计你自己的专用错误类型,使用thiserror,这样调用者就能在失败时准确地收到你选择的信息。这通常适用于类似库的代码。如果你不关心你的函数返回什么错误类型,你只想让它变得简单,那么就使用Anyhow。这在类似应用程序的代码中很常见。