【AI开发之Rust】第 7 课:错误处理 —— panic、Result 与 `?`

第 7 课:错误处理 ------ panic、Result 与 ?

预计时间:75-90 分钟 | 难度:⭐⭐⭐ | 前置:第 6 课(struct/enum/Option/Result)、第 10 课会用到本课的自定义错误


7.1 这节课解决什么问题

Rust 没有异常,没有 try/catch/throw。取而代之的是一套更显式的哲学:

复制代码
可以预见、可以恢复的错误(文件不存在、网络超时、JSON 解析失败)
        → 用返回值表达:Result<T, E>,调用者必须处理
不可预见、不可恢复的错误(越界、断言失败、除以零)
        → panic!:程序直接崩,或至少把那条线程崩掉

这种设计带来的好处:错误是"值" ,是函数签名的一部分(fn f() -> Result<T, E> 一读就知道会失败),编译器逼你在每个会失败的地方做决策。代价是要学会几种"错误值"的传递手法------本课的目标就是把这套手法练到肌肉记忆,它直接决定第 13-15、17-21 课写网络/存储/FFI 代码时是否顺手。

7.2 两类错误的清晰分界

panic!(不可恢复) Result<T,E>(可恢复)
触发 越界、unwrap(None/Err)、断言失败、除零 任何"能预见可能失败"的操作
程序表现 默认 unwind 回溯并终止当前线程(--release 可改 abort) 函数正常返回 Err(e),流程可控
谁决定 通常由代码自身缺陷导致 由外部条件/输入导致
应对 修复 bug;测试用 #[should_panic] 调用方用 match/? 决定如何降级

判断口诀 :能提前知道"可能会失败"且业务上能降级 → Result;属于"程序 bug、不该发生" → panic 由标准库触发即可,别到处手动 panic。

7.2.1 panic 现场长什么样

rust 复制代码
fn main() {
    let v = vec![1, 2, 3];
    let x = v[99];    // 越界 → panic
}
复制代码
thread 'main' panicked at src/main.rs:4:14:
index out of bounds: the len is 3 but the index is 99
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
  • 报错给出行列与原因;
  • 设置 RUST_BACKTRACE=1 重跑可拿到调用栈(做 FFI/实战调试时必备);
  • 依赖里某个 crate panic 会沿线程 unwind,跨 FFI 边界的 panic 是未定义行为 ------第 16 课讲 catch_unwind 时必须回来用它。

7.3 Result 的三种消费姿势

先建一个"会失败"的样例函数(读文件,标准库 std::fs 今天只当黑盒用):

rust 复制代码
use std::fs;

// 打开并读取一个文件 → Result<String, io::Error>
fn read_file(path: &str) -> Result<String, std::io::Error> {
    fs::read_to_string(path)
}

fn main() {
    // 姿势一:match 全处理(最啰嗦但最清晰)
    match read_file("hello.txt") {
        Ok(content) => println!("内容:{content}"),
        Err(e) => println!("读取失败:{e}"),
    }

    // 姿势二:unwrap / expect ------ 练习/原型期"坚信成功"
    let content = read_file("hello.txt").expect("hello.txt 必须存在");
    println!("{content}");
}
  • unwrap():Err 时 panic;expect("自定义信息"):同样 panic,但先打你的说明------代码审查时 expect 的文案等于在解释"为什么这里不可能失败"
  • 生产代码里,unwrap/expect 只出现在"已用逻辑保证不可能失败"的地方(如先 if exists 再读且没有并发删文件的情况)。否则一律走姿势三。

7.3.1 姿势三:? 运算符 ------ 把错误"交给上层"

在返回 Result 的函数里,? 是"成功了取 Ok 里的值继续;失败了直接把 Err 原样返回":

rust 复制代码
use std::fs;

fn read_config(path: &str) -> Result<String, std::io::Error> {
    // fs::read_to_string(path)?  读到内容就用,Err 就 return Err 给调用者
    let raw = fs::read_to_string(path)?;
    Ok(format!("配置已加载({} 字节)", raw.len()))
}

