Skip to content

哈喽,大家好呀,我是呼噜噜,今天我们来聊聊Rust错误处理,这个也是Rust陡峭学习曲线中的一座大山,让笔者给你讲解一下什么是Rust错误处理

JavaPythonC++ 使用异常Exceptions(引入了try-catch 机制),不同的是,Rust错误视为,并且强制开发者显式地处理它们。

也就是说:如果你可能失败,你就必须在类型系统中显式地声明这一点,并且调用者必须显式地处理它

Rust错误类型

Rust中,错误处理的设计目标是兼顾性能安全,所以错误被分为2大类:

  1. 不可恢复错误

指程序发生了严重的、致命的问题程序。比如:访问数组越界、解引用空指针、递归时栈溢出、使用unwrap()expect()NoneErr等等

当程序发生了上述的情况,rust就会触发panicpanicRust 用来处理不可恢复错误的一种机制,触发后,会导致程序立即停止执行,并展开调用栈,最终终止程序

panic除了被动触发,还可以手动触发,Rust 标准库提供了 panic! 宏,我们就可以利用它来手动触发

我们来看个小例子:

rust
fn main() {
    panic!("出错啦!这是一个 panic 示例。");
}

程序运行到这里会直接终止,并打印错误信息和调用栈。

另外了解一下.unwrap().expect(msg),如果发生错误,它们也是返回panic!,区别是expect允许你自定义 panic 的错误信息

在生产环境代码中,我们都应尽量避免被动触发或者手动触发 panic,这次的Cloudflare全球宕机事件就是血泪教训!

  1. 可恢复错误

指程序遇到问题时,但可以通过某种方式处理错误,允许程序继续运行。比如:文件未找到、网络超时、解析输入失败、权限不足等等

一般情况下,我们推荐使用 ResultOption 进行可恢复错误处理,而不是panic。故接下来我们重点来讲解可恢复的错误处理

Result 与 Option

我们还必须知道,Rust是没有 null的,也没有 Exception的。它通过自身的类型系统,来强制我们在编译阶段就得显式地处理所有可能的情况

这样可以从根源上消除了空指针异常并让错误处理兼顾性能与安全

虽然这样写起代码,起初会觉得比较繁琐,但能极大地提高代码的健壮性,另外你可别担心,当熟练🦀后,掌握各种丰富且友好的语法糖,就能极大地提升你的开发效率。

Option

Option<T> 是一个表示 可能有值Some(T) 或 没有值None 的枚举类型:

rust
enum Option<T> {
    Some(T), // 包含一个值 T
    None,    // 什么都没有
}

它可以解决 "Null Pointer Exception"(空指针异常),这价值"十亿美元"的错误

常用方法:map, and_then(或 flat_map),unwrap_or, unwrap_or_elseok_or

我们来看一个例子:

rust
fn find_even(numbers: &[i32]) -> Option<i32> {
    for &n in numbers {
        if n % 2 == 0 {
            return Some(n);
        }
    }
    None
}

let nums = vec![1, 3, 5];
let result = find_even(&nums);

let nums2 = vec![1, 2, 3];
let result2 = find_even(&nums2);

上述,resultNone,而result2Some(2)

Result<T, E>

Result 用于表示 可能成功或失败 的操作。它定义如下:

plain
enum Result<T, E> {
    Ok(T),  // 成功,包含成功的值 T
    Err(E), // 失败,包含错误的类型 E
}

它是 Rust 处理可恢复错误的核心类型。E 通常实现 std::error::Error trait,用于错误链与显示。

我们再来看一个例子:

rust
use std::fs::File;

fn open_file(path: &str) -> Result<File, std::io::Error> {
    File::open(path)
}

let file = open_file("hello.txt");

上述代码,如果文件存在,返回 Ok(File),如果文件不存在,则返回 Err(std::io::Error)

用match来处理Result和Option

Rust中,我们可以用match模式匹配来处理ResultOption

rust
match result2 {
    Some(val) => println!("找到偶数: {}", val),
    None => println!("没有偶数"),
}


match file {
    Ok(f) => println!("文件打开成功!"),
    Err(e) => println!("文件打开失败: {}", e),
}

虽然 match 很安全,但在实际业务中,我们往往需要连续执行多个可能出错的操作。如果每一步都用 match,代码会变成“右箭头型”的嵌套地狱。

我们来看看下方的这个例子:

rust
fn get_reciprocal(input: &str) -> Result<f64, String> {
  //match 嵌套
  match input.parse::<f64>() {
      Ok(num) => {
          match safe_divide(1.0, num) {
              Ok(res) => Ok(res),
              Err(e) => Err(e),
          }
      },
      Err(_) => Err(String::from("解析数字失败")),
  }
}

fn safe_divide(dividend: f64, divisor: f64) -> Result<f64, String> {
  if divisor == 0.0 {
      return Err(String::from("除数不能为零"));
  }
  Ok(dividend / divisor)
}

