Skip to content

练习与自测

本章练习共 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"));
}

输出:

text
Ok((1, 2, 3))
Err("需要 3 个字段,实际 2 个:`1,2`")
Err("`x` 不是整数: invalid digit found in string")

要点解析collect::<Result<Vec<_>, _>>()? 是「一串 Result → 一个 Result」的惯用法,遇到第一个 Err 立即短路,等价于手写循环 + ?。注意 ok_or 家族把 OptionResult,而 collect 是「ResultResult」——两者常配合使用。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}`」)与 Errorsource() 返回底层错误)。然后写 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"));
}

输出:

text
Err(Open { path: "&lt;unknown&gt;", 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));
}

输出:

text
Ok((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"));
}

输出:

text
Err(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 thiserror
toml
# 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 只替你写「样板代码」——Displaysource()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 anyhow
toml
# 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()?;

结论OptionResult? 不互通,跨类型必须显式转换(.ok() / .ok_or(...))。这类题的错误信息都指向 FromResidual——看到这个名词,先看两边分别是 Option 还是 Result

练习 10:读懂真实报错并给出两种修法

难度:★★☆

要求:把下面这段代码原样保存为 src/main.rscargo 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 的返回类型,以及 unwrapio::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_numberunwrap()io::Error 直接 panic 掉了,函数签名只声明 ParseIntError——这等于对外撒谎「我只会因为解析失败而失败」。修法一把 io::Error 保留成 Box<dyn Error>(或用 anyhow),修法二则在 main 里掌握打印与退出码。注意 map_err(|e| format!(...))丢掉 source()String 不是 Error),所以它只适合顶层;要保留链就用自定义 enumanyhow::Context

练习 11:设计对外稳定的错误枚举

难度:★★★

要求:实现 fn count_lines(path: &str) -> Result<usize, MyError>,其中 MyError 是你自己的 #[non_exhaustive] enum(至少两个变体:Io(std::io::Error)NotUtf8 { line: usize })。要求:

  1. File::open + BufReader::new(...).read_line(不用 lines())逐行读取;
  2. 非 UTF-8 内容返回 NotUtf8(这需要自己判断,可用 String::from_utf8 尝试);
  3. 为错误类型实现 Display + Error + From<std::io::Error>
  4. #[cfg(test)] 里写两个测试:一个断言不存在的文件返回 Io 变体(用 matches!),一个断言 NotUtf8Display 文案包含行号。

提示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 的实验:

  1. Arc<Mutex<Vec<i32>>>,起两个线程,一个正常 push,一个在持锁时 panic!
  2. 主线程 join 两个线程,然后检查 mutex.lock() 是否返回 Err(毒化);
  3. lock().unwrap_or_else(|e| e.into_inner()) 从毒化中恢复并打印当前内容;
  4. 用一句话注释说明:为什么这里用 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() 的返回值或消息通道收集结果。

本章小结 / 自测清单


内容以 rustc 1.98.1 · Rust 2024 edition 为基准