Skip to content

panic 与 Result Err

前面基础篇的错误处理章节已经学过 panic!Result 的基本用法, 这一篇专题主要深入讨论: 什么时候该用 panic!, 什么时候该用 Result, 以及实际项目中常用的错误处理库(anyhowthiserror)

回顾: Rust 没有异常(exception)的概念, 错误只分为两大类

  1. 可恢复的错误(recoverable): 比如文件不存在, 网络超时, 用 Result 处理
  2. 不可恢复的错误(unrecoverable): 比如数组越界, 除零, 用 panic! 处理

panic 与 Result 的对比

对比项panic!Result
错误类型不可恢复错误可恢复错误
程序行为程序立即终止(打印错误信息后退出)把错误返回给调用者, 由调用者决定怎么处理
适用场景示例代码/原型/测试/编译器保证不可能出错的情况函数返回值, 一切可能失败的 I/O 操作
能否被捕获默认不能(会展开调用栈, 见下方 panic 的行为配置)可以, 调用者用 match 分支处理
返回值无(类型是 !, 永不返回)Ok(T)Err(E)

什么时候用 panic

panic! 的使用场景是: 程序已经没有继续执行下去的意义了, 直接终止

rust
fn main() {
    // 快速验证逻辑, 暂时不想处理错误
    // 比如学习阶段, 或者写一次性脚本
    let num: u8 = "123".parse().unwrap();
    println!("num = {num}");
}
rust
#[test]
fn test_parse() {
    // 测试失败就应该 panic, 否则测试无法发现 bug
    let num: u8 = "123".parse().unwrap();
    assert_eq!(num, 123);
}
rust
fn main() {
    let arr = [1, 2, 3];

    // 明确知道数组不为空, 所以这里不可能返回 None
    // 此时用 unwrap 是安全的
    let first = arr.first().unwrap();
    println!("{first}");
}

注意点

unwrapexpect 只是 match 的简写, 遇到 Err 时都会触发 panic!, 在正式代码中不要随便使用, 一旦出错程序就崩溃了

  • unwrap(): panic 时输出默认错误信息
  • expect("自定义消息"): panic 时输出自定义消息, 方便排查问题, 优先使用 expect

什么时候用 Result

只要错误是可预料的(文件不存在, 网络断开, 用户输入不合法...), 都应该返回 Result, 把错误交给调用者处理, 而不是直接 panic

rust
use std::fs::File;

fn main() {
    // 文件可能不存在, 这是一个可恢复的错误
    // 如果直接 unwrap, 程序会崩溃, 这是不合理的
    // 应该把错误交给调用者处理
    let file = match File::open("config.txt") {
        Ok(f) => f,
        Err(e) => {
            eprintln!("打开配置文件失败: {e}");
            // 可以在这里降级处理, 比如使用默认配置
            return;
        }
    };
}

Result 的常用方法

Result 提供了大量组合子方法, 避免手写 match, 常用如下:

方法作用
unwrapOk 取出值, Err 时 panic
expect同 unwrap, 但是可以自定义 panic 消息
unwrap_orErr 时返回默认值
unwrap_or_elseErr 时执行闭包, 用闭包的返回值
unwrap_or_defaultErr 时返回类型的默认值
map只对 Ok 的值做转换
map_err只对 Err 的值做转换
and_thenOk 时继续执行一个返回 Result 的闭包(链式调用)
or_elseErr 时执行一个返回 Result 的闭包
is_ok / is_err判断当前是 Ok 还是 Err, 返回 bool
rust
fn main() {
    let ok: Result<i32, &str> = Ok(10);
    let err: Result<i32, &str> = Err("出错了");

    // unwrap: Ok 取出值, Err 直接 panic
    println!("{}", ok.unwrap()); // 10

    // unwrap_or: Err 时使用默认值
    println!("{}", err.unwrap_or(0)); // 0

    // unwrap_or_else: Err 时执行闭包
    println!("{}", err.unwrap_or_else(|e| {
        eprintln!("错误信息: {e}");
        -1
    })); // -1

    // unwrap_or_default: Err 时返回类型的默认值
    println!("{}", err.unwrap_or_default()); // 0

    // map: 只对 Ok 的值做转换, Err 原样返回
    let double = ok.map(|x| x * 2);
    println!("{double:?}"); // Ok(20)

    // map_err: 只对 Err 的值做转换, Ok 原样返回
    let mapped = err.map_err(|e| format!("自定义错误: {e}"));
    println!("{mapped:?}"); // Err("自定义错误: 出错了")

    // and_then: Ok 时继续执行闭包(闭包也要返回 Result)
    let result = ok.and_then(|x| Ok(x + 5));
    println!("{result:?}"); // Ok(15)

    // or_else: Err 时执行闭包, 可以"挽救"错误
    // 注意: 闭包需要返回完整类型的 Result, 所以要标注类型
    let result: Result<i32, &str> = err.or_else(|_e| Ok(100));
    println!("{result:?}"); // Ok(100)

    // is_ok / is_err
    println!("{} {}", ok.is_ok(), ok.is_err()); // true false
}

