07. Error Handling — 错误处理
错误处理是程序设计中最容易被低估的挑战。我们大多数人希望错误「最好不要发生」——它们打乱控制流,让代码膨胀,而且难以测试。但现实世界的程序必须应对文件不存在、网络断开、输入格式非法等种种意外。Rust 的错误处理策略在编译时强制你正视这些可能——代码运行之前,你必须先想好错误该怎么办。
Rust 将错误分为两大类:
| 类别 | 类型 | 用途 | 默认行为 |
|---|---|---|---|
| 不可恢复的错误 | panic! | Bug、不变量违反 | 栈展开,线程退出 |
| 可恢复的错误 | Result<T, E> | 外部因素引起的失败 | 编译器强制处理 |
一条好经验:不要 panic。把 panic 留给那些「绝对不该发生」的情况——比如索引越界、除零、违反关键的内部不变量。对于文件打开失败、网络超时、用户输入非法等可能发生的情况,永远用 Result。
panic!:不可恢复的错误
当程序遇到无法继续的致命错误时,会触发 panic:
// 显式触发 panic
panic!("something went terribly wrong: {}", detail);
// 自动 panic 的场景
let v = vec![1, 2, 3];
let x = v[99]; // 索引越界 → panic
let y = 1 / 0; // 整数除零 → panic
let z = result.unwrap(); // 对 Err 调用 unwrap → panic
let z = result.expect("msg"); // 对 Err 调用 expect → panic
assert!(false); // 断言失败 → panic栈展开
panic 的默认行为是栈展开(stack unwinding):从 panic 点反向遍历调用栈,依次运行每个栈帧中局部变量的 drop 方法——释放资源、关闭文件、归还锁——然后线程退出:
fn pirate_share(total: u64, crew_size: usize) -> u64 {
let half = total / 2;
half / crew_size as u64 // 如果 crew_size == 0,这里 panic
}
fn main() {
let share = pirate_share(100, 0);
// 不会执行到这里——pirate_share 中的除零导致 panic
// 栈展开会释放 main 中所有已初始化的局部变量
}panic 是安全的——它不会违反 Rust 的内存安全保证。未初始化的变量不会被 drop,已初始化的变量按正确顺序释放。panic 是每线程的:一个线程 panic 不影响其他线程继续运行。
可以用 std::panic::catch_unwind() 捕获栈展开:
use std::panic;
let result = panic::catch_unwind(|| {
panic!("oops!");
});
assert!(result.is_err());catch_unwind 主要用于 FFI 边界(C 代码不能处理 Rust 的 panic)和测试框架。不应作为常规错误处理机制。
Abort
在某些环境中(嵌入式、不希望展开),可以编译为 abort 模式:
# Cargo.toml
[profile.release]
panic = "abort"或者在编译命令中:rustc -C panic=abort。发生 panic 时程序立即终止,不执行 drop。注意:两次连续 panic(如在 drop 中再次 panic)无论配置如何都会 abort。
Result<T, E>:可恢复的错误
Result 是 Rust 错误处理的核心类型:
enum Result<T, E> {
Ok(T), // 成功,包含成功值
Err(E), // 失败,包含错误值
}任何可能失败的操作都应返回 Result:
use std::fs::File;
use std::io;
fn read_config(path: &str) -> Result<String, io::Error> {
// File::open 返回 Result<File, io::Error>
let mut file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}Rust 要求你处理每一个 Result 值——编译器会对未使用的 Result 发出警告。这确保了错误不会被静默忽略。
处理 Result
最基本的方式是 match:
match read_config("config.toml") {
Ok(contents) => println!("Config: {}", contents),
Err(e) => eprintln!("Failed to read config: {}", e),
}Result 提供了丰富的辅助方法:
| 方法 | 说明 |
|---|---|
is_ok() | 如果是 Ok 返回 true |
is_err() | 如果是 Err 返回 true |
ok() | Result<T, E> → Option<T> |
err() | Result<T, E> → Option<E> |
unwrap() | 获取 Ok 值,否则 panic |
expect(msg) | 获取 Ok 值,否则 panic(带消息) |
unwrap_or(default) | 获取 Ok 值或默认值 |
unwrap_or_else(f) | 获取 Ok 值或执行闭包产生默认值 |
as_ref() | &Result<T, E> → Result<&T, &E> |
as_mut() | &mut Result<T, E> → Result<&mut T, &mut E> |
使用示例:
let result: Result<i32, &str> = Ok(42);
// unwrap_or:提供回退值
let value = result.unwrap_or(0); // Ok 时返回 42,Err 时返回 0
// unwrap_or_else:惰性计算回退值
let value = result.unwrap_or_else(|e| {
eprintln!("Using default due to: {}", e);
0
});
// as_ref:在不消费 Result 的情况下借用内部值
let r = result.as_ref(); // r: Result<&i32, &&str>
match r {
Ok(n) => println!("Got: {}", n),
Err(e) => println!("Error: {}", e),
}
// result 仍可用类型别名
标准库中的许多模块为 Result 定义了类型的别名,避免重复写错误类型:
// std::io::Result 的定义
type Result<T> = std::result::Result<T, std::io::Error>;
// 使用
fn open_config() -> io::Result<String> {
// 等价于 Result<String, io::Error>
let mut f = File::open("config.toml")?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}打印错误
错误类型通常实现了 Display 和 Debug:
let err = File::open("nonexistent.txt").unwrap_err();
// {} 输出简洁的错误消息
println!("Error: {}", err);
// Error: No such file or directory (os error 2)
// {:?} 输出详细的调试信息
println!("Error: {:?}", err);
// Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }
// to_string() 将错误转为 String
let msg = err.to_string();标准错误类型可以链式追溯根本原因:
use std::error::Error;
fn print_error(mut err: &dyn Error) {
eprintln!("Error: {}", err);
// 遍历错误链——每一层是原始错误的「源头」
while let Some(source) = err.source() {
eprintln!("Caused by: {}", source);
err = source;
}
}? 运算符:错误传播
? 是 Rust 错误处理中最常用的运算符。它让错误传播变得简洁:
fn process() -> Result<String, io::Error> {
let mut f1 = File::open("input.txt")?; // 出错时立即返回 Err
let mut f2 = File::open("output.txt")?; // 同上
let mut data = String::new();
f1.read_to_string(&mut data)?; // 同上
write!(f2, "{}", data)?; // write! 返回 Result
Ok(data)
}? 的展开等价于:
let mut f1 = match File::open("input.txt") {
Ok(file) => file,
Err(e) => return Err(e.into()), // .into() 做自动错误类型转换
};? 不只用于 Result,也可用于 Option:
fn first_even(nums: &[i32]) -> Option<&i32> {
for n in nums {
if n % 2 == 0 {
return Some(n);
}
}
// ? 在此上下文中对 None 返回 None
Some(nums.first()?) // 如果 first() 返回 None,则传播 None
}关键的约束:? 只能用在返回 Result(或 Option)的函数中,且错误类型必须兼容(通过 From Trait 自动转换)。
处理多种错误类型
函数可能产生多种类型的错误。以下函数既要处理 IO 错误,又要处理整数解析错误:
use std::num::ParseIntError;
fn read_numbers(path: &str) -> Result<Vec<i32>, /* 什么类型? */> {
let content = std::fs::read_to_string(path)?; // io::Error
let mut nums = Vec::new();
for line in content.lines() {
let n: i32 = line.trim().parse()?; // ParseIntError
nums.push(n);
}
Ok(nums)
}这会产生编译错误——? 尝试将 ParseIntError 转换为 io::Error,但 From trait 没有对应的实现。
方案一:自定义错误类型
#[derive(Debug)]
enum MyError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
}
impl std::fmt::Display for MyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MyError::Io(e) => write!(f, "IO error: {}", e),
MyError::Parse(e) => write!(f, "Parse error: {}", e),
}
}
}
impl std::error::Error for MyError {}
// 关键:实现 From trait 让 ? 自动转换
impl From<std::io::Error> for MyError {
fn from(e: std::io::Error) -> Self { MyError::Io(e) }
}
impl From<std::num::ParseIntError> for MyError {
fn from(e: std::num::ParseIntError) -> Self { MyError::Parse(e) }
}
fn read_numbers(path: &str) -> Result<Vec<i32>, MyError> {
let content = std::fs::read_to_string(path)?; // io::Error → MyError::Io
let mut nums = Vec::new();
for line in content.lines() {
let n: i32 = line.trim().parse()?; // ParseIntError → MyError::Parse
nums.push(n);
}
Ok(nums)
}方案二:Box<dyn Error> 通用错误类型
更简单的方法——擦除具体的错误类型:
type GenericResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync + 'static>>;
fn read_numbers(path: &str) -> GenericResult<Vec<i32>> {
let content = std::fs::read_to_string(path)?;
let mut nums = Vec::new();
for line in content.lines() {
let n: i32 = line.trim().parse()?;
nums.push(n);
}
Ok(nums)
}如果需要按类型分派处理,可以用 downcast_ref:
fn handle_error(err: &dyn std::error::Error) {
if let Some(io_err) = err.downcast_ref::<std::io::Error>() {
eprintln!("IO error kind: {:?}", io_err.kind());
} else if let Some(parse_err) = err.downcast_ref::<std::num::ParseIntError>() {
eprintln!("Parse error, check the input format");
}
}方案三:用 thiserror 库
thiserror 库通过派生宏消除了样板代码:
use thiserror::Error;
#[derive(Error, Debug)]
enum MyError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error), // #[from] 自动生成 From 实现
#[error("Parse error: {0}")]
Parse(#[from] std::num::ParseIntError),
#[error("Config key '{key}' not found")]
MissingKey { key: String }, // 结构化错误变体
}#[error("...")] 指定 Display 的格式字符串,#[from] 自动生成 From<T> 实现。
自定义错误类型的最佳实践
一个完整的自定义错误类型:
use std::fmt;
#[derive(Debug)]
struct JsonError {
message: String,
line: usize,
column: usize,
}
// 错误必须能够显示
impl fmt::Display for JsonError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} (line {}, column {})", self.message, self.line, self.column)
}
}
// 实现 Error trait(默认方法通常就够用)
impl std::error::Error for JsonError {}
// 便捷构造方法
impl JsonError {
fn new(message: &str, line: usize, column: usize) -> Self {
JsonError {
message: message.to_string(),
line,
column,
}
}
}处理「不可能」发生的错误
有时你「知道」某个操作不会失败——比如刚检查了键的存在就去取值。在这种情况下 unwrap() 是合理的:
let mut map = HashMap::new();
map.insert("key", 42);
// 刚插入的键必然存在——unwrap 是安全的
let val = map.get("key").unwrap();
// 或者用 expect 附带你的推理
let val = map.get("key")
.expect("'key' was just inserted, so it must exist");但要保持警惕——你以为的「不可能」可能在意料之外的情况下发生。unwrap() 在代码审查中是一个信号:确认你是否真的证明了这个操作不会失败。
忽略错误
有时错误确实无关紧要。用 let _ = 来显式忽略:
// writeln! 返回 io::Result,但在日志场景中失败了也只能忽略
let _ = writeln!(stderr(), "Failed to process item: {}", item_id);let _ = 比直接调用而不赋值要好——它明确表达了「我知道这里可能出错,但选择忽略」。
main() 中的错误处理
main() 函数也可以返回 Result:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = read_config()?;
let server = Server::new(config)?;
server.run()?;
Ok(())
}程序结束时,如果 main 返回 Err,Rust 会打印错误并退出(退出码非零)。这种方式让 ? 贯穿整个程序。
另一种方式是 main 中手动处理:
fn main() {
if let Err(e) = run_app() {
eprintln!("Fatal error: {}", e);
if let Some(source) = e.source() {
eprintln!("Caused by: {}", source);
}
std::process::exit(1);
}
}
fn run_app() -> Result<(), Box<dyn std::error::Error>> {
// 所有 ? 在这里自然流动
Ok(())
}何时 panic,何时用 Result
| 场景 | 建议 |
|---|---|
| 原型开发 | unwrap() / expect() 可接受 |
| 测试代码 | unwrap() —— 测试失败 = bug |
| 不变量违反(逻辑错误) | panic! |
| 公共 API | 永不 panic,一定返回 Result |
| 外部输入(文件、网络、用户) | Result |
| 可以 graceful 降级的失败 | Result |
| 后续代码在不变量满足时才有意义 | panic! |
核心原则:边界处用 Result,内部不变量用 panic。库的作者不应 panic(让调用者决定如何处理失败),应用程序的作者可以更自由地选择。
为什么是 Result
对比其他语言的错误处理方式:
| 语言 | 机制 | 问题 |
|---|---|---|
| C | 返回码 + errno | 容易忽略,不强制检查 |
| C++ | 异常 | 隐式控制流,性能开销,异常安全难保证 |
| Java | 受检异常 | 样板代码多,lambda 不友好 |
| Go | 多返回值 (T, error) | 可能忽略,没有 ? 的便利 |
| Python | 异常 | 动态类型,运行前不检查 |
Rust 的 Result + ? 组合:
- 错误路径在代码中可见。 每个
?标记了一个可能的返回点。没有隐藏的控制流。 - 编译器强制你不忽略错误。 未使用的
Result产生警告。不会静默吞掉错误。 - 零运行时开销。
Result是一个简单的枚举,没有异常表、没有栈展开逻辑。 - 明确区分可恢复与不可恢复。
Resultvspanic!的二分类在设计阶段就迫使你做出决策。
代价是:你会在 Rust 中花更多时间思考和设计错误处理策略。Rust 社区认为这是值得的投资——它在编译时消灭了一整类生产环境 bug。
小结
- 两类错误:
panic!用于不可恢复的 Bug(不变量违反),Result<T, E>用于可恢复的外部失败。默认不 panic。 panic!触发栈展开:释放局部变量后线程退出。安全但不应作为常规错误处理。catch_unwind用于 FFI 边界。Result枚举:Ok(T)/Err(E)。编译器强制处理——未使用会产生警告。?运算符是match+return Err的语法糖。传播错误到调用者,同时通过From自动转换错误类型。- 多种错误类型处理:自定义枚举 +
From实现、Box<dyn Error>、或thiserror库。downcast_ref可在擦除后恢复具体类型。 main()可返回Result:让?贯穿整个程序。错误被自动打印,进程以非零码退出。- 每条
?可见,每个Result必须被使用。 Rust 让错误处理路径成为一等公民,代价是需要在设计阶段投入更多思考。