可恢复错误 Result
本教程共 78 篇 · 第 40 篇 · 更新于 2026-08-08 · 约 9 分钟阅读
本节目标:用
Result<T, E>表达”可能失败”的返回值,学会用 match 取值、按错误种类分流,并分清 unwrap/expect 的使用边界。
上一章讲了严重到要崩溃的 panic。但很多错误并不致命:文件没找到、网络超时、用户输入不合法——这些都能补救。Rust 用 Result<T, E> 来承载这类可恢复错误。
1-1 Result 是什么
Result 是一个枚举,定义长这样:
enum Result<T, E> {
Ok(T), // 成功,里面装着正确的值
Err(E), // 失败,里面装着错误信息
}
T 是成功时的返回值类型,E 是失败时的错误类型。因为 Result 太常用,它被放进 prelude,不用手动 use 就能直接用。
为什么 Rust 偏爱用返回值表达错误,而不是像很多语言那样抛异常?关键在于”责任归属”。在异常体系里,错误可能在任意一层被悄悄吞掉,或者一路冒泡到你没料到的地方;而 Result 把”失败”写进了函数签名——任何调用者都看得见”这个函数可能失败”,并且编译器强制你要么处理、要么显式地决定不管。错误不再是隐藏的定时炸弹,而是摆在桌面上的数据。这也正是 Rust 那句”编译器逼你考虑错误”的由来。
最典型的例子是打开文件:
use std::fs::File;
fn main() {
let f = File::open("hello.txt");
}
File::open 返回 Result<std::fs::File, std::io::Error>:成功就给你一个文件句柄 Ok(File),失败就给一个 IO 错误 Err(io::Error)(比如文件不存在、没权限)。
Tip想知道一个函数返回什么类型?装了 rust-analyzer 的 VSCode 会把类型直接标在代码上;或者故意写错类型让编译器告诉你——它会把真实类型甩在你脸上。
1-2 用 match 取出结果
拿到 Result 不处理,编译器会警告。最基础的处理是用 match 把两种情况都覆盖:
use std::fs::File;
fn main() {
let f = File::open("hello.txt");
let f = match f {
Ok(file) => file,
Err(error) => {
panic!("打开文件出错: {:?}", error)
}
};
}
成功就把 Ok 里的句柄拿出来用;失败就 panic。这写法能跑,但把所有错误都一棍子打死,太粗暴。
1-3 按错误种类分流
真实 IO 错误有很多种,我们往往只想对”文件不存在”做特殊处理(比如新建一个),其余错误再 panic。这时嵌套 match:
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let f = File::open("hello.txt");
let f = match f {
Ok(file) => file,
Err(error) => match error.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("创建文件出错: {:?}", e),
},
other_error => panic!("打开文件出错: {:?}", other_error),
},
};
}
这里 error.kind() 能拿到错误的具体种类,NotFound 表示文件不存在,于是尝试 File::create 新建——而它自己又返回 Result,所以再 match 一次。逻辑清楚,但确实啰嗦,后面用 ? 能大幅精简。
1-4 unwrap 和 expect:失败就 panic
写原型、示例、测试时,你不想要 match 那么正式。这时 unwrap 和 expect 最顺手:成功就取出值,失败就直接 panic。
use std::fs::File;
fn main() {
let f = File::open("hello.txt").unwrap();
}
文件不存在时,unwrap 直接崩,报错里带着具体的 Err 内容。expect 和 unwrap 几乎一样,只是让你自定义报错信息:
let f = File::open("hello.txt").expect("打开 hello.txt 失败");
报错会变成 Failed to open hello.txt: ...,比 unwrap 默认那串更易懂。
Warning
unwrap/expect本质是”失败即崩溃”。只用在原型、测试,或者你确信一定成功的场合(比如解析写死的"127.0.0.1")。一旦值来自用户输入或外部系统,正式代码必须走真正的错误处理,否则程序会频繁崩。
1-5 把错误传上去:传播错误
实际项目里,函数往往嵌套十几层。错误通常在出错的那层不处理,而是往上抛,交给上游决定怎么办。这种”层层上传”叫传播错误。
用 match 手动传播是这样:
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let f = File::open("hello.txt");
let mut f = match f {
Ok(file) => file,
Err(e) => return Err(e), // 失败,直接把错误返回给调用者
};
let mut s = String::new();
match f.read_to_string(&mut s) {
Ok(_) => Ok(s),
Err(e) => Err(e),
}
}
函数签名 -> Result<String, io::Error> 表明:成功返回用户名 Ok(String),失败返回 io::Error。中途任何一步出错,都用 return Err(e) 把错误往上传,自己不处理。调用者拿到 Result 后再决定是继续传、panic,还是提示用户。
这种写法正确但很长。下一章的 ? 运算符就是专门来给它瘦身的。
1-6 自定义错误类型:把错误讲清楚
当项目变大,光用 Result<T, &'static str> 不够用了——字符串错误信息无法被程序区分种类。更地道的做法是定义自己的错误枚举,并为它实现标准库的 Error trait(同时需要 Debug 和 Display)。
use std::fmt;
#[derive(Debug)]
enum MyErr {
NotFound,
BadFormat(String),
}
impl fmt::Display for MyErr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MyErr::NotFound => write!(f, "找不到资源"),
MyErr::BadFormat(s) => write!(f, "格式错误:{s}"),
}
}
}
impl std::error::Error for MyErr {}
有了它,函数就能写 -> Result<u32, MyErr>,调用方用 match 精确分流。如果你不想为每个小项目都手写全套,可以临时用 Box<dyn std::error::Error> 当”万能错误”返回类型,借 ? 的 From 自动转换,省事又兼容。
Note标准库 trait
std::error::Error本身方法都有默认实现,你只要满足Debug + Display就能impl,这是写库时的最低成本方案。
1-7 让 main 也返回 Result
前面说”函数返回 Result 才能用 ?”,那 main 函数能不能也返回 Result?答案是能。main 的签名可以写成:
use std::fs;
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
let content = fs::read_to_string("config.txt")?;
println!("读到 {} 字节", content.len());
Ok(())
}
这样 main 内部就能顺畅使用 ? 传播错误;一旦某步出错,错误会沿 ? 一路回到 main,程序以非零状态码退出,并打印出错误。返回类型里的 Box<dyn Error> 是”任意实现了 Error 的错误”的万能盒子,配合 From 自动转换,几乎能接住你遇到的所有标准库错误。
Tip小工具、命令行程序用
fn main() -> Result<(), Box<dyn Error>>是最省心的起手式:既不用满屏unwrap,又能把所有错误干净地交到底层。
1-8 怎么选错误处理手段
学到这里,你手里有好几把”错误处理”的钥匙,关键是什么时候用哪把。写示例、写练习、写”反正崩了也无所谓”的一次性脚本时,unwrap / expect 最快,但生产代码里要克制,因为它们把”崩溃决定权”从调用方手里抢走了。
当错误是可以预期、调用方应当处理时,用 Result 把错误交还,配合 ? 一路传播到该处理的地方。写库时,给自己的错误定义成枚举并实现 Error,或者至少在 main 层用 Box<dyn Error> 兜底,让错误信息既清晰又能被程序区分。
panic! 则留给”内部不变量被破坏、程序继续下去也没有意义”的场景——比如一个按理说绝不会为空、却为空了的缓存。一句话总结:示例图省事用 unwrap,可恢复用 Result+?,库用自定义错误,panic! 只守底线。
Note没有”唯一正确”的选法,只有”这个错误该由谁负责”的判断。想清楚责任在谁,工具自然就选对了。
1-9 错误让函数变得可测试
用 Result 而不是 panic! 还有一个隐藏收益:函数变得极易测试。因为错误是”返回值”的一部分,你能在测试里断言它到底成功没、失败了又是哪一类,而不必真的让测试进程崩掉。
fn parse_age(s: &str) -> Result<u32, String> {
let n: i32 = s.parse().map_err(|_| "不是数字".to_string())?;
if n < 0 { return Err("年龄不能为负".into()); }
Ok(n as u32)
}
#[test]
fn test_parse_age() {
assert_eq!(parse_age("18"), Ok(18));
assert!(parse_age("-1").is_err());
assert!(parse_age("abc").is_err());
}
注意测试里没有用 unwrap 去”假设成功”,而是用 assert_eq! / is_err() 去”核对结果”。这正是 Result 的设计初衷:把”成功值”和”失败原因”都变成可以比较、可以断言的数据,而不是让程序在测试里炸开。
Tip写业务函数时,凡是”可能失败”的,优先返回
Result;这样单测能覆盖成功路径和每一条失败路径,代码质量更有底。
1-10 取值的便捷方法
如果场景简单,不值得写一整个 match,Result 自带一批便捷方法能省不少事:
let r: Result<i32, &str> = Ok(10);
println!("{}", r.is_ok()); // true:是否成功
println!("{}", r.is_err()); // false:是否失败
let v = r.unwrap_or(0); // 成功取值,失败给默认值 0
let v2 = r.unwrap_or_else(|e| { // 失败时用闭包算默认值
println!("出错:{e}");
0
});
unwrap_or(默认) 在失败时返回一个你给的兜底值;unwrap_or_else(闭包) 更灵活,只有真的失败时才执行闭包去算默认值(适合默认值算起来比较贵的场景);还有 unwrap_or_default(),用类型的 Default 实现当兜底(比如 i32 给 0、String 给空串)。这些方法在你”只关心成功值、失败给个合理默认”时非常顺手,不用写 match。
Note这些方法不会 panic,适合在”失败也无所谓、给个默认值就行”的业务分支里用。但若是文件丢失、配置缺失这类你必须知道的错误,还是老老实实用
match或?显式处理。
1-11 用 if let 只关心一边
有时候你只想处理成功、或者只处理失败,另一边懒得写。用 if let 最干净:
let r: Result<i32, &str> = Ok(10);
if let Ok(v) = r {
println!("拿到了值:{v}"); // 只在成功时执行
}
if let Err(e) = r {
println!("出错了:{e}"); // 只在失败时执行
}
if let Ok(v) = r 只匹配成功分支,匹配不上就跳过,省去了写完整 match 的 Err 处理。配合 while let 还能在循环里不断取出 Ok 值直到遇到 Err。不过要提醒一句:只写 if let Ok 而忽略 Err,等于悄悄丢掉了错误信息——写原型、调试时方便,真要上线还得把失败分支补上。
Tip快速调试时用
if let Ok(v)偷看一眼成功值很方便;但正式逻辑里,漏处理Err早晚会还债,记得把失败情况也交代清楚。
1-12 小结
Result<T, E>用Ok(T)表成功、Err(E)表失败,是表达可恢复错误的标准方式;- 用
match必须同时处理Ok和Err; - 可嵌套
match按error.kind()细分错误种类; unwrap/expect是”失败即 panic”的快捷方式,限原型/测试/确信正确时用;- 不想就地处理,就
return Err(e)把错误传播给上层。
下一章学 ? 运算符,几行就能替代上面那一长串错误传播代码。