fn main() {
  let inputs = vec!["2.0", "0.0", "abc"];
  for s in inputs {
      println!("{:?} -> {:?}", s, get_reciprocal(s));
  }
}

上述代码,主要是判断值是否可以进行倒数运算。整体逻辑是非常简单的,但是代码已经很繁琐了,特别是里面套了2层match

那我们改如何优化呢?

使用组合子来优化我们的错误处理

组合子Combinators的概念在函数式编程中很常见,Rust吸收了很多函数式编程的理念,为 ResultOption 提供了如 map, and_then, unwrap_or 等方法。

rust
fn get_reciprocal(input: &str) -> Result<f64, String> {
// 使用组合子
  input.parse::<f64>()
      .map_err(|_| String::from("解析数字失败")) 
      .and_then(|num| safe_divide(1.0, num)) 
}

fn safe_divide(dividend: f64, divisor: f64) -> Result<f64, String> {
  if divisor == 0.0 {
      return Err(String::from("除数不能为零"));
  }
  Ok(dividend / divisor)
}

fn main() {
  let inputs = vec!["2.0", "0.0", "abc"];
  for s in inputs {
      println!("{:?} -> {:?}", s, get_reciprocal(s));
  }
}

结果:

rust
"2.0" -> Ok(0.5)
"0.0" -> Err("除数不能为零")
"abc" -> Err("解析数字失败")

这样我们将错误处理逻辑链式调用,消除了嵌套,代码也更加简洁了

这个时候,有读者朋友就会说了,俺不喜欢链式写法,感觉不够直观,还有什么好办法?


有的兄弟有的Rust还给我们提供?,且让我下文慢慢道来

错误传播:? 运算符

Rust2018 引入了 ? 操作符,它是语法糖,也是 Rust 错误处理最优雅的设计之一。

简单来说?实现了错误传播。它常用于简化 Result、Option 的错误处理,让我们的代码更清晰、更简洁

在实际开发中,我们通常不在当前函数处理错误,而是通过?将其返回给调用者。

如果结果是 Ok,它提取值;如果是 Err,它会直接把错误转换成当前函数的返回类型return Err(...)

我们来看下?的使用规则:

rust
// 使用 ?
let x = foo()?;

// 等价为
let x = match foo() {
    Ok(v) => v,
    Err(e) => return Err(From::from(e)),
};

? 它也可以用于 Option(在返回 Option 的函数中)——将 None 转为 return None

我们通过?来优化一下,上面倒数运算的例子:

rust
fn get_reciprocal(input: &str) -> Result<f64, String> {
    let num = input.parse::<f64>()
        .map_err(|_| String::from("解析数字失败"))?; //使用 ? 提前返回或解包

    //进行除法运算,直接返回结果 (返回的也是 Result)
    safe_divide(1.0, num)
}

fn safe_divide(dividend: f64, divisor: f64) -> Result<f64, String> {
    if divisor == 0.0 {
        return Err(String::from("除数不能为零"));
    }
    Ok(dividend / divisor)
}

fn main() {
    let inputs = vec!["2.0", "0.0", "abc"];
    for s in inputs {
        println!("{:?} -> {:?}", s, get_reciprocal(s));
    }
}

我们来看下这段代码,原本的 and_then 被移除,转而我们使用 ? 放在 parse() 调用链的末尾。

如果解析成功,num 会直接获得 f64 类型的值;如果失败,它会立即从函数返回 Err

另外代码流向发生了改变:从函数式的链条传导执行,变成了自上而下执行。这样更加清晰更加直观

到这里有眼尖的朋友,就会问了"为什么map_err还保留",不能用?符号吗

因为换不掉啊,我们必须得明白? 不仅仅是 return Err。它还会自动进行类型转换!但它要求错误类型必须能够通过 From trait 转换

这里input.parse::<f64>() 返回的错误类型是 ParseFloatError,而函数签名要求返回 String。只要错误类型 String 实现了 From<ParseFloatError> trait,? 就能把 ParseFloatError 转换成 String。我们需要记住这个规则,非常的重要

由于标准库没有实现从 ParseFloatErrorString 的自动转换,所以很可惜, 我们这里需要保留 map_err手动转换错误信

自定义错误类型与错误转换

虽然标准库没有实现ParseFloatErrorString 的自动转换,但是我们完全可以自定义错误类型

我们这里,自定义一个错误类型 MyError,只需再实现From 转换,那我们就可以用 ? 自动把 parse 错误转成 MyError

我们这里再优化一下倒数运算的例子:

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

// 自定义错误类型 MyError
#[derive(Debug)]
pub enum MyError {
    /// 输入解析成数字失败
    ParseError(String),

    /// 除数为零
    DivideByZero,
}

//为 MyError 实现 Display(错误信息)
impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            MyError::ParseError(input) => write!(f, "解析数字失败: {}", input),
            MyError::DivideByZero => write!(f, "除数不能为零"),
        }
    }
}

//为 MyError 实现 std::error::Error
impl Error for MyError {}

