课程概览 · 第 13 章

上一篇:迭代器与闭包:让数据转换可组合

下一篇:模块:crate、路径与可见性

文件缺失、输入格式错误和正常的查询缺失,不是同一件事。返回类型应告诉调用方能做什么,而不只是把失败压成一段字符串。本章从任务加载器出发,让领域变体、真实行号与底层原因同时保留下来;它是独立示例,不使用其他章节的 Task 实现。

Option 表示正常缺失,Result 表示可行动失败,panic 用于程序不变量破坏;上下文与策略分层
图:底层保留原因,领域层补上下文,应用边界决定提示与退出策略。

前置知识与学习目标

需要理解枚举、match、第 05 章的 ? 与文件 I/O。学完本章应能:

  • 区分 Option、Result 与 panic 的适用边界;
  • 为任务加载设计可匹配、可显示、保留 source 的错误;
  • 判断何时用 From,何时必须在调用点用 map_err 补上下文;
  • 说明库、应用和 panic 捕获的不同责任。

Option、Result 与 panic 的决策

情况表达方式例子
正常有值或没有值Option<T>按 ID 查询没有匹配任务
成功或需要处置的失败Result<T, E>文件不存在、非法端口、超时
无法维护内部不变量panic内部声称非空的结构实际为空

若查询本身也可能失败,用 Result<Option<T>, E> 区分“查询成功但没有记录”与“查询失败”。外部输入中的 ID 为 0 应由校验函数返回错误;只有内部已经承诺合法却违反承诺时,才考虑 panic。可恢复不等于当前函数必须恢复,返回 Result 也允许入口选择报错退出。

TaskLoadError 与任务加载器

每行格式是 <id> <title>,空行跳过,标题会 trim,行号从 1 开始。枚举的命名变体让调用者分支处理;Display 面向人,Error::source 保留底层原因。下例只验证 u64 解析,不额外规定 ID 非零。

独立示例(仅标准库;完整内容放 src/main.rs,Edition 2024;执行 cargo +stable run)。示例在系统临时目录创建 rust-course-ch13-进程ID-UNIX时间纳秒,只清理本次目录;两个进程不会共用固定 fixture 路径。I/O 与清理失败通过 main 的 Result 返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
use std::error::Error;
use std::fmt;
use std::fs;
use std::io;
use std::num::ParseIntError;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

#[derive(Debug)]
enum TaskLoadError {
Io(io::Error),
EmptyTitle { line: usize },
InvalidId { line: usize, source: ParseIntError },
}

impl fmt::Display for TaskLoadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(f, "读取任务文件失败: {error}"),
Self::EmptyTitle { line } => write!(f, "第 {line} 行的标题为空"),
Self::InvalidId { line, .. } => {
write!(f, "第 {line} 行的任务 ID 不是合法数字")
}
}
}
}

impl Error for TaskLoadError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Io(error) => Some(error),
Self::InvalidId { source, .. } => Some(source),
Self::EmptyTitle { .. } => None,
}
}
}

impl From<io::Error> for TaskLoadError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}

#[derive(Debug, PartialEq)]
struct Task {
id: u64,
title: String,
}

fn load_tasks(path: &Path) -> Result<Vec<Task>, TaskLoadError> {
let text = fs::read_to_string(path)?;
let mut tasks = Vec::new();
for (index, line) in text.lines().enumerate() {
let line_number = index + 1;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let (id_text, title) = trimmed.split_once(' ')
.ok_or(TaskLoadError::EmptyTitle { line: line_number })?;
let title = title.trim();
if title.is_empty() {
return Err(TaskLoadError::EmptyTitle { line: line_number });
}
let id = id_text.parse().map_err(|source| {
TaskLoadError::InvalidId { line: line_number, source }
})?;
tasks.push(Task { id, title: title.to_owned() });
}
Ok(tasks)
}

fn main() -> Result<(), Box<dyn Error>> {
let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let name = format!("rust-course-ch13-{}-{nanos}", std::process::id());
let dir = std::env::temp_dir().join(name);
fs::create_dir(&dir)?;
let path = dir.join("tasks.txt");
let exercise = (|| -> Result<(), Box<dyn Error>> {
fs::write(&path, "1 fix build\n2 review pr\n")?;
let tasks = load_tasks(&path)?;
assert_eq!(tasks, [
Task { id: 1, title: "fix build".into() },
Task { id: 2, title: "review pr".into() },
]);

fs::write(&path, "1 fix build\n2 \n")?;
let error = load_tasks(&path).expect_err("blank title must fail");
assert!(matches!(error, TaskLoadError::EmptyTitle { line: 2 }));
assert_eq!(error.to_string(), "第 2 行的标题为空");
assert!(error.source().is_none());

fs::write(&path, "abc fix build\n")?;
let error = load_tasks(&path).expect_err("bad id must fail");
assert!(matches!(error, TaskLoadError::InvalidId { line: 1, .. }));
assert!(error.source().is_some());

let error = load_tasks(&dir.join("missing.txt"))
.expect_err("missing file must fail");
assert!(matches!(error, TaskLoadError::Io(_)));
assert!(error.source().is_some());
Ok(())
})();
let cleanup = fs::remove_dir_all(&dir);
exercise?;
cleanup?;
Ok(())
}

ok_or 在“缺失不能接受”的边界把 Option 转为 Result。map_err 则在 ID 解析点把 ParseIntError 包成带真实行号的 InvalidId。不能为它写一个以 line: 0 兜底的 From:From 只看到源错误,无法得知当前行号。I/O 变体本身不携带行号,因此 From<io::Error> 可以无损完成这个转换。

