错误处理
问题
Rust 的错误处理机制是什么?为什么没有 try/catch?
答案
Rust 将错误分为两类,用不同机制处理:
| 错误类型 | 机制 | 说明 |
|---|---|---|
| 不可恢复错误 | panic! | 程序 bug,直接崩溃(如数组越界、unwrap 失败) |
| 可恢复错误 | Result<T, E> | 预期中的失败(如文件不存在、网络超时) |
panic! — 不可恢复错误
fn main() {
// 显式 panic
panic!("崩溃了!");
// 隐式 panic
let v = vec![1, 2, 3];
v[99]; // index out of bounds → panic
}
panic 的行为
- 默认(unwinding):展开调用栈,逐层执行析构函数清理资源
- abort 模式:直接终止进程,不清理。通过
Cargo.toml设置:
Cargo.toml
[profile.release]
panic = 'abort' # 减小二进制大小
Result — 可恢复错误
use std::fs::File;
use std::io::{self, Read};
fn read_file(path: &str) -> Result<String, io::Error> {
let mut file = File::open(path)?; // 失败则提前返回 Err
let mut content = String::new();
file.read_to_string(&mut content)?;
Ok(content)
}
fn main() {
match read_file("config.toml") {
Ok(content) => println!("{}", content),
Err(e) => eprintln!("读取失败: {}", e),
}
}
? 操作符
? 是错误传播的语法糖,等价于 match + early return:
// 使用 ?
fn read_username() -> Result<String, io::Error> {
let content = std::fs::read_to_string("username.txt")?;
Ok(content.trim().to_string())
}
// 等价的 match 写法
fn read_username_verbose() -> Result<String, io::Error> {
let content = match std::fs::read_to_string("username.txt") {
Ok(c) => c,
Err(e) => return Err(e),
};
Ok(content.trim().to_string())
}
? 还可以链式调用:
fn read_username() -> Result<String, io::Error> {
let mut s = String::new();
File::open("username.txt")?.read_to_string(&mut s)?;
Ok(s.trim().to_string())
}
? 的适用条件
?只能用在返回Result或Option的函数中?会自动调用From::from()转换错误类型(如果目标Err类型实现了From<源错误>)main函数中使用?需要改签名:fn main() -> Result<(), Box<dyn Error>>
自定义错误类型
use std::fmt;
use std::num::ParseIntError;
use std::io;
#[derive(Debug)]
enum AppError {
Io(io::Error),
Parse(ParseIntError),
Custom(String),
}
// 实现 Display(给用户看的信息)
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::Io(e) => write!(f, "IO 错误: {}", e),
AppError::Parse(e) => write!(f, "解析错误: {}", e),
AppError::Custom(msg) => write!(f, "{}", msg),
}
}
}
// 实现 Error trait
impl std::error::Error for AppError {}
// 实现 From,让 ? 自动转换
impl From<io::Error> for AppError {
fn from(e: io::Error) -> Self { AppError::Io(e) }
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self { AppError::Parse(e) }
}
fn process() -> Result<i32, AppError> {
let content = std::fs::read_to_string("number.txt")?; // io::Error → AppError
let num: i32 = content.trim().parse()?; // ParseIntError → AppError
Ok(num * 2)
}
thiserror — 简化自定义错误
thiserror 用 derive 宏自动生成错误类型的样板代码:
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("IO 错误: {0}")]
Io(#[from] std::io::Error), // 自动生成 From<io::Error>
#[error("解析错误: {0}")]
Parse(#[from] std::num::ParseIntError),
#[error("自定义错误: {0}")]
Custom(String),
#[error("验证失败: {field} 的值 {value} 不合法")]
Validation { field: String, value: String },
}
anyhow — 快速错误处理
anyhow 提供动态错误类型,适合应用层快速处理:
use anyhow::{Context, Result, bail, ensure};
fn process_config() -> Result<Config> {
let content = std::fs::read_to_string("config.toml")
.context("读取配置文件失败")?; // 附加上下文信息
let config: Config = toml::from_str(&content)
.context("解析配置文件失败")?;
ensure!(config.port > 0, "端口号必须大于 0"); // 条件不满足则返回 Err
if config.name.is_empty() {
bail!("名称不能为空"); // 直接返回 Err
}
Ok(config)
}
thiserror vs anyhow
| 方面 | thiserror | anyhow |
|---|---|---|
| 用途 | 库开发 | 应用开发 |
| 错误类型 | 自定义枚举(精确匹配) | anyhow::Error(动态) |
| 调用方处理 | 可以 match 不同错误 | 只能看错误信息链 |
| 上下文 | 手动 | .context() 自动附加 |
| 依赖 | 编译期(proc-macro) | 运行时(动态分发) |
最佳实践
- 写库用
thiserror:调用方需要精确匹配不同错误类型 - 写应用用
anyhow:快速传播错误,不需要精确分类 - 两者可以组合:库用 thiserror 定义错误,应用用 anyhow 包装
Option 的错误处理
? 同样适用于 Option:
fn first_even(numbers: &[i32]) -> Option<i32> {
let first = numbers.first()?; // None 则提前返回 None
if first % 2 == 0 {
Some(*first)
} else {
None
}
}
Option 转 Result:
let value: Option<i32> = Some(42);
// Option → Result
let result: Result<i32, &str> = value.ok_or("值不存在");
let result: Result<i32, String> = value.ok_or_else(|| format!("值不存在"));
// Result → Option
let opt: Option<i32> = "42".parse::<i32>().ok();
常见面试问题
Q1: Rust 为什么不用 try/catch 异常机制?
答案:
- 显式性:
Result<T, E>在函数签名中明确表达"这个函数可能失败",调用方无法忽略 - 零成本:
Result是普通枚举,没有异常展开的运行时开销 - 类型安全:编译器强制你处理错误,不会像异常一样被遗漏
- 可组合:
?让错误传播几乎和异常一样简洁,但不失显式性
异常机制的问题:函数签名看不出会抛什么异常(Java 的 checked exception 尝试解决但效果不佳),异常展开有运行时开销。
Q2: unwrap() 和 expect() 应该在什么场景使用?
答案:
两者在 None/Err 时都会 panic,区别是 expect 可以附加自定义消息。
适用场景:
- 测试代码:测试中可以自由 unwrap
- 逻辑上不可能失败:你能证明绝不会是 None/Err(加注释说明原因)
- 原型开发:快速迭代,之后替换为正确处理
// ✅ 逻辑上不可能失败("10" 一定能解析为 i32)
let n: i32 = "10".parse().expect("硬编码数字一定能解析");
// ❌ 用户输入/外部数据不应 unwrap
let n: i32 = user_input.parse().unwrap(); // 可能 panic
生产代码中应避免 unwrap/expect,用 ? 或 match 正确处理。
Q3: 如何在 main 函数中使用 ??
答案:
修改 main 的返回类型:
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
let content = std::fs::read_to_string("config.toml")?;
println!("{}", content);
Ok(())
}
或使用 anyhow:
use anyhow::Result;
fn main() -> Result<()> {
let content = std::fs::read_to_string("config.toml")?;
println!("{}", content);
Ok(())
}
Q4: Box<dyn Error> 和 anyhow::Error 有什么区别?
答案:
| 方面 | Box<dyn Error> | anyhow::Error |
|---|---|---|
| 来源 | 标准库 | 第三方 crate |
| 大小 | 16 字节(胖指针) | 8 字节(瘦指针) |
| 错误链 | 需手动 .source() | .context() 自动构建 |
| backtrace | 无内置支持 | 自动捕获(nightly) |
| 降级(downcast) | 可以 | 可以,更方便 |
Q5: ? 操作符是如何做错误类型转换的?
答案:
? 在传播错误时,会自动调用 From::from() 将源错误类型转换为目标错误类型:
// 函数返回 AppError,但 File::open 返回 io::Error
fn process() -> Result<(), AppError> {
let file = File::open("data.txt")?;
// ? 展开为:
// match File::open("data.txt") {
// Ok(f) => f,
// Err(e) => return Err(AppError::from(e)), // 自动调用 From
// }
Ok(())
}
这就是为什么自定义错误类型需要实现 From<XxxError>(或用 thiserror 的 #[from])。
Q6: panic 和 Result 应该如何选择?
答案:
| 场景 | 选择 | 原因 |
|---|---|---|
| 程序 bug(不应该发生的情况) | panic! | 尽早崩溃,暴露问题 |
| 外部输入可能出错 | Result | 预期中的失败,需要优雅处理 |
| 原型/示例代码 | unwrap | 快速开发 |
| 库的公共 API | Result | 让调用方决定如何处理 |
| 违反前置条件 | panic! | 类似 assert |
| 初始化失败(无法恢复) | panic! / process::exit | 程序无法继续 |
原则:如果调用方有办法恢复,返回 Result;如果是程序逻辑错误,用 panic!。
Q7: 如何为错误添加上下文信息?
答案:
// 1. map_err:转换错误
std::fs::read_to_string("config.toml")
.map_err(|e| format!("读取配置失败: {}", e))?;
// 2. anyhow::Context
use anyhow::Context;
std::fs::read_to_string("config.toml")
.context("读取配置文件失败")?;
// 错误信息变为:读取配置文件失败
// Caused by: No such file or directory
// 3. 动态上下文
std::fs::read_to_string(path)
.with_context(|| format!("读取 {} 失败", path))?;