// From 实现:把 ParseFloatError 自动转成 MyError
// 这样 parse::<f64>()? 就能直接返回 MyError
impl From<std::num::ParseFloatError> for MyError {
    fn from(_err: std::num::ParseFloatError) -> Self {
        // 我们无法从 ParseFloatError 中获取原始输入
        // 所以只能给个通用错误
        MyError::ParseError("输入不是有效数字".into())
    }
}

//别名
type Result<T> = std::result::Result<T, MyError>;

fn get_reciprocal(input: &str) -> Result<f64> {
    // parse()? 自动把 ParseFloatError 转换为 MyError
    let num: f64 = input.parse::<f64>()?;

    // 使用安全除法
    safe_divide(1.0, num)
}

fn safe_divide(dividend: f64, divisor: f64) -> Result<f64> {
    if divisor == 0.0 {
        return Err(MyError::DivideByZero);
    }
    Ok(dividend / divisor)
}

fn main() {
    let inputs = vec!["2.0", "0.0", "abc"];

    for s in inputs {
        println!("{:?} -> {:?}", s, get_reciprocal(s));
    }
}

结果:

rust
"2.0" -> Ok(0.5)
"0.0" -> Err(DivideByZero)
"abc" -> Err(ParseError("输入不是有效数字"))

我们这里还实现了Display, 为了让错误信息可读

但感觉这样是不是又繁琐起来了,上面我们的写法,那是非常的标准且严谨,但是我们不得不写impl Display, impl From这样的样板代码

别担心,现在Rust 社区有两个非常牛逼的库thiserroranyhow,能极大地帮我们简化错误处理,目前也事实上成为了Rust错误处理标准

现代生态系统:thiserror 与 anyhow

  1. thiserror,用于定义结构良好的错误枚举

它通过派生宏帮我们自动生成 Display, From, Error trait 的实现,比较适用于库/Library 开发

rust
use thiserror::Error;

#[derive(Error, Debug)]
pub enum DataStoreError {
    #[error("文件读取失败")] // 自动实现 Display
    Io(#[from] std::io::Error), // 自动实现 From<io::Error>
    
    #[error("无效的数据格式")]
    InvalidFormat,
    
    #[error("未知错误: {0}")]
    Unknown(String),
}
  1. anyhow 提供动态错误类型 anyhow::Error,方便捕获任意错误并附加上下文。适合于应用开发
rust
use anyhow::{Context, Result};

// 注意这里用的是 anyhow::Result ,不是标准库的Result
fn main_logic() -> Result<()> {
    let path = "config.json";
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("尝试读取配置文件 {} 失败", path))?; 
    println!("Content: {}", content);
    Ok(())
}

我们再再再来优化下倒数运算的例子,先引入这2个库:

rust
[dependencies]
anyhow = "1.0.98" # 错误处理
thiserror = "2.0.12" # 错误处理

接着修改main.rs:

rust
use thiserror::Error;
use anyhow::{Result, Context};

/// 使用 thiserror 定义业务错误
#[derive(Debug, Error)]
pub enum MyError {
    /// 输入解析失败,保留原始输入
    #[error("解析数字失败: {0}")]
    ParseError(String),

    /// 除数为零
    #[error("除数不能为零")]
    DivideByZero,
}

/// 可逆计算
fn get_reciprocal(input: &str) -> Result<f64> {
    // parse 时无法从 ParseFloatError 获取具体内容,因此手动处理
    let num: f64 = input
        .parse::<f64>()
        .map_err(|_| MyError::ParseError(input.to_string()))
        .context("数字解析失败")?;

    safe_divide(1.0, num).context("计算倒数失败")
}

/// 安全除法
fn safe_divide(dividend: f64, divisor: f64) -> Result<f64> {
    if divisor == 0.0 {
        return Err(MyError::DivideByZero.into());
    }
    Ok(dividend / divisor)
}

fn main() -> Result<()> {
    let inputs = vec!["2.0", "0.0", "abc"];

    for s in inputs {
        match get_reciprocal(s) {
            Ok(v) => println!("{:?} -> Ok({})", s, v),
            Err(e) => println!("{:?} -> Err(\"{}\")", s, e),
        }
    }

    Ok(())
}

执行结果是:

rust
"2.0" -> Ok(0.5)
"0.0" -> Err("计算倒数失败")
"abc" -> Err("数字解析失败")

我们通过 thiserror来自动实现 Display + Errormain函数使用 anyhow::Result<()>,这样我们就可以用用 ? 轻松返回任何错误

可以说,? + anyhow + thiserror 就是目前Rust中错误处理最佳实践

关注我的公众号【小牛呼噜噜】,获取更多干货,下期我们再见!

扫码到公众号中,回复关键字:Rust错误处理,即可获取本文完整代码示例和练习项目

作者:小牛呼噜噜

本文到这里就结束啦,感谢阅读,关注同名公众号:小牛呼噜噜,防失联+获取更多技术干货 ::: -->