Skip to content
rust
use std::net::IpAddr;
fn main() {
    let s = "100eee";
    if let Err(e) = s.parse::<i32>() {
        // e 这里是 ParseIntError
        println!("Failed conversion to i32: {e}");
    }

    let addr = "127.0.0.1:8080".parse::<IpAddr>();
    if let Err(e) = addr {
        // e 这里是 AddrParseError
        println!("Failed conversion to IpAddr: {e}");
    }
}
// 输出
Failed conversion to i32: invalid digit found in string
Failed conversion to IpAddr: invalid IP address syntax

用枚举定义错误

下面我们来看在Rust中使用Result作为函数返回值,在上层中处理的典型方式。

rust
// 定义自己的错误类型,一般是一个枚举,因为可能有多种错误
enum HereError {
    Error1,
    Error2,
    Error3,
}
// 一个函数返回Err
fn bar() -> Result<String, HereError> {
    Err(HereError::Error3)
}

fn foo() {
    match bar() {
        Ok(_) => {}
        Err(err) => match err {  // 在上层中通过match进行处理
            HereError::Error1 => {}
            HereError::Error2 => {}
            HereError::Error3 => {}
        },
    }
}

通常我们会在当前模块中定义错误类型,一般是枚举类型,因为错误种类往往不止一个。如果某个接口返回了这个错误类型,在上层就需要match这个枚举类型进行错误处理。

到目前为止我们并没有给我们自定义的错误类型HereError实现Debug、Display和Error trait,所以我们的错误类型还仅限于自己玩,为了把它纳入Rust生态体系,我们需要给它实现这3个trait。但是我们没必要自己手动去实现,社区中已经有很好的工具crate: thiserror 可以帮助我们实现这个目的,继续往下看,待会儿就会讲到。

错误的传递

前面我们介绍了错误类型的定义和处理的基本方式,接下来,我们开始系统性地介绍错误的传递。

函数返回 Result<T, E>

前面已经讲过,在Rust中只要一个函数中可能有出错的情况发生,那么它的返回值就默认约定为Result。在继续讲之前,我们先对比一下其他语言中是怎么处理的。

C语言中,一般用同一种类型的特殊值表示异常。比如一个函数返回一个有符号整数,可以用0表示正常情况下的返回,用-1或其他负数值表示异步情况下的返回。但是这个约定并不是普遍共识,因此你可以在C语言中看到,大部分情况下函数返回0表示正常,但在一些特定情况下,返回0又表示不正确。缺乏强制约束给整个生态带来了混乱。

Java这种语言,提供强大的try-catch-throw,在语言层面捕获异常。这种形式虽然方便,但实际上会给语言Runtime带来负担,因为语言的Runtime要负责捕获代码中的异常,会有额外的性能损失。另外,由于try-catch-throw使用很方便,有时会看到程序员为了偷懒,将一大段代码全部包在try-catch-throw中的情况,无疑这会大大降低代码的质量,整个程序没办法对错误情况做精细地处理。

而Rust采取的方式是把异常情况独立出来一个维度,放在 Result<T, E> 的Err变体中。也就是说,错误在类型上就是以独立的维度存在的。比如:

rust
fn foo(num: u32) -> Result<String, String> {
    if num == 10 {
        Ok("Hello world!".to_string())
    } else {
        Err("I'm wrong!".to_string())
    }
}

上述代码中的错误类型部分被定义为String类型,实际上你可以定义成任意类型,比如下面我们把错误定义成u32类型。

rust
fn foo(num: u32) -> Result<String, u32> {
    if num == 10 {
        Ok("Hello world!".to_string())
    } else {
        Err(100)
    }
}

有时一个函数中的错误情况可能不止一种,这时候该怎样定义返回类型呢?惯用办法就是使用enum,前面其实已经见过了,这里再看一个示例。

rust
enum MyError {
    Error1,
    Error2,
    Error3,
}

fn foo(num: u32) -> Result<String, MyError> {
    match num {
        10 => Ok("Hello world!".to_string()),
        20 => Err(MyError::Error1),
        30 => Err(MyError::Error2),
        _ => Err(MyError::Error3),
    }
}

这里Result的E部分,类型就是我们自定义的MyError。

另一种常用的办法是让函数返回 Result<_, Box<dyn Error>>,比如:

rust
use std::error::Error;
use std::fmt;

