课程概览 · 第 13 章
上一篇:迭代器与闭包:让数据转换可组合
下一篇:模块:crate、路径与可见性
文件缺失、输入格式错误和正常的查询缺失,不是同一件事。返回类型应告诉调用方能做什么,而不只是把失败压成一段字符串。本章从任务加载器出发,让领域变体、真实行号与底层原因同时保留下来;它是独立示例,不使用其他章节的 Task 实现。
图:底层保留原因,领域层补上下文,应用边界决定提示与退出策略。 前置知识与学习目标 需要理解枚举、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 当成事务回滚。
自测 查询不存在与查询失败如何在一个返回类型中区分? 为什么 InvalidId 必须在解析点加行号,而不能用 From 填 0? ? 会自动添加上下文吗?Result 是否无法被忽略?两个进程同时运行加载器主例,为什么不会写同一个 fixture? catch_unwind 能否把 panic 变成可靠的业务恢复机制? 参考答案 使用 Result<Option<T>, E>,Ok(None) 是正常缺失,Err 是查询失败。 解析点知道真实行号;From 只拿到解析错误,虚构 0 会丢失定位信息。 不会;上下文由 map_err 等明确添加。Result 通常有诊断,但可有意丢弃。 路径包含进程 ID 与本次 UNIX 时间纳秒;create_dir 失败也返回错误,不复用旧目录。 不能;它不捕获 abort,且展开清理不等于恢复逻辑不变量。