panic 的行为配置

panic! 默认会展开调用栈(unwind), 逐层执行清理逻辑(释放资源)后再退出, 也可以通过配置改为直接中止(abort), 不执行任何清理, 减小程序体积

toml
# Cargo.toml
[profile.release]
panic = "abort" # 发布版本直接终止, 不展开调用栈(程序体积更小)

调试 panic 时可以设置环境变量 RUST_BACKTRACE 打印调用栈:

sh
RUST_BACKTRACE=1 cargo run

# 输出:
# thread 'main' panicked at src/main.rs:4:5:
# 出错了
# stack backtrace:
#    0: rust_begin_unwind
#    ...

Result 与错误传播

? 运算符的原理

? 运算符是错误传播的语法糖, 它的完整写法其实就是一段 match:

rust
// 定义一个可能失败的函数
fn div(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        return Err("除数不能为 0".to_string());
    }
    Ok(a / b)
}

fn calc() -> Result<i32, String> {
    // Ok 时取出值, Err 时直接 return 给调用者
    let x = div(10, 2)?;
    let y = div(x, 5)?;
    Ok(x + y)
}

fn main() {
    println!("{:?}", calc()); // Ok(6)
}
rust
fn calc() -> Result<i32, String> {
    // div(10, 2)? 等价于下面这段 match
    let x = match div(10, 2) {
        Ok(v) => v,
        Err(e) => return Err(e), // 错误直接返回, 后面的代码不执行
    };

    let y = match div(x, 5) {
        Ok(v) => v,
        Err(e) => return Err(e),
    };
    Ok(x + y)
}

错误类型的自动转换

? 在返回错误时, 会自动调用 From::from 做类型转换, 所以只要实现了 From<原始错误类型>, 就能把不同来源的错误统一转换成自己的错误类型

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

// 自定义错误类型
#[derive(Debug)]
enum MyError {
    Io(std::io::Error),                        // IO 错误
    Utf8(std::string::FromUtf8Error),          // 编码错误
}

// 实现 From, 让 ? 能把 io::Error 自动转成 MyError::Io
impl From<std::io::Error> for MyError {
    fn from(e: std::io::Error) -> Self {
        MyError::Io(e)
    }
}

// 实现 From, 让 ? 能把 FromUtf8Error 自动转成 MyError::Utf8
impl From<std::string::FromUtf8Error> for MyError {
    fn from(e: std::string::FromUtf8Error) -> Self {
        MyError::Utf8(e)
    }
}

fn read_file(path: &str) -> Result<String, MyError> {
    let mut file = File::open(path)?;              // io::Error -> MyError::Io
    let mut buf = Vec::new();
    file.read_to_end(&mut buf)?;                   // io::Error -> MyError::Io
    let content = String::from_utf8(buf)?;         // FromUtf8Error -> MyError::Utf8
    Ok(content)
}

fn main() {
    match read_file("a.txt") {
        Ok(content) => println!("{content}"),
        Err(e) => eprintln!("读取失败: {e:?}"),
    }
}

这个手动实现 From 的过程, 后面学的 thiserror 可以用一行 #[from] 自动完成

main 函数返回 Result

main 函数也可以返回 Result, 此时如果返回 Err, 程序会自动打印错误信息并以非 0 状态码退出, 适合命令行程序的顶层入口

rust
use std::error::Error;

// Box<dyn Error>: 不知道(也不关心)错误的具体类型, 直接向上抛
fn main() -> Result<(), Box<dyn Error>> {
    // 整个 main 里可以放心大胆的用 ? 了
    let content = std::fs::read_to_string("config.txt")?;
    println!("{content}");
    Ok(())
}

注意点

Box<dyn Error> 只能当作"万能错误"向上抛, 调用者拿不到具体的错误类型, 所以只适合在 main 这种顶层使用; 库代码必须返回具体的错误类型, 方便调用者分类处理

Option 与 Result 互转

两种类型经常需要互相转换: 忘记处理错误把 Result 丢成 Option, 或者反过来给 Option 补上错误信息

