课程概览 · 第 13 章
上一篇:迭代器与闭包:让数据转换可组合
下一篇:模块、属性与宏:控制边界,减少重复
任务文件可能不存在、某一行可能是空标题、配置里的端口可能不是数字–这些是可恢复失败 ,调用方应当能决定重试、报错或忽略。而“任务 ID 为 0 的任务出现在了内存里”是程序缺陷 ,它说明代码已经违反自己的基本假设。Rust 用 Result 表示前者、用 panic 表示后者;混淆两类后果是错误处理最大的错误。
图:错误从底层保留原因,在领域层补上下文,最终由应用入口决定日志、重试和退出。 学习目标与默认选择 学完本章你应当能够:
区分正常缺失(Option)、可恢复失败(Result)、程序缺陷(panic)三种情况; 为领域操作定义实现 Display、std::error::Error 和 From 转换的错误枚举; 用 ? 和 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 }, 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 , } } }impl From <io::Error> for TaskLoadError { fn from (error: io::Error) -> Self { TaskLoadError::Io (error) } }impl From <ParseIntError> for TaskLoadError { fn from (error: ParseIntError) -> Self { TaskLoadError::InvalidId { line: 0 , source: error } } }#[derive(Debug)] 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 : 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 () { 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" ); 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 })); assert_eq! (error.to_string (), "第 2 行的标题为空" ); 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 ()); 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(' ') 返回 Option,ok_or 把“没有标题”的正常缺失转换为带上下文的 EmptyTitle 错误–Option 到 Result 的升级发生在“缺失不可接受”的那一刻 。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 , } } }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 )); 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" ); println! ("config errors ok" ); }
规则:? 管传播,map_err 管翻译 。底层错误细节(ParseIntError、io::Error)通过 source() 保留给日志和调试,公共错误类型只暴露领域语义(哪个字段、哪一行、什么操作)。
库与应用的错误策略 库 :调用方要按种类做不同的事,所以公开稳定、可判别的错误枚举 (如 TaskLoadError),让 matches!(err, TaskLoadError::EmptyTitle { .. }) 这种分支成为可能。不要把内部数据库/HTTP 客户端的错误细节不加筛选地泄漏为公共 API–将来换实现就是破坏性变更。应用 :最外层(main)最终只打印、上报或退出,不在乎种类时可以用一个统一类型装上下文。标准库路径下,用自己的错误枚举 + Box<dyn Error> 也能表达;生态里 anyhow (应用侧:类型擦除 + 上下文叠加)和 thiserror (库侧:派生 Display/From/source)是两个常见的命名工具。它们解决的是样板代码问题,不是“隐藏依赖”的许可–引入它们与引入其他依赖用同一套审查标准。本章的 TaskLoadError 手写了 Display、Error、From,正好展示 thiserror 替你写的那几行是什么。
unwrap、expect、panic 与 catch_unwindunwrap() 在 Err/None 时 panic,且消息没有上下文。合法场景只有两类:
测试代码 :assert_eq! 失败时本来就该停;expect("fixture file") 让失败可读。刚刚验证过的不变量 :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 (); assert_eq! (sorted.first (), Some (&1 )); let smallest = sorted.first ().expect ("sorted is non-empty by construction" ); assert_eq! (*smallest, 1 ); let user_input : Option <u32 > = None ; 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 错误、路径、堆栈直接回给终端用户;公共错误应当指向“哪一步、什么输入”,内部细节进日志。Option 与 Result 混用 :查询任务不存在返回 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,让边界层决定怎么记录。自测 “按 ID 查任务不存在”应该返回 Option、Result 还是 panic?为什么?方向:正常缺失用 Option(或 Result<Option<T>, E>);调用方能继续工作,不存在系统故障,更不是程序缺陷。 TaskLoadError::EmptyTitle { line: usize } 为什么把行号放进变体而不是错误消息字符串?方向:调用方能编程判别(matches!)并直接定位问题行;字符串只能给人看,格式一变就失效。 ? 遇到 Err 时做了什么?和异常有什么区别?方向:提前返回 Err(经 From 转换),沿途局部变量正常 Drop;没有隐式栈展开,等价于自动生成的 match+return。 库的错误类型为什么要避免泄漏内部依赖的错误细节?方向:公开类型是兼容性承诺;换掉内部实现(换数据库、换 HTTP 客户端)不应成为破坏性变更。 什么时候 expect 是可以接受的?方向:测试代码,或“刚刚在同一作用域验证过的 invariant”(如上一步 assert 过非空);对任何外部输入、文件、网络结果都不行。