#[derive(Debug)]
struct MyError;

impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self)
    }
}

impl Error for MyError {}

fn foo(num: u32) -> Result<String, Box<dyn Error>> {
    match num {
        10 => Ok("Hello world!".to_string()),
        _ => {
            let my_error = MyError;
            Err(Box::new(my_error))
        }
    }
}

可以看到,一旦把错误独立到另一个维度来处理后,我们得到了相当大的灵活性和安全性:可以借助类型系统来帮助检查正常情况与异常情况的不同返回,大大减少了编码出错的机率。

有了这套优秀的错误处理底层设施后,整个Rust生态上层建筑逐渐结构性地构建起来了,大家都遵从这个约定,用同样的方式来传递和处理错误,形成了一个健康良好的生态。

map_err转换错误类型

我们常常使用Result上的 map_err 方法手动转换错误类型。比如下面这个示例:

rust
use std::fs::File;
use std::io::Read;

fn read_file() -> Result<String, String> {
    match File::open("example.txt").map_err(|err| format!("Error opening file: {}", err)) {
        Ok(mut file) => {
            let mut contents = String::new();
            match file
                .read_to_string(&mut contents)
                .map_err(|err| format!("Error reading file: {}", err))
            {
                Ok(_) => Ok(contents),
                Err(e) => {
                    return Err(e);
                }
            }
        }
        Err(e) => {
            return Err(e);
        }
    }
}

我们要在 read_file() 中打开一个文件,并读取文件全部内容到字符串中。整个过程中,有可能出现两个I/O错误:打开文件错误和读取文件错误。可以看到在示例中我们使用 map_err 将这两个I/O错误的类型都转换成了String类型,来和函数返回类型签名相匹配。然后,对两个操作的Result进行了match匹配。这个函数里的两个文件操作可能的错误都是std::io::Error类型的。

很多时候同一个函数中会产生不同的错误类型,这时仍然可以使用 map_err 显式地把不同的错误类型转换成我们需要的同一种错误类型。

Result 链式处理

除了每次对Result进行match处理外,Rust中还流行一种方式,就是对Result进行链式处理。我们可以将上面打开文件并读取内容的例子改写成链式调用的风格。

代码如下:

rust
use std::fs::File;
use std::io::Read;

fn read_file() -> Result<String, String> {
    File::open("example.txt")
        .map_err(|err| format!("Error opening file: {}", err))
        .and_then(|mut file| {
            let mut contents = String::new();
            file.read_to_string(&mut contents)
                .map_err(|err| format!("Error reading file: {}", err))
                .map(|_| contents)
        })
}

fn main() {
    match read_file() {
        Ok(n) => println!("{}", n),
        Err(err) => println!("Error: {}", err),
    }
}

可以明显看到,使用链式风格改写的示例比前面用match进行处理的示例简洁很多。这里用到了 map_errand_thenmap 三种链式操作,它们可以在不解开Result包的情况下直接对里面的内容进行处理。关于这几个方法的详细内容,你可以参考 第 8 讲

这里需要说明的是,在第5行 File::open() 执行完,如果产生的 Result 是 Err,那么在第6行 map_err() 后,不会再走 and_then() 操作,而是直接从 read_file() 函数中返回这个 Result 了。如果第5行的操作产生的 Result 是 Ok,就会跳过第6行,进入第7行执行。

进入第7行后,会消解前面产生的Result,把 file 对象传进来使用。然后我们再去看第9行产生的Result,如果这个Result实例是Err,那么执行完第10行后,就直接从闭包返回了,返回的是Err值,这个值会进一步作为 read_file() 函数的返回值返回。而如果Result实例是Ok,就会跳过第10行,执行第11行,第11行将 contents 字符串move进来作为内层闭包的返回值,并进一步以 Ok(contents) 的形式作为 read_file() 函数的返回值返回。

你可能会惊叹,这种链式处理比前面的match操作优美太多,但是理解起来也困难太多。这是正常的,开始的时候我们对这种链式写法会比较陌生,不过没关系,可以多写写,慢慢理解,这就是一个熟能生巧的事情,本身其实并不复杂。

? 问号操作符

另一方面,前面的match写法,有没有办法简化呢?因为看上去好像有很多样板代码。

rust
use std::fs::File;
use std::io::Read;

