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
本篇技术指南围绕 comprehensive-rust 课程error-handling模块中的核心练习展开:将第 2 天"表达式求值器"练习的初始实现改写成符合 Rust 惯用风格(idiomatic)的错误处理版本,用Result<i64, DivideByZeroError>替换panic!("Cannot divide by zero!"),让除零这类可预期的运行期错误以类型化的方式显式传播。读完本文,你将掌握Result枚举、?传播运算符、自定义错误类型(单元结构体)的设计方法,以及如何用单元测试验证错误路径——这些正是把 Rust 代码从"能跑"提升到"健壮可维护"的关键技能。
练习背景:为什么要重写这个求值器
原练习位于 src/error-handling/exercise.md,要求回归第 2 天的表达式求值器练习,但初始解决方案忽略了一个关键错误场景:除以零。当表达式为99 / 0时,程序会直接崩溃。
Rust 对运行期错误有两条截然不同的处理路径(详见 src/error-handling/panics.md):
- Panic:面向不可恢复、非预期的错误。Panic 是程序 bug 的症状——越界访问、
assert!失败、显式调用panic!宏都会触发;默认情况下 panic 会"展开(unwind)"调用栈,并像函数正常返回一样逐层 drop 值。 Result:面向连接被拒、文件不存在、除数为零这类"可预期的失败"。函数能否出错被直接编码进类型签名中,调用方无法在不解包的情况下访问成功值或错误值,因此不存在"忘记处理错误"的可能。
除法运算遇到零除数,属于用户输入可触发的可预期失败,正确的做法不是 panic,而是返回一个错误值让调用方决定如何处理。
起步代码:理解表达式树与原始实现
练习的完整起始代码与解决方案位于 src/error-handling/exercise.rs,mdbook 通过{{#include exercise.rs:types}}等锚点(ANCHOR)将其嵌入课件页面。
类型定义:表达式以树形结构组织
/// 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变体携带一个Operation和两个子表达式,借助Box<Expression>打破递归,使枚举大小有界;Value变体承载字面量i64。DivideByZeroError是一个单元结构体(unit struct)——没有任何字段。这对当前场景足够:除零错误不需要额外上下文(比如是哪个操作数导致的),见 src/error-handling/solution.md 的讨论。- 为
DivideByZeroError派生了PartialEq, Eq, Debug,这是为了能在测试中用assert_eq!直接比较错误值。
原始实现:用 panic 兜底
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, } }注意课件文档(src/error-handling/exercise.md)特别提示:起始代码与上一练习的答案并不完全一致——这里显式加入了一个panic!,目的是让学员清楚地看到错误发生的位置。讲师在课堂上应主动指出这一点,避免学员因代码与之前的解法不同而困惑。
原实现的三个局限:
- 返回值类型
i64无法表达"计算失败"这一信息; - 除零直接
panic!,程序崩溃,调用方没有机会恢复; - 错误只以消息字符串形式存在,无法被程序化地区分和处理。
重构目标:把错误编入类型签名
改写后的函数签名从fn eval(e: Expression) -> i64变为:
fn eval(e: Expression) -> Result<i64, DivideByZeroError>这一行改动正是Result哲学的核心体现。正如 src/error-handling/result.md 所讲:
Result是 Rust 最主要的错误处理机制,只有两个变体:Ok(携带成功值)与Err(携带某种错误值);- 函数能否出错,通过返回
Result体现在类型签名中——调用方一眼即可判断该函数是否可能失败; - 与
Option类似,必须先模式匹配才能访问内部值;unwrap等便捷方法只适用于"快速草稿"代码,源码中总能一眼看到哪里跳过了健壮的错误处理。
从跨语言对比的角度(同样见 src/error-handling/result.md):
- 异常(Exceptions,如 C++/Java/Python):函数是否会抛异常不出现在类型签名中,调用前无从判断;且异常会展开调用栈向上传播,可能波及栈深处无关的函数。
- 错误码(Error Numbers,如 C/Go):成功返回值与错误值分离返回,语言层面无法强制检查,容易忘记检查而读到未初始化或无效的成功值。
- Rust 的
Result:失败可能性是类型系统的一部分,编译器强制你在编译期面对它。
惯用解法:?运算符 + 显式检查
参照 src/error-handling/solution.md 与 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), } }四个关键改动逐一拆解
Result返回类型:Result<i64, DivideByZeroError>显式声明了失败可能性,强制调用方处理除零错误。?运算符传播错误:递归调用eval(*left)?和eval(*right)?是惯用解法的点睛之笔。?的语义等价于下面这段样板代码(见 src/error-handling/try.md):match some_expression { Ok(value) => value, Err(err) => return Err(err), }即:若子表达式求值返回
Err,当前函数立即返回该Err;若返回Ok(v),则解包出v赋给left/right。因为左右子树可能各自除零,?保证第一个错误最先传播出来。Ok包装成功值:Ok(match op { ... })把所有成功路径的结果统一包进Ok(...);Expression::Value(v) => Ok(v)同样如此。除零显式检查:
if right == 0 { return Err(DivideByZeroError); }取代了原来的panic!。注意这里用的是提前return Err而非?,因为DivideByZeroError是当场构造的,而非来自子调用。
?的深层语义:错误处理几乎与异常一样简洁,但控制流是显式的
src/error-handling/try.md 还补充了一个实用的知识点:main也可以返回Result<(), E>,只要E实现std::process::Termination(实践中即E: Debug)。此时可执行文件会打印Err变体并以非零退出码结束。这为"顶层直接?传播"提供了官方通道。
用单元测试验证成功与失败两条路径
exercise.rs 的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,断言返回Err(DivideByZeroError)——这正是原实现会 panic 的场景;test_ok:构造20 - 10,断言正常返回Ok(10)——确保重构没有破坏成功路径。
这两个测试共同证明了重构后函数的双路径完整性:错误被类型化返回,而非崩溃。这也与 comprehensive-rust 全课程"练习配测试"的风格一致(例如 src/error-handling/exercise.rs 通过 Cargo.toml 以[lib]形式暴露为parser库,便于cargo test直接验证)。
从练习出发:错误处理的进阶方向
练习仅要求处理除零这一种错误,src/error-handling 目录还提供了扩展思路:
- 自定义错误类型的
std::error::Error实现(见 src/error-handling/error.md):如果想把错误装箱成Box<dyn Error>动态分发(例如顶层程序只需展示错误消息),自定义错误类型必须实现std::error::Errortrait。装箱省代码,但会丧失对不同错误分支的精细处理能力,因此一般不建议用在库的公开 API 上。 thiserror+anyhow组合(见 src/error-handling/anyhow.md):anyhow::Error本质是Box<dyn Error>的包装,anyhow::Result<V>是Result<V, anyhow::Error>的类型别名;.context()/.with_context()能为错误附加语义化的上下文追踪(类似 Go 的 error 包装习惯)。thiserror则用派生宏#[derive(Error)]免去手写 trait 实现的样板。二者正是本练习"手写单元错误类型"在生产级应用中的升级形态,src/error-handling/Cargo.toml 中已将这两个 crate 作为依赖引入。
小结
本练习是理解 Rust 错误处理哲学的绝佳切片:同一个"除零"错误,从panic!崩溃到Result<i64, DivideByZeroError>显式返回,改变的不仅是代码,更是**错误从"运行时事故"到"类型系统第一公民"**的认知转变。掌握Result签名、?传播、单元结构体错误类型与配套测试,你就掌握了 Rust 惯用错误处理的全部基础构件;再叠加thiserror/anyhow,即可应对真实应用中的复杂错误场景。
【免费下载链接】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),仅供参考