Rust 的错误处理: 使用类型系统来构建
1. 可恢复错误:Option/Result错误类型处理
由于诞生的年代比较晚,Rust 有机会从已有的语言中学习到各种错误处理的优劣。对于 Rust 来说,目前的几种方式相比而言,最佳的方法是,使用类型系统来构建主要的错误处理流程。
Option
Result
Result 是一个更加复杂的 enum,其定义如下:
#[must_use = "this `Result` may be an `Err` variant, which should be handled"] pub enum Result<T, E> { Ok(T), Err(E), }
当函数出错时,可以返回 Err(E),否则 Ok(T)。
Result 类型声明时还有个 must_use 的标注
我们看到,Result 类型声明时还有个 must_use 的标注
编译器会对有 must_use 标注的所有类型做特殊处理:
如果该类型对应的值没有被显式使用,则会告警。这样,保证错误被妥善处理。
如下图所示:
这里,如果我们调用 read_file 函数时,直接丢弃返回值,由于 #[must_use] 的标注,Rust 编译器报警,要求我们使用其返回值。
2. 抛出异常:panic! 和 catch_unwind
panic!抛出异常: 不可/不想恢复
使用 Option 和 Result 是 Rust 中处理错误的首选,绝大多数时候我们也应该使用,但 Rust 也提供了特殊的异常处理能力。
在 Rust 看来,一旦你需要抛出异常,那抛出的一定是严重的错误。
所以,Rust 跟 Golang 一样,使用了诸如 panic! 这样的字眼警示开发者:想清楚了再使用我。
一般而言,panic! 是不可恢复或者不想恢复的错误,我们希望在此刻,程序终止运行并得到崩溃信息。
unwrap/expect语法糖
unwrap() 或者 expect()就是准备panic!
在使用 Option 和 Result 类型时,开发者也可以对其 unwrap() 或者 expect(),强制把 Option
比如下面的代码,它解析 noise protocol的协议变量:
let params: NoiseParams = "Noise_XX_25519_AESGCM_SHA256".parse().unwrap();
如果开发者不小心把协议变量写错了,最佳的方式是立刻 panic! 出来,让错误立刻暴露,以便解决这个问题。
catch_uwind捕获异常:崩溃后恢复上下文
catch_unwind(): 有些场景下,我们也希望能够像异常处理那样能够栈回溯,把环境恢复到捕获异常的上下文。
Rust 标准库下提供了 catch_unwind() ,把调用栈回溯到 catch_unwind 这一刻,作用和其它语言的 try {…} catch {…} 一样。见如下代码:
use std::panic; fn main() { let result = panic::catch_unwind(|| { println!("hello!"); }); assert!(result.is_ok()); let result = panic::catch_unwind(|| { panic!("oh no!"); }); assert!(result.is_err()); println!("panic captured: {:#?}", result); }
当然,和异常处理一样,并不意味着你可以滥用这一特性. 我想这也是 Rust 把抛出异常称作 panic! ,而捕获异常称作 catch_unwind 的原因:让初学者望而生畏,不敢轻易使用。这也是一个不错的用户体验。
catch_unwind 在哪些场景下非常有用:
catch_unwind 在某些场景下非常有用:
-
比如你在使用 Rust 为 erlang VM 撰写 NIF,你不希望 Rust 代码中的任何 panic! 导致 erlang VM 崩溃。 因为崩溃是一个非常不好的体验,它违背了 erlang 的设计原则:process 可以 let it crash,但错误代码不该导致 VM 崩溃。
-
此刻,你就可以把 Rust 代码整个封装在 catch_unwind() 函数所需要传入的闭包中。 这样,一旦任何代码中,包括第三方 crates 的代码,含有能够导致 panic! 的代码,都会被捕获,并被转换为一个 Result。
3. 自定义异常类型
3.1 使用Error trait
我们可以定义我们自己的数据类型,然后为其实现 Error trait。
使用Error trait自定义错误类型
上文中,我们讲到 Result<T, E> 里 E 是一个代表错误的数据类型。为了规范这个代表错误的数据类型的行为,Rust 定义了 Error trait:
pub trait Error: Debug + Display { fn source(&self) -> Option<&(dyn Error + 'static)> { ... } fn backtrace(&self) -> Option<&Backtrace> { ... } fn description(&self) -> &str { ... } fn cause(&self) -> Option<&dyn Error> { ... } }
3.2 使用thiserror简化
不过,这样的工作已经有人替我们简化了:我们可以使用 thiserror和 anyhow来简化这个步骤。
thiserror 提供了一个派生宏(derive macro)来简化错误类型的定义
use thiserror::Error; #[derive(Error, Debug)] #[non_exhaustive] pub enum DataStoreError { #[error("data store disconnected")] Disconnect(#[from] io::Error), #[error("the data for key `{0}` is not available")] Redaction(String), #[error("invalid header (expected {expected:?}, found {found:?})")] InvalidHeader { expected: String, found: String, }, #[error("unknown data store error")] Unknown, }
如果你在撰写一个 Rust 库,那么 thiserror 可以很好地协助你对这个库里所有可能发生的错误进行建模。
3.3 使用anyhow扩展 ?操作符
- anyhow 实现了 anyhow::Error 和任意符合 Error trait 的错误类型之间的转换,让你可以使用 ? 操作符,不必再手工转换错误类型。
- anyhow 还可以让你很容易地抛出一些临时的错误,而不必费力定义错误类型,当然,我们不提倡滥用这个能力。
3.4 让错误无所遁形
作为一名严肃的开发者,我非常建议你在开发前:
- 先用类似 thiserror 的库定义好你项目中主要的错误类型
- 并随着项目的深入,不断增加新的错误类型,让系统中所有的潜在错误都无所遁形。
4. 捕获异常
4.1 ? 操作符
由来
? 操作符的由来
这虽然可以极大避免遗忘错误的显示处理,但如果我们并不关心错误,只需要传递错误,还是会写出像 C 或者 Golang 一样比较冗余的代码。怎么办?
好在 Rust 除了有强大的类型系统外,还具备元编程的能力:
- 早期 Rust 提供了 try! 宏来简化错误的显式处理
- 后来为了进一步提升用户体验,try! 被进化成 ? 操作符。
用于传播📣
如果你只想传播错误,不想就地处理,可以用 ? 操作符
所以在 Rust 代码中,如果你只想传播错误,不想就地处理,可以用 ? 操作符,比如(代码):
use std::fs::File; use std::io::Read; fn read_file(name: &str) -> Result<String, std::io::Error> { let mut f = File::open(name)?; let mut contents = String::new(); f.read_to_string(&mut contents)?; Ok(contents) }
通过 ? 操作符,Rust 让错误传播的代价和异常处理不相上下,同时又避免了异常处理的诸多问题。
?操作符展开
? 操作符内部被展开成类似这样的代码:
match result { Ok(v) => v, Err(e) => return Err(e.into()) }
所以,我们可以方便地写出类似这样的代码,简洁易懂,可读性很强:
fut .await? .process()? .next() .await?;
整个代码的执行流程如下:
虽然 ? 操作符使用起来非常方便,但你要注意在不同的错误类型之间是无法直接使用的,需要实现 From trait 在二者之间建立起转换的桥梁,这会带来额外的麻烦。
4.2 函数式错误处理: map/map_err/and_then
Rust 还为 Option 和 Result 提供了大量的辅助函数,如 map / map_err / and_then,你可以很方便地处理数据结构中部分情况。
map / map_err / and_then: 使用函数式错误处理
如下图所示:
通过这些函数,你可以很方便地对错误处理引入 Railroad oriented programming 范式。
比如用户注册的流程,你需要校验用户输入,对数据进行处理,转换,然后存入数据库中。你可以这么撰写这个流程:
Ok(data) .and_then(validate) .and_then(process) .map(transform) .and_then(store) .map_error(...)
执行流程如下图所示:
此外,Option 和 Result 的互相转换也很方换,这也得益于 Rust 构建的强大的函数式编程的能力。
我们可以看到,无论是通过 ? 操作符,还是函数式编程进行错误处理,Rust 都力求让错误处理灵活高效,让开发者使用起来简单直观。