fn read_file() -> Result<String, String> {
    match File::open("example.txt").map_err(|err| format!("Error opening file: {}", err)) {
        Ok(mut file) => {
            let mut contents = String::new();
            match file
                .read_to_string(&mut contents)
                .map_err(|err| format!("Error reading file: {}", err))
            {
                Ok(_) => Ok(contents),
                Err(e) => {
                    return Err(e);
                }
            }
        }
        Err(e) => {
            return Err(e);
        }
    }
}

比如上面代码中的第13~15行和第18~20行,都是把错误 Err(e) 返回到上一层。

rust
  Err(e) => {
      return Err(e);
  }

Rust中有一个 操作符,可以用来简化这种场景。我们把前面代码用 操作符改造一下。

rust
use std::fs::File;
use std::io::Read;

fn read_file() -> Result<String, String> {
    let mut file =
        File::open("example.txt").map_err(|err| format!("Error opening file: {}", err))?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)
        .map_err(|err| format!("Error reading file: {}", err))?;

    Ok(contents)
}

哇,神奇不?怎么行数缩短了这么多。

具体来说, 操作符大体上等价于一个match语句。

rust
let ret = a_result?;
等价于
let ret = match a_result {
    Ok(ret) => ret,
    Err(e) => return Err(e),   // 注意这里有一个return语句。
};

也就是说,如果result的值是Ok,就解包;如果是Err,就提前从此函数中返回这个Err。这实际是一种 防御式编程,遇到了错误,就提前返回。防御式编程能让函数体中的代码大大简化,可以减少很多层括号,相信你已经从上面的示例对比中感受到了。

细心的你可能已经发现了,这里的e是这个 a_resultErr(e) 中的 e。这个实例的类型是什么呢?使用return语句返回它的话,那么它是不是一定和函数中定义的返回类型中的错误类型一致呢?这个问题其实很重要。从上面的示例来看,我们明确地用 map_err 把 io::Error 转换成了 String 这种类型,所以是没问题的。我们可以来做个实验,把 map_err 去掉试试。

rust
use std::fs::File;
use std::io::Read;

fn read_file() -> Result<String, String> {
    let mut file =
        File::open("example.txt")?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;

    Ok(contents)
}

编译器报错了:

rust
error[E0277]: `?` couldn't convert the error to `String`
 --> src/lib.rs:6:34
  |
