练习与自测
本章练习共 11 题,答案折叠在每题下方。建议先自己写、编译通过后再展开答案;难度标记:★☆☆ 基础 / ★★☆ 综合 / ★★★ 挑战。
共 12 题。第 8、9 题是「判断代码能否编译」题型;第 10 题必须动手看编译错误。
练习 1:用 ? 贯穿解析流程
难度:★☆☆
要求:不写任何自定义错误类型,用标准库实现 fn parse_three(s: &str) -> Result<(i32, i32, i32), String>,输入形如 "1,2,3"(允许逗号两侧有空格)。字段数量不对、或有字段解析失败,都要返回带具体信息的 Err。用 ? 贯穿,不要嵌套 match。
提示:split(',') + collect::<Vec<_>>(),长度检查用 ok_or / ok_or_else。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
fn parse_three(s: &str) -> Result<(i32, i32, i32), String> {
let parts: Vec<&str> = s.split(',').map(|p| p.trim()).collect();
if parts.len() != 3 {
return Err(format!("需要 3 个字段,实际 {} 个:`{s}`", parts.len()));
}
// 这里用 map + collect 把三个 Result 折成一个 Result,再用 ? 传播
let nums: Vec<i32> = parts
.iter()
.map(|p| p.parse::<i32>().map_err(|e| format!("`{p}` 不是整数: {e}")))
.collect::<Result<_, _>>()?;
Ok((nums[0], nums[1], nums[2]))
}
fn main() {
println!("{:?}", parse_three("1, 2, 3"));
println!("{:?}", parse_three("1,2"));
println!("{:?}", parse_three("1,x,3"));
}输出:
textOk((1, 2, 3)) Err("需要 3 个字段,实际 2 个:`1,2`") Err("`x` 不是整数: invalid digit found in string")
要点解析:collect::<Result<Vec<_>, _>>()? 是「一串 Result → 一个 Result」的惯用法,遇到第一个 Err 立即短路,等价于手写循环 + ?。注意 ok_or 家族把 Option 变 Result,而 collect 是「Result 的 Result」——两者常配合使用。String 当错误只在小工具里可接受;要进库 API 就换成 enum。
练习 2:自定义错误枚举与 Display
难度:★★☆
要求:定义 enum LineError { Open { path: PathBuf, source: std::io::Error }, Parse { line: usize, text: String, source: ParseIntError } },实现 Display(消息分别是「无法打开文件 `{path}`」和「第 {line} 行不是整数:`{text}`」)与 Error(source() 返回底层错误)。然后写 fn sum_lines(path: &str) -> Result<i64, LineError>:逐行读取,跳过空行和 # 开头的注释行,其余行 parse::<i64>() 后累加。
提示:BufReader::new(file).lines().enumerate() 给出 0 起始行号,报错时记得 +1。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
use std::error::Error;
use std::fmt;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::num::ParseIntError;
use std::path::PathBuf;
#[derive(Debug)]
enum LineError {
Open { path: PathBuf, source: std::io::Error },
Parse { line: usize, text: String, source: ParseIntError },
}
impl fmt::Display for LineError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LineError::Open { path, .. } => write!(f, "无法打开文件 `{}`", path.display()),
LineError::Parse { line, text, .. } => {
write!(f, "第 {line} 行不是整数:`{text}`")
}
}
}
}
impl Error for LineError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
LineError::Open { source, .. } => Some(source),
LineError::Parse { source, .. } => Some(source),
}
}
}
fn sum_lines(path: &str) -> Result<i64, LineError> {
let path_buf = PathBuf::from(path);
let file = File::open(&path_buf).map_err(|e| LineError::Open {
path: path_buf.clone(),
source: e,
})?;
let mut total = 0i64;
for (idx, line) in BufReader::new(file).lines().enumerate() {
let line = line.map_err(|e| LineError::Open {
path: path_buf.clone(),
source: e,
})?;
let text = line.trim();
if text.is_empty() || text.starts_with('#') {
continue; // 注释与空行不算错误,直接跳过
}
let n: i64 = text.parse().map_err(|e| LineError::Parse {
line: idx + 1, // enumerate 是 0 起始,对人类要 +1
text: text.to_string(),
source: e,
})?;
total += n;
}
Ok(total)
}
fn main() {
match sum_lines("no-such-file.txt") {
Ok(t) => println!("sum = {t}"),
Err(e) => {
println!("{e}");
let mut cur = e.source();
while let Some(s) = cur {
println!(" caused by: {s}");
cur = s.source();
}
}
}
}输出:
text无法打开文件 `no-such-file.txt` caused by: 系统找不到指定的文件。 (os error 2)
要点解析:错误结构里存 PathBuf(拥有所有权)而不是 &str,因为 source() 要求 'static,且错误值必须能脱离原始 path 参数独立存活。lines() 的每一项也是 Result,别只顾着 parse 而漏掉 IO 错误。
练习 3:用 From 让 ? 自动转换错误
难度:★★☆
要求:在练习 2 的基础上,去掉所有 map_err,改为只实现两个 impl From<...> for LineError,让 ? 独自完成转换。写完后回答:这样做丢掉了什么信息?(用一行注释写在代码里。)
提示:From<std::io::Error> 想不出 path 时可以填 PathBuf::from("<unknown>")。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
use std::error::Error;
use std::fmt;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::num::ParseIntError;
use std::path::PathBuf;
#[derive(Debug)]
enum LineError {
Open { path: PathBuf, source: std::io::Error },
Parse { line: usize, text: String, source: ParseIntError },
}
impl fmt::Display for LineError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LineError::Open { path, .. } => write!(f, "无法打开文件 `{}`", path.display()),
LineError::Parse { line, text, .. } => write!(f, "第 {line} 行不是整数:`{text}`"),
}
}
}
impl Error for LineError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
LineError::Open { source, .. } => Some(source),
LineError::Parse { source, .. } => Some(source),
}
}
}
// 关键:From 让 ? 自动转换,但拿不到调用点的局部信息
impl From<std::io::Error> for LineError {
fn from(source: std::io::Error) -> Self {
// 丢失信息 #1:不知道是哪个 path、也不知道是「打开」还是「读取中」失败
LineError::Open { path: PathBuf::from("<unknown>"), source }
}
}
impl From<ParseIntError> for LineError {
fn from(source: ParseIntError) -> Self {
// 丢失信息 #2:不知道行号和原始文本,用户无法定位
LineError::Parse { line: 0, text: String::new(), source }
}
}
fn sum_lines(path: &str) -> Result<i64, LineError> {
let file = File::open(path)?; // ? 自动 From
let mut total = 0i64;
for (idx, line) in BufReader::new(file).lines().enumerate() {
let line = line?;
let text = line.trim();
if text.is_empty() || text.starts_with('#') {
continue;
}
let n: i64 = text.parse()?;
let _ = idx;
total += n;
}
Ok(total)
}
fn main() {
println!("{:?}", sum_lines("no-such-file.txt"));
}输出:
textErr(Open { path: "<unknown>", source: Os { code: 2, kind: NotFound, message: "系统找不到指定的文件。" } })
要点解析:From 是「无上下文」的转换——它只拿到底层错误值,看不到调用点的局部变量。所以「有 path/line 字段的错误类型」和「纯 From 自动转换」天然冲突。工程上的解法有两级:库内部用 #[from] 只在真正无上下文的变体上(如 Io),需要上下文的变体就在调用点 map_err;二进制直接用 anyhow::context,它把「上下文字符串」和「底层错误」一起存起来,不需要为每个调用点设计新变体。
练习 4:collect::<Result<_>> 的三种写法
难度:★★☆
要求:写一个函数 fn stats(readings: &[&str]) -> Result<(usize, i64), ParseIntError>,返回(成功解析的个数,总和);只要有一个解析失败就整体返回 Err。分别用三种写法实现:collect::<Result<Vec<_>, _>>()、try_for_each、显式 for 循环 + ?。
提示:try_for_each 的闭包要返回 Result<(), ParseIntError>。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
use std::num::ParseIntError;
/// 写法 1:collect 到 Result<Vec<_>, _>,短路于第一个 Err
fn stats_collect(readings: &[&str]) -> Result<(usize, i64), ParseIntError> {
let nums: Vec<i64> = readings.iter().map(|r| r.parse::<i64>()).collect::<Result<_, _>>()?;
Ok((nums.len(), nums.iter().sum()))
}
/// 写法 2:try_for_each,闭包自己返回 Result,? 在闭包内合法
fn stats_try(readings: &[&str]) -> Result<(usize, i64), ParseIntError> {
let mut count = 0usize;
let mut sum = 0i64;
readings.iter().try_for_each(|r| {
sum += r.parse::<i64>()?;
count += 1;
Ok(())
})?;
Ok((count, sum))
}
/// 写法 3:显式 for 循环,最直白,也最容易加日志/断点
fn stats_loop(readings: &[&str]) -> Result<(usize, i64), ParseIntError> {
let mut count = 0usize;
let mut sum = 0i64;
for r in readings {
sum += r.parse::<i64>()?;
count += 1;
}
Ok((count, sum))
}
fn main() {
let good = ["1", "2", "3"];
let bad = ["1", "x", "3"];
println!("{:?} {:?} {:?}", stats_collect(&good), stats_try(&good), stats_loop(&good));
println!("{:?} {:?} {:?}", stats_collect(&bad), stats_try(&bad), stats_loop(&bad));
}输出:
textOk((3, 6)) Ok((3, 6)) Ok((3, 6)) Err(ParseIntError { kind: InvalidDigit }) Err(ParseIntError { kind: InvalidDigit }) Err(ParseIntError { kind: InvalidDigit })
要点解析:三种写法都短路(遇到第一个错误就停止)——这是 ? 语义一致性的体现,也是它们能互相替换的原因。选择标准:只需要最终结果用 collect;需要在闭包里改外部状态(累加、计数)用 try_for_each;需要插入 if、日志、continue 等控制流用 for 循环。用 for 循环写 ? 是最不需要动脑的写法,性能也完全一致。
练习 5:给错误附加上下文信息
难度:★★★
要求:定义带上下文的 struct 错误(类型形状如下,PathBuf / ParseIntError 记得 use):
rust
use std::num::ParseIntError;
use std::path::PathBuf;
struct FileNumberError { path: PathBuf, line: usize, kind: Kind }
enum Kind { Io(std::io::Error), Parse(ParseIntError) }给出 Display(「处理 `{path}` 第 {line} 行失败」,line == 0 时显示「打开文件时失败」)和 source()。然后写 fn sum_file(path: &str) -> Result<i64, FileNumberError>,要求每个 ? 点都用 map_err 补全上下文,包括 File::open、.lines() 的每一次迭代、以及 parse。
提示:path 会被移动进错误结构,所以先 let path = PathBuf::from(path); 再用 path.clone()。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
use std::error::Error;
use std::fmt;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::num::ParseIntError;
use std::path::PathBuf;
#[derive(Debug)]
struct FileNumberError {
path: PathBuf,
line: usize, // 0 表示与具体行无关(打开文件失败)
kind: Kind,
}
#[derive(Debug)]
enum Kind {
Io(std::io::Error),
Parse(ParseIntError),
}
impl fmt::Display for FileNumberError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.line == 0 {
write!(f, "打开 `{}` 时失败", self.path.display())
} else {
write!(f, "处理 `{}` 第 {} 行失败", self.path.display(), self.line)
}
}
}
impl Error for FileNumberError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match &self.kind {
Kind::Io(e) => Some(e),
Kind::Parse(e) => Some(e),
}
}
}
fn sum_file(path: &str) -> Result<i64, FileNumberError> {
let path = PathBuf::from(path);
let file = File::open(&path).map_err(|e| FileNumberError {
path: path.clone(),
line: 0,
kind: Kind::Io(e),
})?;
let mut total = 0i64;
for (idx, line) in BufReader::new(file).lines().enumerate() {
let line = line.map_err(|e| FileNumberError {
path: path.clone(),
line: idx + 1,
kind: Kind::Io(e),
})?;
let text = line.trim();
if text.is_empty() || text.starts_with('#') {
continue;
}
let n: i64 = text.parse().map_err(|e| FileNumberError {
path: path.clone(),
line: idx + 1,
kind: Kind::Parse(e),
})?;
total += n;
}
Ok(total)
}
fn main() {
println!("{:?}", sum_file("no-such-file.txt"));
}输出:
textErr(FileNumberError { path: "no-such-file.txt", line: 0, kind: Io(Os { code: 2, kind: NotFound, message: "系统找不到指定的文件。" }) })
要点解析:map_err 在这里不是啰嗦,而是把局部变量注入错误的唯一手段(From 看不到 path/idx)。line: 0 是一个「哨兵值」约定——用注释/Display 把它翻译成「打开文件时失败」。更讲究的做法是把 line 改成 Option<usize>,用类型而不是约定表达「没有行号」。这种错误类型适合中间层(既有结构化字段,又可以被上层 From 转走)。
练习 6:用 thiserror 派生错误类型
难度:★★★
要求:用 thiserror 把练习 5 重写成一个 enum ConfigError,至少包含 4 个变体:Missing { path: PathBuf }、Empty { key: String }、BadNumber { key: String, source: ParseIntError }(用 #[source])、Io { source: std::io::Error }(用 #[from]),另加一个 #[error(transparent)] 的兜底变体。给出 cargo add 命令、Cargo.toml 片段和完整代码。
提示:想给 BadNumber 带上 key,就不能用 #[from],要在调用点 map_err。
参考答案(先自己写再看)
参考答案(先自己写再看)
powershell
cargo add thiserrortoml
# Cargo.toml
[dependencies]
thiserror = "2"rust
use std::num::ParseIntError;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
enum ConfigError {
#[error("配置文件不存在:{path}")]
Missing { path: PathBuf },
#[error("字段 `{key}` 为空")]
Empty { key: String },
#[error("字段 `{key}` 不是整数")]
BadNumber {
key: String,
#[source] // 标记为错误链的下一环
source: ParseIntError,
},
#[error("读写配置文件失败")]
Io {
#[from] // 自动生成 impl From<std::io::Error> for ConfigError
source: std::io::Error,
},
#[error(transparent)] // Display 与 source 都直接转发
Other(#[from] Box<dyn std::error::Error + Send + Sync + 'static>),
}
fn parse_port(raw: &str) -> Result<u16, ConfigError> {
if raw.trim().is_empty() {
return Err(ConfigError::Empty { key: "port".into() });
}
// 需要附加 key 字段,所以不能用 #[from],就地 map_err 构造
raw.trim()
.parse()
.map_err(|source| ConfigError::BadNumber { key: "port".into(), source })
}
fn load_config(path: &str) -> Result<u16, ConfigError> {
let text = std::fs::read_to_string(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ConfigError::Missing { path: PathBuf::from(path) }
} else {
ConfigError::Io { source: e } // 也可以用 e.into(),? 之外的场景要显式
}
})?;
parse_port(&text)
}
fn main() {
for path in ["no-such.toml"] {
match load_config(path) {
Ok(port) => println!("port = {port}"),
Err(e) => {
println!("{e}");
let mut cur = std::error::Error::source(&e);
while let Some(s) = cur {
println!(" caused by: {s}");
cur = s.source();
}
}
}
}
println!("{:?}", parse_port("abc"));
}输出:
text配置文件不存在:no-such.toml Err(BadNumber { key: "port", source: ParseIntError { kind: InvalidDigit } })
要点解析:thiserror 只替你写「样板代码」——Display、source()、From,它不替你做错误设计。记住三条规则:#[error("...")] 里的 {字段名} 会走该字段的 Display;每个类型最多一个 #[from](否则 From impl 冲突);需要额外上下文字段时(key),就放弃 #[from] 改用 map_err。这正是「thiserror 减少代码但不消灭上下文工作」的分界线。
练习 7:用 anyhow 快速传播与加上下文
难度:★★★
要求:用 anyhow 写一个三步流程 fn deploy(cfg_path: &str) -> anyhow::Result<u16>:读配置文件(失败 → 上下文「读取配置 {path}」);解析出 port: u16(失败 → 上下文「解析端口 {raw}」,用 with_context);校验端口 ≥ 1024(失败 → 用 bail! 给出人话);用 ensure! 保证 cfg_path 不以 test_ 开头。main 返回 anyhow::Result<()>,用 {e:#} 和 {e:?} 分别打印。
提示:with_context 接收返回 String 的闭包,所以 format! 写在里面。
参考答案(先自己写再看)
参考答案(先自己写再看)
powershell
cargo add anyhowtoml
# Cargo.toml
[dependencies]
anyhow = "1"rust
use anyhow::{bail, ensure, Context, Result};
const MIN_PORT: u16 = 1024;
fn deploy(cfg_path: &str) -> Result<u16> {
// 先做廉价的前置校验,避免无意义的 IO
ensure!(
!cfg_path.starts_with("test_"),
"拒绝把测试配置 `{cfg_path}` 用于部署"
);
// 读文件:静态上下文用 context
let raw = std::fs::read_to_string(cfg_path)
.with_context(|| format!("读取配置 `{cfg_path}`"))?;
// 解析:需要格式化,用 with_context(惰性求值,成功路径零开销)
let port: u16 = raw
.trim()
.parse()
.with_context(|| format!("解析端口 `{}`", raw.trim()))?;
// 业务校验:bail! 直接返回一个人话错误
if port < MIN_PORT {
bail!("端口 {port} 小于 {MIN_PORT},需要 root 权限才能绑定,请换一个");
}
Ok(port)
}
fn main() -> Result<()> {
// 构造一个真实的临时配置,跑通成功路径
let path = std::env::temp_dir().join("dsh_deploy_demo.toml");
std::fs::write(&path, "8080\n")?;
let path = path.to_string_lossy().to_string();
match deploy(&path) {
Ok(port) => println!("成功,port = {port}"),
Err(e) => println!("失败:{e:#}"),
}
// 失败路径 1:读不到文件 —— 展示完整因果链
if let Err(e) = deploy("no-such-config.toml") {
println!("--- 完整链 ---\n{e:?}");
}
// 失败路径 2:端口太小 —— 展示 bail! 的效果
let low = std::env::temp_dir().join("dsh_low_port.toml");
std::fs::write(&low, "80\n")?;
if let Err(e) = deploy(&low.to_string_lossy()) {
println!("--- 业务校验 ---\n{e:?}");
}
// 失败路径 3:ensure! 拦截
if let Err(e) = deploy("test_config.toml") {
println!("--- ensure! ---\n{e:?}");
}
Ok(())
}输出(形如):
text成功,port = 8080 --- 完整链 --- 读取配置 `no-such-config.toml` Caused by: 系统找不到指定的文件。 (os error 2) --- 业务校验 --- 端口 80 小于 1024,需要 root 权限才能绑定,请换一个 --- ensure! --- 拒绝把测试配置 `test_config.toml` 用于部署
要点解析:context 用于静态字符串(省一次分配),with_context 用于需要 format! 的场合并且惰性——成功路径上不会白白格式化字符串。bail! / ensure! 是「立即返回」的语法糖,注意 ensure!(cond, fmt, args...) 只在 cond 为假时求值参数。{e:#} 只打印最外层(给用户看),{e:?} 打印完整链(给开发者看)——这是 anyhow 最实用的两个格式开关。
练习 9:判断错误处理代码能否编译(进阶)
难度:★★☆
要求:判断下面四段能否编译,说明理由。
rust
// A
fn a() -> Result<(), Box<dyn std::error::Error>> {
let n: i32 = "12".parse()?;
let _ = n;
Ok(())
}
// B
fn b() -> Result<(), Box<dyn std::error::Error>> {
let inner: Result<(), Box<dyn std::error::Error + Send + Sync>> = Err("x".into());
inner?;
Ok(())
}
// C
fn c(v: Option<i32>) -> Result<i32, String> {
let n = v?;
Ok(n)
}
// D
fn d(v: Result<i32, String>) -> Option<i32> {
let n = v?;
Some(n)
}提示:B 看两个 Box<dyn Error> 之间有没有 From;C、D 看「函数返回类型」和「? 作用的类型」是否匹配(注意 FromResidual)。
参考答案(先自己写再看)
参考答案(先自己写再看)
A:能编译。 Rust 2024 起 Box<dyn Error> 自身实现了 Error(更早的版本 Box<dyn Error> 不实现 Error,靠 From<E> for Box<dyn Error> 与「Box<dyn Error> 是 Residual 可接受类型」的专门支持也能通过)。ParseIntError 通过 impl From<E: Error + 'static> for Box<dyn Error> 自动装箱。
B:不能编译(E0277)。
text
error[E0277]: `?` couldn't convert the error: `dyn std::error::Error + Send + Sync: Sized`
is not satisfied
= note: required for `Box<dyn std::error::Error + Send + Sync>` to implement `std::error::Error`
= note: required for `Box<dyn std::error::Error>` to implement
`From<Box<dyn std::error::Error + Send + Sync>>`标准库没有 impl From<Box<dyn Error + Send + Sync>> for Box<dyn Error>,而且 Box<dyn Error + Send + Sync> 不满足 From<E: Error + 'static> 的 Sized 要求(dyn ... + Send + Sync 是 ?Sized)。修法:统一把返回类型写成 Box<dyn Error + Send + Sync>。
C:不能编译(E0277)。 函数返回 Result<i32, String>,但 v? 的 None 分支要 return None——Option 的残差不能被 Result 的返回类型接收。修法:let n = v.ok_or("缺少值")?;。
D:不能编译(E0277)。 反方向同样不行:函数返回 Option<i32>,而 v? 在 Err 分支要 return Err(e),Result 的残差不能进 Option。修法:let n = v.ok()?;。
结论:Option 与 Result 的 ? 不互通,跨类型必须显式转换(.ok() / .ok_or(...))。这类题的错误信息都指向 FromResidual——看到这个名词,先看两边分别是 Option 还是 Result。
练习 10:读懂真实报错并给出两种修法
难度:★★☆
要求:把下面这段代码原样保存为 src/main.rs,cargo build,把完整错误信息抄下来,然后给出两种修法(一种改 main,一种不改 main 但保留错误信息)。
rust
fn read_number(path: &str) -> Result<i32, std::num::ParseIntError> {
let text = std::fs::read_to_string(path).unwrap();
text.trim().parse()
}
fn main() {
let n = read_number("data.txt")?;
println!("{n}");
}提示:两个问题:main 的返回类型,以及 unwrap 把 io::Error 丢掉了。
参考答案(先自己写再看)
参考答案(先自己写再看)
编译输出(读者实测,路径会不同):
text
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option`
(or another type that implements `FromResidual`)
--> src/main.rs:7:33
|
6 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
7 | let n = read_number("data.txt")?;
| ^ cannot use the `?` operator in a function that returns `()`这就是「? 用在了返回 Result 以外的函数里」的 E0277:main 返回 ()。
修法一(改 main 的返回类型,最小改动):
rust
use std::error::Error;
fn read_number(path: &str) -> Result<i32, Box<dyn Error>> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("读取 `{path}` 失败: {e}"))?; // 把 io 错误带进 Box<dyn Error>
Ok(text.trim().parse()?)
}
fn main() -> Result<(), Box<dyn Error>> {
let n = read_number("data.txt")?;
println!("{n}");
Ok(())
}修法二(不改 main,在 main 里显式处理):
rust
use std::error::Error;
fn read_number(path: &str) -> Result<i32, Box<dyn Error>> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("读取 `{path}` 失败: {e}"))?;
Ok(text.trim().parse()?)
}
fn main() {
match read_number("data.txt") {
Ok(n) => println!("{n}"),
Err(e) => {
eprintln!("出错了:{e}");
std::process::exit(1);
}
}
}要点解析:原代码有两个问题:main 返回 () 不能接 ?;read_number 用 unwrap() 把 io::Error 直接 panic 掉了,函数签名只声明 ParseIntError——这等于对外撒谎「我只会因为解析失败而失败」。修法一把 io::Error 保留成 Box<dyn Error>(或用 anyhow),修法二则在 main 里掌握打印与退出码。注意 map_err(|e| format!(...)) 会丢掉 source() 链(String 不是 Error),所以它只适合顶层;要保留链就用自定义 enum 或 anyhow::Context。
练习 11:设计对外稳定的错误枚举
难度:★★★
要求:实现 fn count_lines(path: &str) -> Result<usize, MyError>,其中 MyError 是你自己的 #[non_exhaustive] enum(至少两个变体:Io(std::io::Error) 和 NotUtf8 { line: usize })。要求:
- 用
File::open+BufReader::new(...).read_line(不用lines())逐行读取; - 非 UTF-8 内容返回
NotUtf8(这需要自己判断,可用String::from_utf8尝试); - 为错误类型实现
Display+Error+From<std::io::Error>; - 在
#[cfg(test)]里写两个测试:一个断言不存在的文件返回Io变体(用matches!),一个断言NotUtf8的Display文案包含行号。
提示:read_until(b'\n', &mut buf) 读原始字节,再用 std::str::from_utf8 校验;测试第二个变体时可以构造错误值直接测 to_string()。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
use std::error::Error;
use std::fmt;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
#[non_exhaustive]
#[derive(Debug)]
pub enum MyError {
Io { path: PathBuf, source: std::io::Error },
NotUtf8 { line: usize },
}
impl fmt::Display for MyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MyError::Io { path, .. } => write!(f, "读取 `{}` 失败", path.display()),
MyError::NotUtf8 { line } => write!(f, "第 {line} 行不是合法的 UTF-8"),
}
}
}
impl Error for MyError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
MyError::Io { source, .. } => Some(source),
MyError::NotUtf8 { .. } => None,
}
}
}
impl From<std::io::Error> for MyError {
fn from(source: std::io::Error) -> Self {
// 只有 io::Error 时不知道 path,用占位符表示「来源未知」
MyError::Io { path: PathBuf::from("<unknown>"), source }
}
}
/// 统计文本行数。用 read_until 读原始字节,再校验 UTF-8。
pub fn count_lines(path: &str) -> Result<usize, MyError> {
let path_buf = PathBuf::from(path);
let file = File::open(&path_buf).map_err(|source| MyError::Io {
path: path_buf.clone(),
source,
})?;
let mut reader = BufReader::new(file);
let mut buf: Vec<u8> = Vec::new();
let mut line_no = 0usize;
loop {
buf.clear();
let read = reader.read_until(b'\n', &mut buf).map_err(|source| MyError::Io {
path: path_buf.clone(),
source,
})?;
if read == 0 {
break; // EOF
}
line_no += 1;
std::str::from_utf8(&buf).map_err(|_| MyError::NotUtf8 { line: line_no })?;
}
Ok(line_no)
}
fn main() {
match count_lines("no-such-file.txt") {
Ok(n) => println!("{n} 行"),
Err(e) => {
println!("{e}");
let mut cur = e.source();
while let Some(s) = cur {
println!(" caused by: {s}");
cur = s.source();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_file_yields_io_variant() {
let err = count_lines("definitely-not-here.txt").unwrap_err();
// matches! 是断言 enum 变体的通用手法(io::Error 没实现 PartialEq)
assert!(matches!(err, MyError::Io { .. }), "实际是 {err:?}");
}
#[test]
fn not_utf8_message_contains_line_number() {
let err = MyError::NotUtf8 { line: 7 };
let msg = err.to_string();
assert!(msg.contains('7'), "错误文案应包含行号,实际是 {msg}");
assert!(err.source().is_none(), "NotUtf8 没有底层原因");
}
#[test]
fn nonexhaustive_enum_still_matchable_with_wildcard() {
// 因为 #[non_exhaustive],外部 crate 的 match 必须带兜底分支;
// 本 crate 内部可以穷尽,但写成兜底更稳
let err = MyError::NotUtf8 { line: 1 };
let desc = match err {
MyError::Io { ref path, .. } => format!("io @ {}", path.display()),
MyError::NotUtf8 { line } => format!("bad utf8 @ {line}"),
_ => "未知".to_string(),
};
assert_eq!(desc, "bad utf8 @ 1");
}
}输出(
cargo run):text读取 `no-such-file.txt` 失败 caused by: 系统找不到指定的文件。 (os error 2)
cargo test:3 个测试全部通过。
要点解析:read_until 返回原始字节,所以可以在解析前用 std::str::from_utf8 校验并给出「哪一行」——这正是「分层错误类型」的价值:NotUtf8 是业务语义错误,不是 IO 错误。#[non_exhaustive] 让这个 pub enum 未来可以加变体而不破坏下游。测试里用 matches!(err, MyError::Io { .. }) 而不是 assert_eq!,因为 io::Error 没有 PartialEq。
练习 12:panic 捕获与 Mutex 毒化实验
难度:★★★
要求:写一个 Mutex + catch_unwind 的实验:
- 用
Arc<Mutex<Vec<i32>>>,起两个线程,一个正常 push,一个在持锁时panic!; - 主线程
join两个线程,然后检查mutex.lock()是否返回Err(毒化); - 用
lock().unwrap_or_else(|e| e.into_inner())从毒化中恢复并打印当前内容; - 用一句话注释说明:为什么这里用
unwrap_or_else而不是unwrap()。
提示:PoisonError::into_inner() 拿回 MutexGuard。线程 panic 会打印到 stderr,属正常现象。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
use std::panic;
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let shared = Arc::new(Mutex::new(Vec::<i32>::new()));
let a = Arc::clone(&shared);
let worker_ok = thread::spawn(move || {
a.lock().unwrap().push(1);
a.lock().unwrap().push(2);
});
let b = Arc::clone(&shared);
let worker_boom = thread::spawn(move || {
// 用 catch_unwind 抓住 panic,避免整个进程受影响;
// 注意:即使在这里 catch,Mutex 已经因为 panic 在临界区内而被毒化
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let mut guard = b.lock().unwrap();
guard.push(3);
panic!("在持锁状态下模拟失败");
}));
assert!(result.is_err(), "闭包应该 panic");
});
worker_ok.join().unwrap();
worker_boom.join().unwrap();
// 现在锁被毒化:任何 lock() 都会得到 Err(PoisonError)
match shared.lock() {
Ok(guard) => println!("未毒化,内容 {guard:?}"),
Err(poisoned) => {
// into_inner() 拿回 MutexGuard,读到 panic 前已写入的数据
let guard = poisoned.into_inner();
println!("锁已被毒化,恢复后的内容 {guard:?}");
}
}
// 惯用的「我知道怎么恢复」写法:
// 这里用 unwrap_or_else 而不是 unwrap(),因为我们**预期**锁可能被毒化,
// 且数据结构(Vec<i32>)不存在「半更新」的不一致状态,所以能安全恢复。
let guard = shared.lock().unwrap_or_else(|e| e.into_inner());
println!("恢复后长度 = {}", guard.len());
}输出(stderr 会有两个 panic 报告,属正常):
text锁已被毒化,恢复后的内容 [1, 2, 3] 恢复后长度 = 3
要点解析:Rust 的锁毒化(poisoning)机制 = 「持锁线程 panic 了,锁里的数据可能处于不完整状态」。它不是内存不安全(Mutex 永远保证互斥),只是「逻辑一致性可能被破坏」的提醒。因此:
- 数据是简单容器(
Vec、计数器)时,unwrap_or_else(|e| e.into_inner())恢复是常见且安全的; - 数据有复杂不变量(如「两个
Vec长度必须相等」)时,应该unwrap()让进程崩掉,而不是带着坏数据继续跑; catch_unwind在这里只是让实验能继续,不是错误处理——真实代码里应该用Result表达「任务失败」,用join()的返回值或消息通道收集结果。