cover

Rust 中的错误处理

本文的主要内容是简要介绍在 Rust 错误处理相关的内容,以及如何使用 thiserror 和 anyhow 简化错误处理流程。

2024-04-18

在 Rust 中有两类错误

  1. 不可恢复错误:指那些影响程序运行、不可恢复的错误,比如数组越界。对于这种类型的错误,可以使用
    panic!
    宏直接终止程序。
  2. 可恢复错误:指那些不影响应用运行、可以被用户解决的错误,比如“文件不存在”错误。对于这种类型的错误,可以通过
    Result<T, E>
    类型传递错误并交由程序中对应的模块进行处理;
heading

错误传播(Propagating Errors)机制

Rust 中的错误传播(Propagating Errors)指的是在错误发生后不在函数内部进行处理,而是将错误直接返回给函数调用方,比如在下面的代码中,在处理

username_file_result
的时候并没有直接使用
panic!
终止程序,而是将其作为
Err
返回:

use std::fs::File; use std::io::{self, Read}; fn read_username_from_file() -> Result<String, io::Error> { 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), } }
heading

使用
Result<T, E>
处理可恢复错误

在 Rust 中可以

Result<T, E>
处理可恢复错误,
Result<T, E>
是一个枚举类型,因此可以使用
match
if let
等语句进行处理:

// `T` 为执行成功的返回的结果的类型,`E` 为错误的类型 enum Result<T, E> { Ok(T), Err(E), }

使用

Result<T, E>
处理错误的常见用法如下:

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), }; }

除了直接使用分支匹配进行处理外,还可以使用下面的方法在处理前先对结果进行转换:

  • map(fn)
    方法:使用闭包
    fn
    Ok
    值进行映射;
  • map_err(fn)
    方法:使用闭包
    fn
    Err
    值进行映射;
  • map_or(default, fn)
    方法:如果结果为
    Err
    则返回
    default
    值,否则使用闭包
    fn
    Ok
    值进行映射;
  • map_or_else(default, fn)
    方法:如果结果为
    Err
    则使用闭包
    default
    获取一个默认值,否则使用闭包
    fn
    Ok
    值进行映射;
  • and(res)
    :如果结果为
    Ok
    则返回
    res
    (也是一个
    Result
    类型的实例),否则返回
    Err
  • and_then(op)
    方法:如果结果为
    Ok
    则调用
    op
    方法并返回另一个
    Result
    ,否则返回
    Err
heading

unwrap
expect
?
的区别

当一个方法返回

Result<T, E>
枚举时,除了使用分支匹配(match)处理外,还可以使用
unwrap
expect
?
来理简化处理流程,这三者的区别在与:

  • unwrap
    expect
    在遇到
    Err
    分支时会触发 panic 终止程序,而
    ?
    操作符会自动进行错误传播,将
    Err
    返回给方法调用者。
  • expect
    除了终止程序外,还能够手动添加更多的信息,因此在生产环境中更常使用
    expect
    而非
    unwrap
heading

在 Rust 中自定义错误类型

使用

Err
返回错误实例时,可以使用任意类型的值,因此在项目中自定义错误类型的方法多种多样(比如定义 struct 或定义 Enum),但一般而言,在 Rust 中自定义错误类型时都需要实现两个 trait:

  1. fmt::Display
    trait:用于错误信息的展示;
  2. std::error::Error
    trait:让自定义错误类型和 Rust 标准错误类型兼容。
use std::error::Error; use std::fmt; #[derive(Debug)] pub struct MyError { message: String, } impl fmt::Display for MyError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "Some error occurred: {}", self.message) } } impl Error for MyError { fn description(&self) -> &str { &self.message } } fn bad_function() -> Result<(), MyError> { Err(MyError { message: "error".to_string(), }) } fn main() { match bad_function() { Ok(_) => (), Err(e) => println!("{}", e.to_string()) } }
src/main.rs

上面这段代码遵循了标准做法实现了

MyError
错误,这样做的好处是:

  1. 实现
    std::error::Error
    trait 中的
    source()
    方法可以实现错误链接(Error Chaining),便于调试;
  2. 实现
    std::error::Error
    trait 能够和系统库、第三方库保持兼容;
  3. 实现
    fmt::Display
    trait 能够提供更加表义的错误信息。

比如在这个例子中,如果自定义错误类型

EmptyVec
没有实现
std::error::Error
trait,并且
double_first
方法中出现其他类型的
Error
,则需要手动转换错误类型,而不能直接使用
Box<dyn Error>
来标识
Err
类型。

在实际项目中,手动实现所有错误类型的过程很繁琐,在 Rust 生态中更常使用

库来简化这一过程。

heading

使用
thiserror
简化错误类型定义

thiserror
提供了三个宏用于简化错误类型的定义过程,这三个宏及其作用为:

  1. #[error("...")]
    :为错误类型提供描述信息字符串模版,在模版中可以使用
    {0}
    {1}
    等占位符插入变体的数据。
  2. #[from] std::io::Error
    :自动实现
    From
    trait,比如在上面的代码,
    std::io::Error
    能够自动转换为
    MyError::IoError
  3. #[error(transparent)]
    :直接转发底层错误的源和
    Display
    方法,常用于定义“其他”错误。