fn main() -> Result<(), std::io::Error> {
    // main 也可以返回 Result!Err 会被运行时打印并置退出码
    let msg = read_config("config.toml")?;
    println!("{msg}");
    Ok(())
}

? 是语法糖,等价于:

rust 复制代码
let raw = match fs::read_to_string(path) {
    Ok(v) => v,
    Err(e) => return Err(e.into()),   // into():自动做错误类型转换(见 7.4)
};

💡 ? 只能在返回 Result/Option/可转成 Result 的类型 的函数里用。main 里用 ? 需要把 main 声明成 -> Result<(), E>(见上)。这条在实战的每个 IO 函数里都会出现,务必练熟。

7.4 错误类型转换:为什么一层层 ? 能串起来

? 内部有 .into(),意味着只要上层错误类型能从下层错误类型 From 转换,就能一路向上抛。标准库为此做了大量现成实现:

rust 复制代码
use std::fs;

// 读取数字并解析。可能错在:读文件(io::Error)或解析(ParseIntError)。
// 函数统一返回 io::Error 就能直接 ?(io::Error 实现了 From<ParseIntError>?并没有)
// ------所以这个例子用 std::io::Error 写不干净,见下方正确示范

正确示范------定义自己的错误枚举,让两类底层错误都能转进来:

rust 复制代码
use std::fs;

#[derive(Debug)]
enum ConfigError {
    Io(std::io::Error),          // 包一层
    BadNumber(std::num::ParseIntError),
}

impl From<std::io::Error> for ConfigError {
    fn from(e: std::io::Error) -> Self { ConfigError::Io(e) }
}
impl From<std::num::ParseIntError> for ConfigError {
    fn from(e: std::num::ParseIntError) -> Self { ConfigError::BadNumber(e) }
}

fn read_port() -> Result<u16, ConfigError> {
    let raw = fs::read_to_string("port.conf")?;   // io::Error → ConfigError::Io ✓
    let n: u16 = raw.trim().parse()?;             // ParseIntError → ConfigError::BadNumber ✓
    Ok(n)
}

fn main() {
    match read_port() {
        Ok(p) => println!("端口:{p}"),
        Err(e) => println!("读取配置失败:{e:?}"),
    }
}

两条结论:

  1. 手写 From 太啰嗦 → 7.6 的 thiserror 用一行 derive 替你写完;
  2. 因为 ? 内置转换,错误类型设计决定调用链是否干净。实战规范:库(业务核心)用精确的领域错误(thiserror),应用入口(main/UI 桥)用宽泛的 anyhow 统一兜底。

7.5 Option 与 Result 互转、常用组合子

7.5.1 Option ↔ Result

rust 复制代码
fn main() {
    // Option → Result:给 None 一个理由
    let o: Option<i32> = None;
    let r: Result<i32, &str> = o.ok_or("值为空");      // Err("值为空")
    println!("{r:?}");

    // Result → Option:丢掉错误信息,只要成功值
    let ok: Result<i32, &str> = Ok(7);
    let o2 = ok.ok();                                    // Some(7)
    let o3 = ok.err();                                   // None(Ok 时没有错误)

    // transpose:把"Result 里的 Option"翻过来
    let outer: Result<Option<i32>, &str> = Ok(Some(3));
    let flipped: Option<Result<i32, &str>> = outer.transpose();  // Some(Ok(3))
    println!("{flipped:?}");
}

7.5.2 组合子:在 Result 上链式变换而不 unwrap

rust 复制代码
fn main() {
    let r: Result<i32, &str> = Ok(10);

    // map:成功就变换值,错误原样保留
    let r2 = r.map(|n| n * 2);                       // Ok(20)

    // and_then:连续执行"可能失败"的下一步
    let r3 = r.and_then(|n| if n >= 5 { Ok(n - 5) } else { Err("太小") });
    println!("{r3:?}");                               // Ok(5)

    // unwrap_or / unwrap_or_else:失败给默认值
    let v = r.unwrap_or(0);                          // 10
    let e: Result<i32, &str> = Err("oops");
    let v2 = e.unwrap_or_else(|msg| { println!("fallback:{msg}"); -1 });  // -1
    println!("{v} {v2}");
}
组合子 作用 什么时候用
.map(f) 成功值变换 纯计算
.and_then(f) 成功后执行下一个可能失败步骤 链式 IO/校验
.ok_or(e) / .ok() Option↔Result 互转 切换语义
.unwrap_or(d) / .unwrap_or_else(f) 提供兜底值 有合理默认值
.transpose() 翻转嵌套 少见但考试爱考

