news 2026/9/10 12:52:48

Comprehensive Rust 错误处理实战:用 `Result` 与 `?` 重写表达式求值器

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Comprehensive Rust 错误处理实战:用 `Result` 与 `?` 重写表达式求值器

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, } }

这段代码有几个典型的“非惯用”问题:

  1. 错误路径不可见:函数的返回类型是i64,调用者从类型签名上完全看不出它会“爆炸”,只能靠文档或运行时崩溃发现风险。
  2. 用 panic 表达可预期错误:除零是表达式求值过程中完全可预见的输入错误,属于“可恢复错误”范畴,却用panic!处理。课程在 panics.md 中明确:panic 只用于不可恢复的、意外发生的错误,是程序 bug 的症状;断言失败、越界访问会 panic,而“除数为 0”这种业务输入错误应该走Result通道。

支撑类型:OperationExpression

练习与答案共用同一组类型定义(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 { ... })——对AddSubMul三个分支,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!")。注意答案中?已提前把leftright解包为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_libraryparser)与rust_testparser_test,size 为small)两条 Bazel 规则。课程允许你按自己熟悉的方式运行:

  • 使用 Cargo:在该目录执行cargo test(仓库根目录有对应 Cargo.toml 工作区配置,练习位于 src/error-handling/Cargo.toml);
  • 使用 Bazel:执行bazel test //src/error-handling:parser_test

parser依赖anyhowthiserror两个 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派生宏可以自动为错误类型生成DisplayError实现;在应用层聚合错误时,anyhow.md 的anyhow::Result则是更轻量的选择——这与本练习所在的 Cargo.toml 中同时声明anyhowthiserror依赖的设计一脉相承。

小结

本练习的完整脉络是:发现 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 12:51:15

Lazydocker 里鼠标无法选中复制文本怎么办?

Lazydocker 里鼠标无法选中复制文本怎么办&#xff1f; 【免费下载链接】lazydocker The lazier way to manage everything docker 项目地址: https://gitcode.com/GitHub_Trending/la/lazydocker 用 lazydocker 管理 Docker 容器时&#xff0c;一个常见的困扰是&#x…

作者头像 李华
网站建设 2026/9/10 12:50:44

STC51四轴飞控从原理图到代码:传感器融合与PID控制

简介&#xff1a;基于STC8A8K16S4A12单片机的四轴飞控开源项目&#xff0c;是一份适合航模爱好者与电子DIY初学者的入门级资料包。整套设计聚焦姿态飞行控制&#xff0c;包含完整原理图与C语言源码&#xff0c;可在250mm至750mm轴距多类机架上通过PID调参适配&#xff0c;帮助读…

作者头像 李华
网站建设 2026/9/10 12:49:55

3步装好 ExplorerPatcher:把 Windows 11 界面调回你熟悉的样子

3步装好 ExplorerPatcher&#xff1a;把 Windows 11 界面调回你熟悉的样子 【免费下载链接】ExplorerPatcher This project aims to enhance the working environment on Windows 项目地址: https://gitcode.com/GitHub_Trending/ex/ExplorerPatcher 刚把电脑升到 Windo…

作者头像 李华
网站建设 2026/9/10 12:47:55

CANN/ge图引擎SetMarks接口

SetMarks 【免费下载链接】ge GE&#xff08;Graph Engine&#xff09;是面向昇腾的图编译器和执行器&#xff0c;提供了计算图优化、多流并行、内存复用和模型下沉等技术手段&#xff0c;加速模型执行效率&#xff0c;减少模型内存占用。 GE 提供对 PyTorch、TensorFlow 前端的…

作者头像 李华
网站建设 2026/9/10 12:46:58

Matlab实现GRU时间序列回归预测:从原理到代码详解

在科研、课程作业和工程辅助决策里&#xff0c;时间序列回归预测任务出现得频率相当高。很多人第一次接触这类问题&#xff0c;是被“GRU”“LSTM”这些名字劝退的&#xff0c;总觉得深度学习模型离自己很远。实际上&#xff0c;用Matlab做一个能跑的GRU时间序列回归预测模型&a…

作者头像 李华