前两天有个朋友问我,说他写 Rust 写得快疯了。我问咋了,他说你看看这代码:

fn process_user_input(input: &str) -> Result<User, String> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return Err("Empty input".to_string());
    }

    let user: User = serde_json::from_str(trimmed)
        .map_err(|e| e.to_string())?;

    if user.name.len() > 100 {
        return Err("Name too long".to_string());
    }
    Ok(user)
}

我一看就乐了,这不就是我一年前的样子嘛。到处都是 return Err("xxx".to_string()),那个 .map_err(|e| e.to_string()) 写得人想吐。明明就是个简单的输入校验,愣是写成了填表格。

我跟他说,你试试 bail! 这个宏。他一脸懵,啥玩意儿?

我说你先别问,把代码改成这样:

use anyhow::{bail, Result};

fn process_user_input(input: &str) -> Result<User> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        bail!("Empty input");
    }

    let user: User = serde_json::from_str(trimmed)?;

    if user.name.len() > 100 {
        bail!("Name too long");
    }
    Ok(user)
}
错误处理的混乱 vs 清爽对比

图1: 错误处理,从一团乱麻到清清爽爽

他看了半天,说就这?我说对啊就这。没有 .map_err(...) 了,没有 return Err(...) 了,想报错直接 bail!("错误信息") 完事。输入为空?bail!名字太长?bail!读起来跟说人话似的。

他问这宏里面是啥黑魔法,我说你看:

macro_rules! bail {
    ($e:expr) => {
        return Err($crate::Error::msg($e));
    };
}
bail! 宏的工作原理示意图

图2: bail! 把繁琐的 return Err(…) 打包成一句话

就这么几行,它干的事儿就是帮你把 return Err(...) 包了一层。你可以理解成你家楼下开了个快递驿站,以前寄快递得自己找盒子、写单子、跑快递点,现在直接把东西往驿站一扔说声"寄北京"就完事了。本质没变,但体验完全不一样。

他说行,简单的我看懂了,复杂点的呢?我说那你看这个,解析命令行、读配置、启动服务,一套组合拳:

// 不用 bail! 的写法
fn run() -> Result<(), String> {
    let args = parse_args().map_err(|e| e.to_string())?;
    if !args.config_path.exists() {
        return Err("Config file not found".to_string());
    }
    let config = load_config(&args.config_path)
        .map_err(|e| format!("Failed to load config: {}", e))?;
    if config.threads == 0 {
        return Err("Invalid thread count".to_string());
    }
    start_process(config).map_err(|e| e.to_string())
}

再看用了 bail! 的:

use anyhow::{bail, Result};

fn run() -> Result<()> {
    let args = parse_args()?;
    if !args.config_path.exists() {
        bail!("Config file not found");
    }
    let config = load_config(&args.config_path)?;
    if config.threads == 0 {
        bail!("Invalid thread count");
    }
    start_process(config)?;
    Ok(())
}
使用 bail! 前后的代码流程对比

图3: 流程没变,但分支更干净:出错就 bail!

他看完说,这确实清爽。解析参数、检查文件、加载配置、检查线程数、启动进程,每一步干啥一目了然,出错就 bail,不废话。

然后他问了个我当时也问过的问题:这玩意儿性能咋样?毕竟写 Rust 的谁不是性能控呢。

我说我测过,跑了一万次函数调用,手写 Result<T, String> 平均 123 纳秒,用 anyhow::Resultbail! 是 125 纳秒。内存的话一个 24 字节一个 32 字节。

手写 Result 与 anyhow+bail 的性能对比

图4: 性能差距几乎可以忽略:更在意可读性就大胆用

他说就差这点?我说对啊,2 纳秒的事儿。除非你在写操作系统内核或者搞高频交易,不然这点差距谁在乎。CLI 工具、Web 后端、内部服务,随便用。

他又问,那啥时候不该用呢?我说也有,比如你在写库代码需要精确控制错误类型的,或者 no_std 环境,那还是老老实实手写。但大部分业务代码,校验多、逻辑复杂的那种,用 bail! 准没错。

bail! 适用场景示意图

图5: 业务代码、CLI、Web 后端:bail! 很好用;库代码/ no_std:谨慎

我又给他看了个我常用的写法,参数校验:

fn validate(user: &User) -> Result<()> {
    if user.name.trim().is_empty() {
        bail!("Name cannot be empty");
    }
    if user.age < 18 {
        bail!("User must be at least 18 years old");
    }
    Ok(())
}

他说这读起来跟念规则似的,名字不能空,年龄得满 18。我说对,这就是 bail! 的好处,代码即文档。

聊完他说回去就改,我说行,改完你就知道了,以后凌晨三点 debug 的时候会感谢自己的。

其实有时候改变你写代码方式的,不是什么高深的设计模式,就是这么一个小工具。现在我每次写函数都会想一下,这里能不能用 bail! 让代码更清爽点。能就用,简单。

对了,你们平时是怎么处理错误的?有没有其他用着顺手的小技巧?评论区聊聊呗。觉得有用的话顺手点个赞转发一下,让更多还在跟 map_err 搏斗的兄弟看到。