方法作用
Result::okResult<T, E> -> Option<T> (丢弃错误)
Result::errResult<T, E> -> Option<E> (丢弃值)
Option::ok_orOption<T> -> Result<T, E> (None 时使用传入的错误)
Option::ok_or_else同 ok_or, 但是错误由闭包延迟生成
rust
fn main() -> Result<(), &'static str> {
    // Result -> Option: 只关心有没有值, 不关心错误
    let num = "42".parse::<i32>().ok();
    println!("{num:?}"); // Some(42)

    // Option -> Result: 给 None 补上错误信息
    // ok_or 的参数是立即求值的, ok_or_else 是延迟求值的(性能更好)
    let num = "42".parse::<i32>().ok().ok_or("解析失败")?;
    println!("num = {num}"); // 42

    // 解析失败时, ok_or 会把错误返回出去
    let num = "abc".parse::<i32>().ok().ok_or_else(|| "解析失败")?;
    Ok(())
}

使用 anyhow

anyhow 是应用层(二进制程序)最常用的错误处理库, 核心思路是: 不关心错误的具体类型, 只负责传递和展示错误

sh
cargo add anyhow
  • anyhow::Result<T>: 等价于 Result<T, anyhow::Error> 的类型别名
  • anyhow::Error: 一个"万能"错误类型, 任何实现了 std::error::Error 的错误都能自动转换进去
rust
use anyhow::{Result, Context};

// 返回类型直接写 anyhow::Result<T>, 不需要指定具体的错误类型
fn read_user_config() -> Result<String> {
    let content = std::fs::read_to_string("config.json")
        // 给错误附加上下文信息, 方便排查问题
        .with_context(|| "读取配置文件失败")?;
    Ok(content)
}

fn main() -> Result<()> {
    let config = read_user_config()?;
    println!("{config}");
    Ok(())
}

运行时输出错误, 会同时打印上下文和底层错误:

txt
Error: 读取配置文件失败

Caused by:
    No such file or directory (os error 2)

添加上下文 Context

Context trait 给错误"套上一层信息", 常用方法:

方法作用
context给错误添加上下文(参数立即求值)
with_context给错误添加上下文(参数是闭包, 延迟求值, 推荐用于拼接字符串)
rust
use anyhow::{Result, Context};

fn main() -> Result<()> {
    // context: 直接传字符串
    let content = std::fs::read_to_string("a.txt")
        .context("读取文件失败")?;

    // with_context: 传闭包, 只有出错时才执行(字符串拼接更高效)
    let content = std::fs::read_to_string("a.txt")
        .with_context(|| format!("读取文件 {} 失败", "a.txt"))?;

    Ok(())
}

常用宏

作用
anyhow!创建一个 anyhow::Error
bail!直接返回错误(等价于 return Err(anyhow!(...)))
ensure!条件不成立时返回错误(等价于 if + bail!)
rust
use anyhow::{anyhow, bail, ensure, Result};

fn check_age(age: i32) -> Result<()> {
    // ensure!: 条件不满足时直接返回错误
    ensure!(age >= 0, "年龄不能是负数: {age}");

    // bail!: 直接返回错误
    if age < 18 {
        bail!("未成年: {age}");
    }

    // anyhow!: 创建一个 Error 值, 再手动 return
    if age > 150 {
        return Err(anyhow!("年龄过大: {age}"));
    }

    Ok(())
}

fn main() -> Result<()> {
    check_age(-1)?;
    // Error: 年龄不能是负数: -1
    Ok(())
}

注意点

anyhow 只适合应用程序(二进制程序), 不适合库

  • 库需要暴露明确的错误类型, 让调用者能 match 到具体分支去处理
  • anyhow::Error 会丢失错误类型信息, 调用者无法分类处理
  • 库的错误定义, 应该用下面的 thiserror :::

thiserror 使用

thiserror最常用的错误处理库, 核心思路是: 用 #[derive(Error)] 自动给自定义错误类型实现 Displaystd::error::Error

sh
cargo add thiserror

定义错误类型

只需要给枚举(或结构体)加 #[derive(Error)], 再给每个变体加 #[error("...")] 描述消息即可

rust
use thiserror::Error;

// 只需要 derive Error 和 Debug
// thiserror 会自动实现 Display 和 std::error::Error
#[derive(Debug, Error)]
enum UserError {
    // 单元变体: 直接写消息
    #[error("用户名不能为空")]
    EmptyName,

    // 结构体变体: 可以用 {字段名} 引用字段
    #[error("用户 {name} 不存在")]
    NotFound { name: String },

    // 元组变体: {0} 表示第 0 个字段
    #[error("年龄不合法: {0}")]
    InvalidAge(i32),