💡 多数场景,用 ? 比组合子更直白;组合子适合"中途不想 return、想把整条链路的结果当值处理"。别为了炫技硬套 map 链。

7.6 生产级错误类型:thiserror 与 anyhow

手写 From 又累又容易漏,实战用两个 crate(本课起引入依赖,第 10 课会系统讲 Cargo 依赖):

toml 复制代码
[dependencies]
thiserror = "2"
anyhow = "1"

7.6.1 thiserror:给"库/核心"定义精确错误

rust 复制代码
use thiserror::Error;

// 领域错误:一个枚举列出这个模块所有会失败的场景
#[derive(Debug, Error)]
pub enum AuthError {
    #[error("用户名不存在:{0}")]
    UserNotFound(String),

    #[error("密码错误")]
    WrongPassword,

    #[error("token 已过期")]
    TokenExpired,

    #[error("底层网络错误:{0}")]
    Network(#[from] std::io::Error),     // #[from] = 自动 impl From
}
  • #[error("...")] 生成 Display 文案,{0} 指第 0 个字段;
  • #[from] 自动补 From impl → 下层错误 ? 直接转进来;
  • 调用处因此能写干净的错误链(这是 7.4 手写版的一行版)。
rust 复制代码
use std::fs;

fn load_credentials() -> Result<String, AuthError> {
    // io::Error 自动转 AuthError::Network
    let raw = fs::read_to_string("creds.txt")?;
    if raw.trim().is_empty() {
        return Err(AuthError::UserNotFound("creds.txt 无内容".into()));
    }
    Ok(raw)
}

7.6.2 anyhow:给"应用/入口"快速兜底

rust 复制代码
use anyhow::{Context, Result};

fn run() -> Result<()> {
    let raw = std::fs::read_to_string("config.toml")
        .context("读取配置文件失败")?;   // context 给错误加"上下文文案"
    let port: u16 = raw.trim().parse().context("配置里 port 不是数字")?;
    println!("端口 {port}");
    Ok(())
}

fn main() {
    if let Err(e) = run() {
        // 打印时能串出完整因果链
        eprintln!("启动失败: {e:#}");
        std::process::exit(1);
    }
}

7.6.3 两者的分工(写进实战的规矩)

复制代码
库 / 业务核心(要被别处调用)  → thiserror:错误类型精确、可 match、可序列化给 UI
二进制 / 入口(main、CLI、demo) → anyhow:不关心精确类型,只求带上下文地报错
向上(核心→UI 桥)转成 UI 可读信息 → match 精确错误 → 映射成字符串/枚举发给界面

💡 一句话:"库"用 thiserror 把错误说明白,"app"用 anyhow 把错误传出去。 实战篇 17-19 课会严格执行这条,UI 拿到的将是一份带错误码的领域错误。

7.7 错误处理最佳实践清单

  1. 会失败就返回 Result,不要吞错误 :宁可让调用者显式处理,也别 let _ = 静默丢弃(编译器会对 Result 加 must_use 警告)。
  2. unwrap/expect 只留给"已被证明不可能失败"的地方 ,其余用 ? 或组合子。
  3. 错误要带上下文 :只给 Err(io::Error) 太裸,用 .context("读取会话列表") 或领域错误里带操作名。
  4. 别用错误做控制流 :可预见的"文件不存在"是业务分支(用 Result 判断存在与否是合理的),不是 panic。
  5. panic 留在边界 :库代码尽量不主动 panic(用 assert!/expect 前先想调用者能否接受);跨 FFI 的函数绝不能让 panic 溜出去(16 课 catch_unwind)。
  6. UI 层收到错误要可展示:核心把错误码/文案规范化,App 再本地化。

7.8 📝 动手练习

参考实现放 code/07-error-handling/(写作时同步给出),本课需要一个带 Cargo.toml 依赖的工程。