4 | fn read_file() -> Result<String, String> {
  |                   ---------------------- expected `String` because of this
5 |     let mut file =
6 |         File::open("example.txt")?;
  |                                  ^ the trait `From<std::io::Error>` is not implemented for `String`
  |
  = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait
  = help: the following other types implement trait `From<T>`:
            <String as From<char>>
            <String as From<Box<str>>>
            <String as From<Cow<'a, str>>>
            <String as From<&str>>
            <String as From<&mut str>>
            <String as From<&String>>
  = note: required for `Result<String, String>` to implement `FromResidual<Result<Infallible, std::io::Error>>`

error[E0277]: `?` couldn't convert the error to `String`
 --> src/lib.rs:8:39
  |
4 | fn read_file() -> Result<String, String> {
  |                   ---------------------- expected `String` because of this
...
8 |     file.read_to_string(&mut contents)?;
  |                                       ^ the trait `From<std::io::Error>` is not implemented for `String`
  |
  = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait
  = help: the following other types implement trait `From<T>`:
            <String as From<char>>
            <String as From<Box<str>>>
            <String as From<Cow<'a, str>>>
            <String as From<&str>>
            <String as From<&mut str>>
            <String as From<&String>>
  = note: required for `Result<String, String>` to implement `FromResidual<Result<Infallible, std::io::Error>>`

提示说, ? 操作符不能把错误类型转换成 String 类型。这也是初学者在使用 ?操作符时的一个常见错误,容易遇到错误类型不一致的问题。并且遇到这种错误时完全不知道发生了什么,更不知道怎么解决。

我们继续看错误提示,它说, 操作符利用From trait尝试对错误类型做隐式转换,并列出了几种已经实现了的可以转换到String的错误类型。也就是说,Rust在处理 操作符的时候,会尝试对错误类型进行转换,试着看能不能自动把错误类型转换到函数返回类型中的那个错误类型上去。如果不行,就会报错。你可以参考 第 11 讲,回顾一下如何使用 From<T> trait。我们按照要求对 std::io::Error 实现一下这个转换就好了。

rust
use std::fs::File;
use std::io::Read;

impl From<std::io::Error> for String {
    fn from(err: std::io::Error) -> Self {
        format!("{}", err)
    }
}

fn read_file() -> Result<String, String> {
    let mut file =
        File::open("example.txt")?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;

    Ok(contents)
}

咦,不通过,提示:

rust
error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate
 --> src/lib.rs:4:1
  |
4 | impl From<std::io::Error> for String {
  | ^^^^^--------------------^^^^^------
  | |    |                        |
  | |    |                        `String` is not defined in the current crate
  | |    `std::io::Error` is not defined in the current crate
  | impl doesn't use only types from inside the current crate
  |
  = note: define and implement a trait or new type instead

发现它违反了 第 9 讲 我们说过的trait孤儿规则。怎么解决呢?好办,重新定义一个自己的类型就可以了,你可以看一下修改后的代码。

rust
use std::fs::File;
use std::io::Read;

struct MyError(String);  // 用newtype方法定义了一个新的错误类型

impl From<std::io::Error> for MyError {
    fn from(err: std::io::Error) -> Self {
        MyError(format!("{}", err))
    }
}

fn read_file() -> Result<String, MyError> {
    let mut file =
        File::open("example.txt")?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;

    Ok(contents)
}

这下就对了。

示例里,我们在第4行用newtype模式定义了一个自定义错误类型,里面包了String类型,然后在第6行对它实现 From<std::io::Error>,在第8行产生了错误类型实例。然后在第12行,把 read_file() 的返回类型改成了 Result<String, MyError>

这样就可以了,如果出现打开文件错误或者读取文件错误, 操作符会自动把std::io::Error类型转换到我们的MyError类型上去,并从 read_file() 函数里返回。不再需要我们每次手动写 map_err 转换错误类型了。整个代码结构看上去非常清爽,我们得到了一个非常不错的解决方案。

利用 操作符,我们可以在函数的嵌套调用中实现一种冒泡式的错误向上传递的效果。

错误处理系统最佳实践

有了前面的铺垫,下面我们来讲一下Rust中的错误处理系统最佳实践是什么。

错误的冒泡

通常我们编写的软件有很多依赖,在每个依赖甚至每个模块中,可能都有对应的错误子系统设计。一般会以一个crate为边界暴露出对象的错误类型及可能相关的处理接口。因此,如果我们从依赖树的角度来看,你编写的软件的错误系统也是以树的形式组织起来的,是一个层级系统。

在层级错误系统中,某一层出现的错误有的会在那一层处理,但有的也不一定会在那一层处理掉,而是采用类似冒泡的方式传递到更上层来处理。前面讲到的 ?操作符就是用于编写冒泡错误处理范式的便捷设施。

那么从下层传上来的错误,具体应该在哪个层次进行处理呢?这个问题没有统一的答案,是由具体的软件架构设计决定的。一般来说,一个软件它本身的架构也是在不断演进的,很可能开始的时候,你会在中间某一层给出一个处理方案,但是随着架构演化,可能最后会往上抛,甚至抛到界面层,抛给你的用户来处理。情况千变万化,需要具体问题具体分析。

那么,有没有最佳实践呢?经过Rust社区几年的探索,目前确实有一些实践经验得到了较高的评价,我们这里就来介绍一下。

前面讲过,一个完整的错误系统包括:错误的构造和表示、错误的传递、错误的处理。首先就是在错误的构造和表示上,目前Rust生态中有一个很棒的库: thiserror

错误的表示最佳实践

前面我们讲过,我们定义的错误类型得实现std::error::Error 这个trait,才是一个在生态意义上来讲合格的错误类型。但是要靠自己完整地手动去实现这个Error,需要写不少重复的冗余代码。因为对于一个可靠的应用来说,每一个模块都可能会有其错误类型。

所以一个完整的软件,就会有非常多的错误类型,每次都写同样的样板代码,大家都不会喜欢。于是就出现了这样一个库 thiserror,它能为我们一体化标注式地生成那些样板代码。

使用 thiserror 的方式如下:

rust
use thiserror::Error;    // 引入宏

#[derive(Error, Debug)]  // 这里derive Error宏
pub enum DataStoreError {
    #[error("data store disconnected")]  // 属性标注
    Disconnect(#[from] io::Error),       // 属性标注
    #[error("the data for key `{0}` is not available")]
    Redaction(String),
    #[error("invalid header (expected {expected:?}, found {found:?})")]
    InvalidHeader {
        expected: String,
        found: String,
    },
    #[error("unknown data store error")]
    Unknown,
}

利用thiserror,我们可以直接在枚举上drive Error宏。这就方便得多了。在这一个大宏的下面,还可以利用 #[error("")]#[from] 等属性宏对枚举的变体做更多的配置。

通过这样的标注,我们把目标类型转换成了一个合格的被Rust生态认识的错误类型。

错误的传递最佳实践

前面我们已经多次提到过,在Rust中使用 操作符就能方便地进行错误的冒泡传递。不过需要注意的是, 返回的错误类型可能与函数返回值定义的错误类型不一样,遇到这种情况,就要手动做 map_err,手动实现 From<T> trait,或者利用thiserror 里提供的 #[from] 属性宏标注。

错误处理最佳实践

错误处理指的是要对传过来的错误进行处理。Rust生态中有一个anyhow crate,非常好用。

anyhow这个crate,提供了一套方便的功能,让我们可以快速(无脑)地接收和处理错误。你可以统一使用 Result<T, anyhow::Error> 或等价的 anyhow::Result<T> 作为一个函数的返回类型,担当错误的接收者。

这意味着什么呢?以前你需要自己定义一个模块级的Result,才能简写 std::result::Result。模块层级多了后,光维护这些Result类型,都是一件头痛的事情。

rust
struct MyError;
type Result<String> = std::result::Result<String, MyError>;

现在你不需要自定义一个 Result type了。直接使用 anyhow::Result<T> 就可以。

rust
fn foo() -> anyhow::Result<String> {}

这有什么好处呢?实际是又在一个更高的层次上定义了一种错误接收协议——你写的任何模块,都可以用这同一种定义,而不需要在不同的模块中定义不同的Result类型。不同的人也不需要定义各自的Result类型,大家都一样的,使用anyhow::Result就行了,这样交流起来就更方便。

使用 anyhow::Result<T> 作函数返回值,你在函数中可以使用 ?操作符来把错误向上传递,只要这个错误类型实现了std::error::Error 这个 trait 就行了。而我们前面讲过,这个trait是std标准库中的一个标准类型,如果你想让自己的错误类型融入社区,都应该实现这个trait。而前面的thiserror也是方便实现这个trait的一个工具库。这样是不是一下子就串起来了。std、anyhow 和 thiseror 可以无缝配合起来使用。

这样就产生了一个什么样的效果呢?你不用再为错误类型的不一致,也就是向上传递的错误类型与函数返回值的错误类型不一致,而头痛了。所以我们可以无脑写出下面的代码:

rust
use anyhow::Result;

fn get_cluster_info() -> Result<ClusterMap> {
    let config = std::fs::read_to_string("cluster.json")?;
    let map: ClusterMap = serde_json::from_str(&config)?;
    Ok(map)
}

注意,上面第4行返回的错误类型和第5行返回的错误类型是不同的,但是都能无脑地扔给anyhow::Result,因为它们都实现了 std::error::Error trait。

当你使用 anyhow::Result<T> 接收到错误实例之后,下一步就是处理错误。可以使用 match 结合downcast 系列函数进行处理。

rust
match root_cause.downcast_ref::<DataStoreError>() {
    Some(DataStoreError::Censored(_)) => Ok(..),
    None => Err(error),
}

因为 anyhow::Result<T> 定义的是统一的错误类型接收载体,所以在实际处理的时候,需要把错误还原成原来的类型,分别进行处理,这也是为什么需要 downcast 的原因,语法就和上面的示例差不多。这里也隐含了一个知识点,就是anyhow::Error其实保留了错误实例的原始类型信息,有了这些信息后面我们才能做正确的错误处理分派。

除此之外,anyhow还提供了几个辅助宏,用于简化错误实例的生成。其中一个是anyhow! 宏,它可以快速地构造出一次性的能被 anyhow::Result<T> 接收的错误。

rust
return Err(anyhow!("Missing attribute: {}", missing));

可以看到,anyhow这个crate直接把Rust的错误处理的体验提升了一个档次,让我们可以以一种统一的形式设计项目的错误处理系统。std、anyhow和thiserror 它们一起构成了Rust语言错误处理的最佳实践。

注:更多anyhow的资料,请查阅 链接

Released under the GPL License.