课程概览 · 第 13 章

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

下一篇:模块、属性与宏:控制边界,减少重复

任务文件可能不存在、某一行可能是空标题、配置里的端口可能不是数字–这些是可恢复失败,调用方应当能决定重试、报错或忽略。而“任务 ID 为 0 的任务出现在了内存里”是程序缺陷,它说明代码已经违反自己的基本假设。Rust 用 Result 表示前者、用 panic 表示后者;混淆两类后果是错误处理最大的错误。

Option 正常缺失、Result 可恢复失败、panic 不变量破坏,以及错误上下文和最终策略所在的层级
图:错误从底层保留原因,在领域层补上下文,最终由应用入口决定日志、重试和退出。

学习目标与默认选择

学完本章你应当能够:

  • 区分正常缺失(Option)、可恢复失败(Result)、程序缺陷(panic)三种情况;
  • 为领域操作定义实现 Displaystd::error::ErrorFrom 转换的错误枚举;
  • ?map_err 组合错误,并在边界处补充上下文(如行号);
  • 说明库与应用的错误策略差异,以及 anyhow/thiserror 的定位。

默认选择:能被调用方恢复的失败一律 Result;正常的“没有”用 Option;只有违反内部不变式才 panic。unwrap/expect 只出现在测试或刚刚验证过的不变量处。

三类结果的决策规则

情况表达方式示例谁来处理
有值或正常缺失Option<T>按 ID 查任务不存在、可选字段当前函数,通常不是错误
成功或可恢复失败Result<T, E>文件不存在、格式错误、超时调用方(重试、降级、上报)
程序缺陷panic!内部数组非空却取到空、状态机进入非法组合没有人能处理;进程/线程应当失败

判断口径:如果调用方拿到这个信息后能做一件不同的事(重试、跳过、提示用户),它就是 Result 的变体;如果任何调用方拿到它都只能修代码,它才是 panic。

一个完整的错误类型与任务加载器

逐行读取任务文件,把每种失败建模成命名变体。这是本章的核心示例:

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
109
110
111
112
113
114
115
116
117
118
119
120
121
use std::error::Error;
use std::fmt;
use std::fs;
use std::io;
use std::num::ParseIntError;
use std::path::Path;

#[derive(Debug)]
enum TaskLoadError {
/// 读取文件失败(不存在、权限、磁盘)。
Io(io::Error),
/// 某一行标题为空。携带行号,调用方可以直接指向问题行。
EmptyTitle { line: usize },
/// ID 不是合法的 u64。
InvalidId { line: usize, source: ParseIntError },
}

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

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

/// 让 `?` 能把 io::Error 自动转换为 TaskLoadError::Io。
impl From<io::Error> for TaskLoadError {
fn from(error: io::Error) -> Self {
TaskLoadError::Io(error)
}
}

/// 让 `?` 能把 ParseIntError 转换为带行号的 InvalidId。
impl From<ParseIntError> for TaskLoadError {
fn from(error: ParseIntError) -> Self {
TaskLoadError::InvalidId { line: 0, source: error }
}
}

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

/// 逐行加载任务。每行格式:"<id> <title>"。
/// 空行跳过;标题为空的行报 EmptyTitle,并带上从 1 开始的行号。
fn load_tasks(path: &Path) -> Result<Vec<Task>, TaskLoadError> {
let text = fs::read_to_string(path)?; // Io 变体由 From 自动转换。
let mut tasks = Vec::new();
for (index, line) in text.lines().enumerate() {
let line_number = index + 1; // enumerate 从 0 开始,行号从 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: u64 = id_text
.parse()
.map_err(|source| TaskLoadError::InvalidId { line: line_number, source })?;
tasks.push(Task { id, title: title.to_string() });
}
Ok(tasks)
}

fn main() {
// 用临时文件驱动真实 I/O 路径。
let dir = std::env::temp_dir().join("rust-course-ch11");
fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("tasks.txt");

// 场景一:正常输入。
fs::write(&path, "1 fix build\n2 review pr\n").expect("write fixture");
let tasks = load_tasks(&path).expect("valid input loads");
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].title, "fix build");