    // #[from] 会自动生成 From<std::io::Error> 实现
    // 这样 ? 就能把 io::Error 自动转成 UserError::Io
    #[error("IO 错误: {0}")]
    Io(#[from] std::io::Error),

    // 多个 #[from] 也是可以的, 会自动生成多个 From 实现
    #[error("数字解析失败: {0}")]
    Parse(#[from] std::num::ParseIntError),
}

fn read_age_from_file(path: &str) -> Result<u8, UserError> {
    // 文件不存在 -> io::Error -> 自动转换为 UserError::Io
    let content = std::fs::read_to_string(path)?;

    // 内容不是数字 -> ParseIntError -> 自动转换为 UserError::Parse
    let age = content.trim().parse::<u8>()?;
    Ok(age)
}

常用属性

属性作用
#[error("...")]定义 Display 输出格式, 支持 {0}(第 0 个字段) 和 {字段名} 引用字段
#[from]自动实现 From<字段类型>, 配合 ? 使用, 最常见的用法是包一层底层错误
#[source]标记错误来源字段(自动实现 source() 方法), 底层错误会作为"Caused by"打印
#[error(transparent)]直接透传内部错误的 Display 和 source, 不新增消息
rust
use thiserror::Error;

#[derive(Debug, Error)]
enum MyError {
    #[error("业务错误: {0}")]
    Business(String),

    // #[source]: 手动指定错误来源
    // 打印时底层错误会作为 Caused by 展示
    #[error("底层错误")]
    Underlying {
        #[source]
        source: std::io::Error,
    },

    // #[error(transparent)]: 完全透传内部错误
    // Display 和 source 都用 io::Error 的
    #[error(transparent)]
    Transparent(#[from] std::io::Error),
}

使用场景

  • thiserror: 库代码, 定义明确的错误类型, 调用者可以 match 具体分支处理
  • anyhow: 应用程序, 只关心错误能不能被传递和打印

两个可以搭配使用, 见下面的 anyhow 与 thiserror 一起使用

anyhow 与 thiserror 一起使用

实际项目的分层原则: 库用 thiserror 定义错误, 应用用 anyhow 消费错误

  1. 库(被他人调用): 用 thiserror 定义具体的错误类型, 调用者才能分类处理
  2. 应用(二进制程序): 用 anyhow 传递错误, 打印时自动带上所有"Caused by"

为什么能混用? 因为 anyhow::Error 实现了 From<E>(任何实现了 std::error::Error 的类型), 所以库返回的 thiserror 错误, 在应用里直接用 ? 就能自动转换成 anyhow::Error

txt
project/
├── Cargo.toml
├── lib-demo/          # 库 crate: 使用 thiserror
│   ├── Cargo.toml
│   └── src/lib.rs
└── src/main.rs        # 二进制 crate: 使用 anyhow

库层: thiserror 定义错误

toml
# lib-demo/Cargo.toml
[dependencies]
thiserror = "2"
rust
// lib-demo/src/lib.rs
use thiserror::Error;

// 库对外暴露的错误类型, 必须是明确的
#[derive(Debug, Error)]
pub enum DemoError {
    #[error("年龄不合法: {0}")]
    InvalidAge(u8),

    #[error("配置读取失败: {0}")]
    Config(#[from] std::io::Error),
}

// 库的公开函数: 返回自己定义的错误类型
pub fn get_age(config_path: &str) -> Result<u8, DemoError> {
    // io::Error 通过 #[from] 自动转成 DemoError::Config
    let content = std::fs::read_to_string(config_path)?;

    // 解析失败时, 返回 DemoError::InvalidAge
    let age = content
        .trim()
        .parse::<u8>()
        .map_err(|_| DemoError::InvalidAge(0))?;
    Ok(age)
}

应用层: anyhow 消费错误

toml
# Cargo.toml (二进制 crate)
[dependencies]
anyhow = "1"
lib-demo = { path = "./lib-demo" }
rust
// src/main.rs
use anyhow::{Context, Result};

fn main() -> Result<()> {
    // 库返回的 DemoError, 通过 ? 自动转换为 anyhow::Error
    // 因为 anyhow::Error 实现了 From<DemoError>
    let age = lib_demo::get_age("age.txt")?;
    println!("age = {age}");

    // 也可以再添加上下文, 打印时会显示:
    // Error: 获取年龄失败
    //
    // Caused by:
    //     配置读取失败: No such file or directory (os error 2)
    let age = lib_demo::get_age("age.txt").context("获取年龄失败")?;
    println!("age = {age}");

    Ok(())
}

其他常用错误处理库

定位说明
snafu错误定义thiserror 的替代品, 支持自动生成错误上下文(context), 用法更灵活
eyre错误报告anyhow 的替代品, 可以自定义错误报告的处理方式(Report Handler)
color-eyre错误报告eyre 的增强版, 输出带颜色/间距的详细错误报告, 适合开发调试
miette错误报告输出带源码片段/高亮的诊断报告, 类似编译器的错误提示, 适合 CLI 工具
derive_more派生宏除了 Error, 还提供了 Display/From/Add 等大量派生宏, 一个宏全家桶
fehler异常模拟用宏模拟其他语言的 try/catch 写法, 与 Rust 社区风格不符, 已停止维护

如何选择

Released under the MIT License.