Result 和 Option:Rust 风格的错误处理
1. 概述
Rust 没有像 Java 或 C# 那样的异常。相反,Rust 使用两种数据类型 Option<T> 和 Result<T, E> 来表示“可能没有值”和“可能失败”。这篇文章将帮你理解如何正确使用这两种类型,如何用 ? 操作符简洁地传播错误,以及如何用 thiserror 定义自定义错误。读完后,你将能够编写清晰的 Rust 错误处理代码,无需 try/catch。
2. 为什么不用异常?
在 Java 中,一个方法可以随时抛出 RuntimeException,而方法签名中完全没有声明。你必须阅读 Javadoc 或代码才能知道它可能抛出什么。
Rust 强制你在返回类型中直接声明失败的可能性:
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
return Err("除以 0".to_string());
}
Ok(a / b)
}
看到签名 Result<i32, String> 就知道这个函数可能会失败。编译器强制你处理 Ok 和 Err 两个分支,不能像 Java 中忘记 catch 那样悄悄跳过错误。
注意: Rust 仍然有
panic!用于不可恢复的错误(编程 bug、不变量违反)。Result用于可预测和可处理的错误。
3. Option:可能缺失的值
Option<T> 替代 null。它有两个变体:Some(T) 和 None。
fn find_customer(id: u32) -> Option<String> {
let customers = vec![(1, "Alice"), (2, "Bob")];
customers.iter()
.find(|(cid, _)| *cid == id)
.map(|(_, name)| name.to_string())
}
fn main() {
match find_customer(2) {
Some(name) => println!("找到了:{name}"),
None => println!("没有这个客户"),
}
}
find 返回 Option<&(u32, &str)>,map 在 Some 内部转换值,如果是 None 则跳过。结果:
找到了:Bob
3.1. 常用方法
let maybe_age: Option<u32> = Some(25);
let age = maybe_age.unwrap_or(18); // 25,或 18 若为 None
let doubled = maybe_age.map(|a| a * 2); // Some(50)
let result: Result<u32, &str> = maybe_age.ok_or("缺少年龄"); // Ok(25)
unwrap_or(default):取值或返回默认值,永不 panic。map(f):如果有值就在内部转换,没有则保留None。ok_or(err):将Option<T>转换为Result<T, E>——当你后面需要用?操作符时很有用。
注意: 避免在生产代码中直接调用
.unwrap()(在Option/Result上)。一旦遇到None/Err就会 panic,相当于抛出不可控的NullPointerException。只在测试中或你 100% 确定值存在时使用。
4. Result<T, E>:成功或失败
Result<T, E> 有两个变体:Ok(T) 表示成功,Err(E) 表示失败。
use std::num::ParseIntError;
fn parse_quantity(input: &str) -> Result<u32, ParseIntError> {
input.trim().parse::<u32>()
}
fn main() {
match parse_quantity(" 42 ") {
Ok(qty) => println!("数量:{qty}"),
Err(e) => println!("解析错误:{e}"),
}
}
parse::<u32>() 返回 Result<u32, ParseIntError>。match 强制你处理两个分支——如果缺少任何分支,编译器会拒绝编译。
数量:42
4.1. 用 ? 操作符传播错误
到处写 match 很冗长。? 操作符在遇到 Err 时立即传播错误,类似自动 throw:
use std::num::ParseIntError;
struct Order {
quantity: u32,
price: u32,
}
fn parse_order(qty_str: &str, price_str: &str) -> Result<Order, ParseIntError> {
let quantity = qty_str.trim().parse::<u32>()?;
let price = price_str.trim().parse::<u32>()?;
Ok(Order { quantity, price })
}
fn main() {
match parse_order("3", "15000") {
Ok(order) => println!("总金额:{}", order.quantity * order.price),
Err(e) => println!("数据无效:{e}"),
}
}
行 qty_str.trim().parse::<u32>()? 的意思是:如果 parse 返回 Err,函数 parse_order 立即 返回那个 Err 给调用者,无需手动编写 match。如果是 Ok,内部值被“解包”并赋给 quantity。
总金额:45000
注意:
?操作符只有在当前函数和被调用函数的错误类型兼容时才能使用(或有From实现进行转换)。否则编译器会在构建时报告类型不匹配错误。
5. 结合 Option 和 Result
这两种类型经常交织使用。例如,在库存中查找产品,然后检查库存是否足够:
struct Product {
name: String,
stock: u32,
}
fn find_product(name: &str, products: &[Product]) -> Option<&Product> {
products.iter().find(|p| p.name == name)
}
fn check_stock(name: &str, requested: u32, products: &[Product]) -> Result<(), String> {
let product = find_product(name, products)
.ok_or_else(|| format!("找不到产品:{name}"))?;
if product.stock < requested {
return Err(format!("只剩 {} 个产品", product.stock));
}
Ok(())
}
ok_or_else 将 Option<&Product> 转换为带错误消息的 Result<&Product, String>,然后如果是 None 就用 ? 传播。这种写法避免了多层嵌套 match。
6. 用 thiserror 定义自定义错误
用 String 作为错误类型对小例子很方便,但真实项目需要结构化的错误 enum。Crate thiserror 使这变得简洁:
<!-- 相当于 Cargo.toml,使用 dependencies 部分 -->
# Cargo.toml
[dependencies]
thiserror = "1.0"
use thiserror::Error;
#[derive(Error, Debug)]
enum OrderError {
#[error("产品不存在:{0}")]
ProductNotFound(String),
#[error("库存不足,剩余 {available} 个产品")]
InsufficientStock { available: u32 },
}
fn validate_order(stock: Option<u32>, requested: u32) -> Result<(), OrderError> {
let available = stock.ok_or_else(|| OrderError::ProductNotFound("Laptop".into()))?;
if available < requested {
return Err(OrderError::InsufficientStock { available });
}
Ok(())
}
#[derive(Error, Debug)] 自动生成 trait std::error::Error 的实现。#[error("...")] 为每个变体定义显示消息,支持如 {0}(位置)或 {available}(字段名)的占位符。打印错误时的结果:
Error: InsufficientStock { available: 2 }
如果打印时用 {} 而不是 {:?},你会得到友好的消息:库存不足,剩余 2 个产品。
7. 应用用 anyhow,库用 thiserror
当编写 binary/应用程序(不是供他人使用的库)时,你通常不需要区分错误类型的细节——只需传播和打印。Crate anyhow 适合这种情况:
[dependencies]
anyhow = "1.0"
use anyhow::{Context, Result};
fn load_config(path: &str) -> Result<String> {
std::fs::read_to_string(path)
.with_context(|| format!("无法读取配置文件:{path}"))
}
fn main() -> Result<()> {
let config = load_config("app.toml")?;
println!("Config:{config}");
Ok(())
}
anyhow::Result<T> 是 Result<T, anyhow::Error> 的别名,可以包含任何实现 trait Error 的错误。with_context 将上下文信息附加到原始错误,有助于调试而无需自己定义 enum。
- 库(他人会
use的 crate):使用thiserror,返回具体的错误 enum 让调用者match处理各种情况。 - 应用程序(最终 binary):使用
anyhow,只需传播和日志记录,没人需要详细的match。
8. 测试错误行为
Rust 有内置的测试框架,基本测试无需额外的库。
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tra_ve_ok_khi_du_hang() {
let result = validate_order(Some(10), 5);
assert!(result.is_ok());
}
#[test]
fn tra_ve_err_khi_thieu_hang() {
let result = validate_order(Some(2), 5);
assert!(matches!(
result,
Err(OrderError::InsufficientStock { available: 2 })
));
}
#[test]
fn tra_ve_err_khi_khong_tim_thay_san_pham() {
let result = validate_order(None, 5);
assert!(matches!(result, Err(OrderError::ProductNotFound(_))));
}
}
#[test] 标记测试函数,用 cargo test 运行。matches! 是一个宏,检查值是否匹配给定的模式——比 assert_eq! 更方便处理有字段的 enum,因为你不需要为整个错误类型实现 PartialEq。
running 3 tests
test tests::tra_ve_ok_khi_du_hang ... ok
test tests::tra_ve_err_khi_thieu_hang ... ok
test tests::tra_ve_err_khi_khong_tim_thay_san_pham ... ok
9. 常见错误
- 滥用
.unwrap():代码在开发时运行正常,但在生产环境中遇到陌生输入时会意外 panic。使用?、match或unwrap_or_else进行明确处理。 - 在公开库中用
String作为错误类型:调用者无法按错误类型match,只能比较字符串——当你改变消息时容易崩溃。用thiserror的 enum 代替String。 - 将
anyhow::Error混入库的公开 API:隐藏了具体错误类型信息,调用者被迫手动downcast。anyhow只应在应用程序最后一层使用。 - 忘记
#[derive(Debug)]:?操作符和许多测试宏要求错误类型实现Debug。缺少它,编译器会报告 trait bound 不满足的错误。
注意: 如果你来自 Java 并想“模拟 checked exception”,不要试图用宏模仿 try/catch。要习惯这样的思维:错误是值(value),像任何其他数据一样被返回和传递。
10. 结论
Option<T> 和 Result<T, E> 将“缺失”和“失败”的处理变成类型系统的一部分,由编译器在编译时检查而不是运行时。? 操作符使代码简洁而明确。对于小项目,String 或 anyhow 就足够了;对于共享库,值得投入用 thiserror 定义清晰的错误 enum,让调用者能安全地处理每种情况。