fixture 练习完成后,无论 exercise 返回 Ok 还是 Err 都会尝试清理本次目录;若断言 panic,则这段普通控制流不再保证清理。临时目录不是业务级事务或崩溃恢复方案,也不应删除不属于本次运行的路径。

?、From 与 map_err 上下文

在 Result 返回路径上,? 遇到 Ok 提取值,遇到 Err 使用相应的残差转换提前返回;常见的错误类型转换由 From 提供。它不会自动知道文件名、行号、配置键等上下文。提前返回仍按正常作用域释放局部资源,不需要引入异常清理类比。

下例区分端口缺失与解析错误,原输入只在错误路径复制进错误值。实际应用若输入敏感,应避免把原值放进公开错误或日志。

独立示例(仅标准库;替换 src/main.rs;执行 cargo +stable run,不沿用上例的类型)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;

#[derive(Debug, PartialEq)]
enum ConfigError {
MissingPort,
InvalidPort { raw: String, source: ParseIntError },
}

impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingPort => write!(f, "配置缺少 port 项"),
Self::InvalidPort { raw, .. } => {
write!(f, "port 值 {raw:?} 不是合法 u16")
}
}
}
}

impl Error for ConfigError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::InvalidPort { source, .. } => Some(source),
Self::MissingPort => None,
}
}
}

fn parse_port(raw: Option<&str>) -> Result<u16, ConfigError> {
let raw = raw.ok_or(ConfigError::MissingPort)?;
raw.parse().map_err(|source| {
ConfigError::InvalidPort { raw: raw.to_owned(), source }
})
}

fn main() {
assert_eq!(parse_port(Some("8080")), Ok(8080));
assert!(matches!(parse_port(None), Err(ConfigError::MissingPort)));
let error = parse_port(Some("99999")).expect_err("out of range");
assert!(matches!(error, ConfigError::InvalidPort { .. }));
assert!(error.source().is_some());
assert_eq!(error.to_string(), "port 值 \"99999\" 不是合法 u16");
}

库与应用的边界

库应返回调用者需要判别的稳定语义,尽量不要让内部数据库或 HTTP 客户端的具体类型成为公共 API 的偶然承诺。source 链可以保留调试原因,但对外显示仍须按场景筛选,不能直接泄漏路径、敏感输入或内部结构。应用入口再决定记录、提示、重试或退出,不在每一层重复打印同一个错误。

标准库的手写 Error 与 Display 足以表达本章需求;生态中的 thiserror 减少错误类型样板,anyhow 常用于应用侧类型擦除与上下文叠加。这里没有调用它们的源码,也没有隐藏依赖;引入这些工具仍需审查 manifest、版本与公开边界。

类型系统区分成功与失败,但不保证每个调用者都选择了正确策略。Result 的未使用通常触发 must_use 诊断,也可以被 let _ = ... 有意忽略;默认值、忽略或错误记录是否合理,仍是业务决定。

unwrap、expect 与 panic 边界

unwrap 在 Err/None 时 panic;expect 增加调用点说明,却不改变控制流。测试可用 expect 解释 fixture 或不变量,生产代码只有在已证明输入满足条件时才适合使用。对用户端口、文件、网络等可预期失败,把 unwrap 换成 expect 不是修复,应返回或处理 Result。

独立示例(仅标准库;替换 src/main.rs;执行 cargo +stable run)。

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let mut sorted = vec![3, 1, 2];
sorted.sort();
let smallest = sorted.first().expect("constructed from three values");
assert_eq!(*smallest, 1);
let user_input: Option<u32> = None;
match user_input {
Some(port) => println!("port: {port}"),
None => println!("missing port, using default"),
}
}

深入阅读:panic 策略与隔离

默认 unwind 模式下,panic 展开当前线程栈并运行沿途 Drop,未被捕获时终止该线程;主线程终止也结束程序。panic = "abort" 则终止进程,不执行这套展开清理。其他线程是否继续、是否通过 join 传播为错误,取决于具体线程边界,见第 18 章。

catch_unwind 只捕获展开式 panic,不捕获 abort,也不保证捕获后业务状态一致。它适合明确设计的插件或线程隔离边界,不是输入校验或重试机制;跨 FFI 还须遵守 ABI 的 unwind 合同,见第 19 章。释放资源与回滚逻辑状态是两件事,不能把 Drop 当成事务回滚。

自测

  1. 查询不存在与查询失败如何在一个返回类型中区分?
  2. 为什么 InvalidId 必须在解析点加行号,而不能用 From 填 0?
  3. ? 会自动添加上下文吗?Result 是否无法被忽略?
  4. 两个进程同时运行加载器主例,为什么不会写同一个 fixture?
  5. catch_unwind 能否把 panic 变成可靠的业务恢复机制?

参考答案

  1. 使用 Result<Option<T>, E>,Ok(None) 是正常缺失,Err 是查询失败。
  2. 解析点知道真实行号;From 只拿到解析错误,虚构 0 会丢失定位信息。
  3. 不会;上下文由 map_err 等明确添加。Result 通常有诊断,但可有意丢弃。
  4. 路径包含进程 ID 与本次 UNIX 时间纳秒;create_dir 失败也返回错误,不复用旧目录。
  5. 不能;它不捕获 abort,且展开清理不等于恢复逻辑不变量。