  1. 读懂三种报错 :对不存在文件分别用 match.expect()?(在 main -> Result<(), io::Error> 里)读取,观察三种路径的输出与退出行为。
  2. ? 链练习 :写 fn read_port() -> Result<u16, Box<dyn std::error::Error>>(万能错误,快速验证期用):读 port.conf → trim → parse → 返回。体会"一层层 ?"(练习期可以偷懒用 Box<dyn Error>,生产别用)。
  3. Option/Result 互转 :写 fn parse_port(s: &str) -> Result<u16, String>,内部用 .parse().map_err(|e| format!("非法端口:{e}"));再用 .ok_or 写一个"取 Vec 首元素"为 Result 的版本。
  4. 组合子训练 :对 Result<u32, &str>.map.and_then.unwrap_or 完成"读用户分 → 加 10 → 若超过 100 视为满分"。
  5. thiserror 领域错误 :用 thiserror 定义 OrderError(字段:EmptyCartInvalidAmount(u32)Network(std::io::Error) + #[from]),写 fn checkout(items: &[String], amount: u32) -> Result<String, OrderError> 触发每种错误。
  6. anyhow 入口 :把 2 改成用 anyhow .context(),main 里用 {e:#} 打印错误链。
  7. panic 观察 :运行越界 panic,加 RUST_BACKTRACE=1 再看 backtrace 形状,截取前 10 行贴进笔记(16 课做 FFI 时还会用到 backtrace)。

验收门禁 :不查资料能说清------何时用 panic 何时用 Result;? 的展开语义(含 .into());main 如何返回 Result;thiserror 与 anyhow 各自定位;能默写 #[error("..{0}")].context() 的最小用法。

✅ 本节小结

  • 两分法 :可恢复错误 → Result;不可恢复 → panic!(默认 unwind,RUST_BACKTRACE=1 看栈);
  • 消费 Result :match 全处理 / unwrap/expect(仅"不可能失败"处)/ ?(向上抛,自动 .into());
  • 互转与组合ok_or/ok/transposemap/and_then/unwrap_or(_else)
  • 自定义错误 :库用 thiserror(#[error]+#[from]),入口用 anyhow(.context());#[from]? 链一路畅通;
  • 最佳实践:不吞错、错误带上下文、panic 留在边界、UI 收到规范化错误。

下一课预告 :第 8 课《泛型、trait 与 trait 对象》------把"行为"抽成接口:trait 怎么定义怎么实现、泛型约束怎么写、dyn Trait 动态分发与 vtable 是什么、以及 #[derive(...)] 派生宏速查表。第 6-7 课所有"自动实现 From/Error/Debug"背后都是 trait 在起作用。

相关推荐
移动云开发者联盟1 小时前
AI重塑短剧生产!移动云×咪咕 AIGC内容创作平台正式发布
大数据·人工智能
具身AGI1 小时前
具身从 demo 走向订单,物理AI 国产 的兑现拐点
人工智能
天远Date Lab1 小时前
零信任架构实战:基于天远车型识别精准构建自动化车险理赔网关
人工智能·ai·工具分享
cxr8281 小时前
Hermes × 本地 Ollama × qwen3.8 效能优化研究实验报告
人工智能·提示词·智能体
qq_452396231 小时前
第十篇:《异步编程:Tokio 入门与 async/await》
rust
甲维斯1 小时前
完蛋了,豆包变这么强?Seed2.1pro小测一下!
前端·人工智能
政企项目老覃1 小时前
边缘 AI 推理部署:安防零售场景下的模型裁剪与端侧落地实践
人工智能·程序人生·算法·性能优化·vllm
sarasuki1 小时前
如何让LLM 能在半夜偷偷打开网易云呢?
人工智能·设计模式·agent
烈风逍遥1 小时前
第一篇:SeaPack 全栈项目工程化实践
前端·后端·架构