// 场景二(关键必验点):第二行只有 ID 没有标题 -> EmptyTitle { line: 2 }。
fs::write(&path, "1 fix build\n2 \n").expect("write fixture");
let error = load_tasks(&path).expect_err("blank title must fail");
assert!(matches!(error, TaskLoadError::EmptyTitle { line: 2 }));

// Display 输出带行号,source 链保留在枚举里。
assert_eq!(error.to_string(), "第 2 行的标题为空");

// 场景三:ID 不是数字 -> InvalidId,保留底层解析错误。
fs::write(&path, "abc fix build\n").expect("write fixture");
let error = load_tasks(&path).expect_err("bad id must fail");
assert!(matches!(error, TaskLoadError::InvalidId { line: 1, .. }));
assert!(error.source().is_some());

// 场景四:文件不存在 -> Io 变体,保留 io::Error。
let missing = dir.join("missing.txt");
let error = load_tasks(&missing).expect_err("missing file must fail");
assert!(matches!(error, TaskLoadError::Io(_)));
assert!(error.source().is_some());

println!("task loader ok");
}

注意三处细节:

  • split_once(' ') 返回 Optionok_or 把“没有标题”的正常缺失转换为带上下文的 EmptyTitle 错误–OptionResult 的升级发生在“缺失不可接受”的那一刻
  • id_text.parse()ParseIntError 通过 map_err 包进 InvalidId,同时补上行号。From<ParseIntError> 也实现了,但它拿不到行号(只能填 0),所以热路径用 map_err 精确补上下文,From 只作为兜底转换。
  • ? 的语义:遇到 Err 就提前返回,但沿途局部变量照常执行 Drop(文件句柄关闭、缓冲释放)。它不是异常,没有跨函数的隐式栈展开成本,等价于一个自动生成的 match + return

map_err? 与上下文边界

? 只做传播,不添加信息。跨越边界(文件名、行号、请求 ID、配置项)时要补充上下文:

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
use std::error::Error;
use std::num::ParseIntError;

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

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

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

/// 解析端口。三种失败路径分别可判别、可显示、保留 source。
fn parse_port(raw: Option<&str>) -> Result<u16, ConfigError> {
let raw = raw.ok_or(ConfigError::MissingPort)?;
raw.parse::<u16>()
.map_err(|source| ConfigError::InvalidPort { raw: raw.to_string(), source })
}

fn main() {
assert_eq!(parse_port(Some("8080")), Ok(8080));

// 正常缺失(None)与可恢复失败(解析错误)是不同的变体。
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()); // ParseIntError 仍在错误链上。
assert_eq!(error.to_string(), "port 值 \"99999\" 不是合法 u16");

println!("config errors ok");
}

规则:? 管传播,map_err 管翻译。底层错误细节(ParseIntErrorio::Error)通过 source() 保留给日志和调试,公共错误类型只暴露领域语义(哪个字段、哪一行、什么操作)。

库与应用的错误策略

  • :调用方要按种类做不同的事,所以公开稳定、可判别的错误枚举(如 TaskLoadError),让 matches!(err, TaskLoadError::EmptyTitle { .. }) 这种分支成为可能。不要把内部数据库/HTTP 客户端的错误细节不加筛选地泄漏为公共 API–将来换实现就是破坏性变更。
  • 应用:最外层(main)最终只打印、上报或退出,不在乎种类时可以用一个统一类型装上下文。标准库路径下,用自己的错误枚举 + Box<dyn Error> 也能表达;生态里 anyhow(应用侧:类型擦除 + 上下文叠加)和 thiserror(库侧:派生 Display/From/source)是两个常见的命名工具。它们解决的是样板代码问题,不是“隐藏依赖”的许可–引入它们与引入其他依赖用同一套审查标准。

本章的 TaskLoadError 手写了 DisplayErrorFrom,正好展示 thiserror 替你写的那几行是什么。

unwrapexpect、panic 与 catch_unwind

