Comprehensive Rust 错误处理实战:用Result与?重写表达式求值器
【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust
本篇文章聚焦 Google Android 团队维护的 Rust 教学课程 Comprehensive Rust(仓库根目录:comprehensive-rust)中“错误处理”章节的课堂练习及其标准答案。文章以 solution.md 为骨架,完整呈现如何把原本遇到除零时直接panic!的表达式求值器eval,改写成返回Result<i64, DivideByZeroError>的惯用错误处理版本,并深入解析Result枚举、?运算符、Ok包装、单元结构体错误类型等核心机制。读完你将掌握:如何通过类型签名让“可能失败”成为函数契约的一部分、如何用?让错误传播简洁清晰、以及如何用单元测试验证错误路径。
练习背景:从 Day 2 求值器出发
本练习(见 exercise.md)是对课程第 2 天“表达式求值器”练习的 revisit。原实现忽略了一个关键错误场景:除以零。练习要求把eval重写为使用惯用错误处理方式,在除零发生时返回错误,而不是崩溃。
练习文档特意指出一个容易让学员困惑的细节:起始代码与之前练习的答案并不完全相同——仓库在原始代码中显式加入了panic!("Cannot divide by zero!"),目的是向学员指明错误用例所在的位置(见 exercise.md 的<details>提示块)。如果你发现代码和记忆中的“正确答案”有出入,这正是刻意为之。
题目同时提供了一个预置的错误类型DivideByZeroError作为eval的错误类型,学员无需自己设计错误类型,可以把全部精力放在Result改造上。
起始代码:暴露问题的“旧世界”
练习的起始代码位于 exercise.rs 的eval锚点段(// ANCHOR: eval):
// The original implementation of the expression evaluator. Update this to // return a `Result` and produce an error when dividing by 0. fn eval(e: Expression) -> i64 { match e { Expression::Op { op, left, right } => { let left = eval(*left); let right = eval(*right); match op { Operation::Add => left + right, Operation::Sub => left - right, Operation::Mul => left * right, Operation::Div => if right != 0 { left / right } else { panic!("Cannot divide by zero!"); }, } } Expression::Value(v) => v, } }这段代码有几个典型的“非惯用”问题:
- 错误路径不可见:函数的返回类型是
i64,调用者从类型签名上完全看不出它会“爆炸”,只能靠文档或运行时崩溃发现风险。 - 用 panic 表达可预期错误:除零是表达式求值过程中完全可预见的输入错误,属于“可恢复错误”范畴,却用
panic!处理。课程在 panics.md 中明确:panic 只用于不可恢复的、意外发生的错误,是程序 bug 的症状;断言失败、越界访问会 panic,而“除数为 0”这种业务输入错误应该走Result通道。
支撑类型:Operation与Expression
练习与答案共用同一组类型定义(exercise.rs 的// ANCHOR: types段),它们决定了求值器的数据结构:
/// An operation to perform on two subexpressions. #[derive(Debug)] enum Operation { Add, Sub, Mul, Div, } /// An expression, in tree form. #[derive(Debug)] enum Expression { /// An operation on two subexpressions. Op { op: Operation, left: Box<Expression>, right: Box<Expression> }, /// A literal value Value(i64), } #[derive(PartialEq, Eq, Debug)] struct DivideByZeroError;三个关键点值得展开:
Expression是递归的树形结构:Op变体通过Box<Expression>持有左右两个子表达式,Value变体表示叶子字面量。求值器eval因此天然是一个递归函数。DivideByZeroError是单元结构体(unit struct):它没有任何字段。答案文档专门提示:这里之所以“零字段就够用”,是因为除零错误不需要携带额外上下文——出错的原因本身就是完整信息。#[derive(PartialEq, Eq, Debug)]让它可以参与assert_eq!比较并能被打印调试。- 文件顶部有
#![allow(dead_code)]:练习代码中部分类型在未完成改造前可能未被全部使用,这一属性允许编译器不产生未使用代码警告,让学员专注练习本身。
标准答案:用Result重构eval
完整的标准答案位于 exercise.rs 的// ANCHOR: solution段,同时被 solution.md 通过 mdBook 的{{#include exercise.rs:solution}}锚点机制直接嵌入页面(这种方式保证文档展示的代码与仓库中的真实源码永远一致):
fn eval(e: Expression) -> Result<i64, DivideByZeroError> { match e { Expression::Op { op, left, right } => { let left = eval(*left)?; let right = eval(*right)?; Ok(match op { Operation::Add => left + right, Operation::Sub => left - right, Operation::Mul => left * right, Operation::Div => { if right == 0 { return Err(DivideByZeroError); } else { left / right } } }) } Expression::Value(v) => Ok(v), } }答案文档提炼了四个核心改造点:
1. 返回类型改为Result<i64, DivideByZeroError>
函数签名从i64变为Result<i64, DivideByZeroError>。这个显式的类型签名强制调用方必须处理失败的可能性——这正是 Rust 错误处理与异常机制的本质差异。课程在 result.md 中强调:Result有两个变体,Ok携带成功值,Err携带某种错误值;函数是否可能出错被直接编码在类型签名中,因此“忘记处理错误”在编译层面就不可能发生。
2.?运算符:递归调用的错误传播
答案在递归调用上使用?:let left = eval(*left)?;。这行代码等价于手写:
match eval(*left) { Ok(value) => value, Err(err) => return Err(err), }try.md 将这一模式总结为:?把常见的match样板压缩成一个运算符——若eval返回Err,函数立即把该Err原样返回给调用方;若返回Ok(v),则把v解包并赋给left(或right)。这让错误沿调用链“透明”向上传播,代码几乎和异常机制一样简洁,但控制流是显式的、类型检查器可见的。
3.Ok包装成功结果
成功路径必须显式包装为Ok(...)。注意答案的写法Ok(match op { ... })——对Add、Sub、Mul三个分支,match表达式本身求出i64,再整体包进Ok;只有Div分支内部需要特殊处理。对于Expression::Value(v)叶子节点,则直接Ok(v)。
4. 显式检查除零,取代 panic
Operation::Div => { if right == 0 { return Err(DivideByZeroError); } else { left / right } }这里用显式的right == 0检查返回Err(DivideByZeroError),替换了原代码中的panic!("Cannot divide by zero!")。注意答案中?已提前把left、right解包为i64,所以这里可以直接做数值比较。从语义上看,return Err(...)在match分支内部提前返回,控制流清晰,且不依赖任何异常机制。
单元测试:验证错误与成功两条路径
答案文档还通过{{#include exercise.rs:tests}}嵌入了配套测试(exercise.rs 的// ANCHOR: tests段):
#[cfg(test)] mod test { use super::*; #[test] fn test_error() { assert_eq!( eval(Expression::Op { op: Operation::Div, left: Box::new(Expression::Value(99)), right: Box::new(Expression::Value(0)), }), Err(DivideByZeroError) ); } #[test] fn test_ok() { let expr = Expression::Op { op: Operation::Sub, left: Box::new(Expression::Value(20)), right: Box::new(Expression::Value(10)), }; assert_eq!(eval(expr), Ok(10)); } }两个测试恰好覆盖改造后的两条关键路径:
test_error:构造99 / 0的表达式树,断言eval返回Err(DivideByZeroError)。它直接验证了本次重构的核心目标——除零不再 panic,而是产生可处理的错误值。这也是DivideByZeroError必须派生PartialEq的原因:assert_eq!需要比较两个错误值是否相等。test_ok:构造20 - 10的表达式树,断言返回Ok(10),保证正常求值路径没有被破坏,同时验证了Ok包装与?解包的往返正确性。
从构建配置看,这个练习是作为独立的 Rust 库(lib 名parser)组织的:Cargo.toml 声明[lib] name = "parser",入口即exercise.rs;BUILD.bazel 则定义了rust_library(parser)与rust_test(parser_test,size 为small)两条 Bazel 规则。课程允许你按自己熟悉的方式运行:
- 使用 Cargo:在该目录执行
cargo test(仓库根目录有对应 Cargo.toml 工作区配置,练习位于 src/error-handling/Cargo.toml); - 使用 Bazel:执行
bazel test //src/error-handling:parser_test。
parser依赖anyhow与thiserror两个 crate(见 Cargo.toml),它们对应错误处理章节后段的进阶内容(anyhow.md、thiserror.md),本练习刻意不引入它们,让学员先用标准库类型掌握原理。
为什么这样改:Result与错误处理范式对比
答案文档的<details>提示区给出了两个供讲师展开的讨论点,也是理解本练习价值的钥匙:
其一,关于单元结构体错误类型。DivideByZeroError没有任何字段就已足够,因为它不需要提供除“除零发生了”之外的额外上下文。当你需要携带更多信息(如哪个表达式、当时的左右操作数)时,再考虑为错误类型增加字段或改用更丰富的错误类型。
其二,关于?与异常的对比。?让错误处理“几乎和异常一样简洁,但控制流是显式的”。课程在 result.md 的 “More to Explore” 中系统比较了三种范式:
- 异常机制(C++、Java、Python 等):函数是否可能抛异常不在类型签名中可见,调用方无从判断;异常会展开调用栈向上传播,深处产生的错误可能波及上层无关函数。
- 错误码(C、Go 等):错误值与成功值分离返回,语言层面可能忘记检查错误码,进而访问未初始化或无效的成功值。
Result(Rust):成功与失败都被编码进类型,不先匹配解包就无法取得值;unwrap之类的方法虽然可以快速写出不做健壮处理的代码,但源码中始终可见“哪里跳过了应有的错误处理”。
超越本练习:从Result到动态错误类型
掌握本练习后,error.md 展示了更进一步的模式:当不想为所有可能错误手写枚举时,可以用std::error::Errortrait 构造 trait object:
fn read_count(path: &str) -> Result<i32, Box<dyn Error>> { let mut count_str = String::new(); fs::File::open(path)?.read_to_string(&mut count_str)?; let count: i32 = count_str.parse()?; Ok(count) }read_count会从文件操作产生std::io::Error、从字符串解析产生std::num::ParseIntError,而Box<dyn Error>让它们可以被统一返回。文档同时给出重要权衡:装箱错误省代码,但放弃了对不同错误分类处理的能力,因此不适合作为库的公开 API,更适合“只想把错误消息展示出来”的应用程序。若定义自定义错误类型,务必实现std::error::Errortrait 以便装箱。
回到本练习:如果你希望在生产级库中定义更结构化的错误类型,thiserror.md 提供的thiserror派生宏可以自动为错误类型生成Display与Error实现;在应用层聚合错误时,anyhow.md 的anyhow::Result则是更轻量的选择——这与本练习所在的 Cargo.toml 中同时声明anyhow与thiserror依赖的设计一脉相承。
小结
本练习的完整脉络是:发现 panic 误用(除零)→ 用Result改造签名 → 用?简化递归错误传播 → 用Ok显式包装成功 → 用单元测试锁死错误与成功两条路径。它浓缩了 Rust 错误处理的核心哲学:错误是值,成功与失败都是类型的一部分,编译器帮你确保每一个可能失败的地方都被显式处理。完整代码、注释与测试均可直接在 exercise.rs 中查阅,讲师讨论要点见 solution.md,配套概念讲解依次见 result.md、try.md 与 panics.md。
【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考