使用

thiserror
后上面的例子可以使用如下的方式重写:

// cargo add thiserror use thiserror::Error; #[derive(Error, Debug)] enum MyError { #[error("Some error occurred: {0}")] SomeError(String), } fn bad_function() -> Result<(), MyError> { Err(MyError::SomeError("error".to_string())) } fn main() { match bad_function() { Ok(_) => (), Err(e) => println!("{}", e.to_string()) } }
error.rs
heading

使用 anyhow 简化错误传播过程

如前所述,错误传播指的是将错误返回给调用方,由调用方对错误进行处理。在处理错误传播的过程中,虽然能够使用

?
操作符简化处理流程,但是依旧需要考虑
Result<T, E>
中的
E
的类型,比如在下面的这段代码中:

fn read_age() -> Result<u32, String> { let mut age = String::new(); println!("Input Your Age: "); std::io::stdin() .read_line(&mut age) .map_err(|_| "Failed to read age")?; Ok(age .trim() .parse() .map_err(|_: std::num::ParseIntError| "Failed to parse age")?) } fn check_age(age: u32, min: u32, max: u32) -> Result<(), String> { if age < min { return Err("Too young".to_string()); } if age > max { return Err("Too old".to_string()); } Ok(()) } fn get_age_between(min: u32, max: u32) -> Result<u32, String> { let age = read_age()?; check_age(min, max)?; Ok(age) } fn main() { match get_age_between(18, 60) { Ok(a) => println!("Age: {}", a), Err(e) => println!("Error: {}", e.to_string()), }; }
main.rs

为了将

Err
返回给调用方,需要调用
map_err
方法将不兼容的错误类型手动转换,如果需要保留相关的错误信息,则需要更多的工作步骤(上面的例子中只是将错误统一为
String
返回,并不包含相关的 BACKTRACE 信息)。针对这种情况,可以使用
anyhow
简化这一过程。

anyhow
中提供了
anyhow!
宏和
with_context
方法,其中:

  • anyhow!()
    宏能够捕获错误的来源,并保留内部错误的上下文,使用上下文信息及用户指定的参数构造一个
    anyhow::Error
    实例;
  • with_context
    方法可以为错误构造上下文,提供更多的信息。

使用

anyhow
重写上面代码:

// cargo add anyhow use anyhow::{anyhow, Result}; fn read_age() -> Result<u32> { let mut age = String::new(); println!("Input Your Age: "); std::io::stdin() .read_line(&mut age) .with_context(|| "Failed to read age {}")?; Ok(age.trim().parse().with_context(|| "Failed to parse age")?) } fn check_age(age: u32, min: u32, max: u32) -> Result<()> { if age < min { return Err(anyhow!("Too young".to_string())); } if age > max { return Err(anyhow!("Too old".to_string())); } Ok(()) } fn get_age_between(min: u32, max: u32) -> Result<u32> { let age = read_age()?; check_age(age, min, max)?; Ok(age) }
main.rs

重写后的主要区别在于:

  1. 使用
    anyhow::Result<T>
    替代默认
    Result<T, E>
    ,不再需要指定错误类型,所有的错误类型统一为
    anyhow::Error
    ,传播过程中的转换由
    anyhow
    完成;
  2. 对于标准库方法(
    std::io::stdin
    parse
    )使用
    with_context
    提供上下文并生成
    anyhow::Error
  3. 使用
    anyhow::anyhow!
    宏生产
    anyhow::Error

需要注意的是,

anyhow::Error
无法在编译期静态地确定,因此不太适用于需要静态类型检查的场景。

heading

结合 thiserror 和 anyhow 进行错误处理

上述例子直接使用

anyhow
来定义所有的错误,但在实际使用中,对于业务相关的错误最好还是自定义错误类型以便在代码中更清晰地表达业务含义。

下面这段代码中结合

thiserror
anyhow
实现了更加通用的错误处理:

use anyhow::Result; use thiserror::Error; #[derive(Error, Debug)] enum AgeError { #[error("Failed to parse: {0}")] ParseError(#[from] std::num::ParseIntError), #[error("Too young")] TooYoung, #[error("Too old")] TooOld, #[error(transparent)] Other(#[from] anyhow::Error), } fn read_age() -> Result<u32> { let mut age = String::new(); println!("Input Your Age: "); std::io::stdin() .read_line(&mut age) .map_err(|e| AgeError::Other(e.into()))?; Ok(age.trim().parse().map_err(AgeError::ParseError)?) } fn check_age(age: u32, min: u32, max: u32) -> Result<()> { if age < min { return Err(AgeError::TooYoung.into()); } if age > max { return Err(AgeError::TooOld.into()); } Ok(()) } fn get_age_between(min: u32, max: u32) -> Result<u32> { let age = read_age()?; check_age(age, min, max)?; Ok(age) } fn main() { match get_age_between(18, 60) { Ok(a) => println!("Age: {}", a), Err(e) => print!("Error: {}", e.to_string()), }; }
main.rs