unwrap()Err/None 时 panic,且消息没有上下文。合法场景只有两类:

  1. 测试代码assert_eq! 失败时本来就该停;expect("fixture file") 让失败可读。
  2. 刚刚验证过的不变量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn main() {
let numbers = vec![3, 1, 2];
let mut sorted = numbers.clone();
sorted.sort();

// 合法:上一行刚验证过 sorted 非空且有序。
assert_eq!(sorted.first(), Some(&1));
let smallest = sorted.first().expect("sorted is non-empty by construction");
assert_eq!(*smallest, 1);

// 不合法:对输入直接 unwrap。
let user_input: Option<u32> = None;
// let port = user_input.unwrap(); // 程序会因用户输入 panic -- 用 match/ok_or 处理。
match user_input {
Some(port) => println!("port: {port}"),
None => println!("missing port, using default"),
}
}

catch_unwind 能捕获 panic 并继续运行,但它不是业务错误控制流

  • panic 之后资源按 RAII 清理(Drop 会执行),但程序逻辑状态是否仍一致没有任何保证
  • panic 可以是内存安全边界(某些配置下),依赖“捕获后继续”是拿安全换便利;
  • 它的合理用途极窄:线程池隔离第三方插件的 panic、FFI 边界阻止 unwind 穿越 C 栈。

需要“失败后继续”的业务路径,正确建模就是 Result–编译器会保证每条失败路径都被处理,而不是靠运行时捕获。

边界与失败场景

  • 错误枚举变成垃圾抽屉:变体超过十几个、调用方只会 unwrap_or_default 时,说明多数失败不需要区分;合并为更少的语义变体,细节放 source 链。
  • From 拿不到上下文impl From<io::Error> 没有参数位置信息;需要行号/字段名时在调用点用 map_err 显式包装。
  • 错误信息泄漏内部细节:把 SQL 错误、路径、堆栈直接回给终端用户;公共错误应当指向“哪一步、什么输入”,内部细节进日志。
  • OptionResult 混用:查询任务不存在返回 Err 会让调用方误以为系统故障;正常缺失返回 Ok(None),真正的失败才返回 Err

为什么可行

Result<T, E>Option<T> 都是普通枚举,编译器通过类型系统强迫每个调用点处理两种分支–? 只是把这段样板压缩成一个字符,语义与手写 match 完全一致。panic 则不同:它表示当前执行流无法继续维护安全或逻辑不变量,默认配置下栈展开、沿途 Drop 清理资源,最终终止线程。两套机制覆盖“调用方该处理的失败”与“只有修代码才能解决的缺陷”,边界恰好落在“是否还有可执行的正确动作”上;把缺陷当失败处理会导致每个调用点都在防御不可能的状态,把失败当缺陷处理则让一个用户输入打崩整个进程。

常见误区

  • String 当错误类型Err(String::from("bad port")) 无法被调用方判别,只能匹配文本;用枚举变体。
  • 到处 unwrap 图省事:每个 unwrap 都是潜在的崩溃点,且消息不含任何上下文;至少换成 expect("为什么这里不可能失败")
  • catch_unwind 当 try/catch 用:panic 后状态一致性无保证,资源虽清理但逻辑可能已损坏;业务失败用 Result
  • 吞掉错误let _ = fs::remove_file(path); 连“权限不足导致文件残留”都看不见;至少 if let Err(e) = ... { log(e); }
  • 错误在产生处就写满日志:库函数里 println! 错误会污染调用方的输出策略;返回 Result,让边界层决定怎么记录。

自测

  1. “按 ID 查任务不存在”应该返回 OptionResult 还是 panic?为什么?
    方向:正常缺失用 Option(或 Result<Option<T>, E>);调用方能继续工作,不存在系统故障,更不是程序缺陷。
  2. TaskLoadError::EmptyTitle { line: usize } 为什么把行号放进变体而不是错误消息字符串?
    方向:调用方能编程判别(matches!)并直接定位问题行;字符串只能给人看,格式一变就失效。
  3. ? 遇到 Err 时做了什么?和异常有什么区别?
    方向:提前返回 Err(经 From 转换),沿途局部变量正常 Drop;没有隐式栈展开,等价于自动生成的 match+return。
  4. 库的错误类型为什么要避免泄漏内部依赖的错误细节?
    方向:公开类型是兼容性承诺;换掉内部实现(换数据库、换 HTTP 客户端)不应成为破坏性变更。
  5. 什么时候 expect 是可以接受的?
    方向:测试代码,或“刚刚在同一作用域验证过的 invariant”(如上一步 assert 过非空);对任何外部输入、文件、网络结果都不行。