进阶篇 (7讲)
学习 Rust 语言中的错误表示、传递及处理。
Rust 的错误处理很重要,也很系统。
错误要分类,不同类型的错误有不同的处理策略。
分 可恢复型错误 和 不可恢复型错误 这两种。
碰到了,要及早退出程序,或直接不启动程序,而是打出错误信息。
支持设施有 4 个:
遇到后,不该让程序直接停止运行,要在代码逻辑中分析可能的错误原因,要么尝试恢复性处理,要么返回自定义的错误信息(让用户明白任务未达到预期的原因)。
Rust 中,用 Result
Result
不可恢复型错误和可恢复型错误在某些时候界限不是那么清晰,在同一个地方,你既可以把它设计成可恢复型错误,也可以把它处理成不可恢复型错误。因此从本质上来说,是由业务逻辑来确定你要把它当成哪一类错误来处理。
Rust 用 Result 类型(实例)来包裹错误类型(实例)。
Result 通过函数返回值返回到上层调用者函数,由上层函数来决定是在本层处理错误,还是继续把错误抛到更上一层函数。可把包裹错误的 Result
Result
两个方法的区别仅在于 expect() 可提供更多的信息(提供这个 panic 的精确位置),前提是这个提示信息要独一无二。在调用栈层次非常深的情况下,提供准确的 panic 位置信息非常重要。
错误处理本身是一项系统性的工作:
软件项目到成熟阶段,用于错误处理的代码会占总代码的三分之一以上。
Rust 从语法和标准库层面,对错误处理做了根本上的原生支持,必须对程序的健壮性负责,至少也要留下明显足迹。
演示Rustc 如何提醒我们。
//
fn foo() -> Result {
Ok("abc".to_string())
}
fn main() {
foo(); // 这里Rustc小助手会警告你
}
其他语言中,若像示例那样,调用 foo() 函数、忽略了它的返回值,一般没问题。
Rust 会提醒,有一个 Result 没被使用,必须要处理,并给出了处理建议。
//
warning: unused `Result` that must be used
--> src/main.rs:5:5
|
5 | foo(); // 这里Rustc小助手会警告你
| ^^^^^
|
= note: this `Result` may be an `Err` variant, which should be handled
= note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
|
5 | let _ = foo(); // 这里Rustc小助手会警告你
| +++++++
小助手不会放过任何一个可能的错误。
Rust 中定义了一个抽象的 Error trait,任何实现了这个 trait 的类型都被 Rust 生态认为是一个错误类型。
//
pub trait Error: Debug + Display {}
一个类型要实现 Error,必须同时为这个类型实现 Debug 和 Display trait(Debug trait 可用 derive 宏自动实现,而 Display trait 要手动 impl 实现)。
实现 Error trait 是 Rust 生态中的一种规范。若在本层 module 里定义错误类型,为它实现了 Debug、Display、Error ,生态就认成是一种错误类型。--> trait 作为社区协议的作用。
实现了 Error trait 的类型,可被代入到 dyn Error 这个 trait object 里使用。生态中很多库支持接受 &dyn Error 或 Box
如,让自定义的错误类型从下面的函数中返回。
//
fn foo() -> Result> {
// 可返回一个由Box包起来的自定义错误类型。
}
这并不是说, std::result::Result
标准库里 Result
//
pub enum Result {
Ok(T),
Err(E),
}
从这个 enum 可以看到,对 E 没有任何约束。即使你的类型不实现 Error trait,它还是能被代入 Result
Result
Rust 里定义的错误类型,常用的有:
io Error,一个结构体。标准库里 I/O 相关场景的错误类型。
//
pub struct Error { /* private fields */ }
常见的 I/O 操作:标准输入输出、网络读写、文件读写等。
std::io 模块对这种 I/O 读写做了统一抽象,而类型 io::Error 也是这个抽象里的一部分。
可用 new() 函数创建一个 io::Error 实例,第一个参数是 ErrorKind 枚举,第二个参数是具体的错误内容。
//
use std::io::{Error, ErrorKind};
// 错误可以从字符串中构造
let custom_error = Error::new(ErrorKind::Other, "oh no!");
其中 ErrorKind 是一个枚举(目前包含 40 种变体),标准库中能遇到的 I/O 错误都定义了。
//
pub enum ErrorKind {
NotFound, // 未找到
PermissionDenied, // 权限被拒绝;拒绝访问
ConnectionRefused, // 连接被拒绝
ConnectionReset, // 连接被重置
HostUnreachable, // 主机无法访问
NetworkUnreachable, // 网络无法访问
ConnectionAborted, // 连接中止
NotConnected, // 未连接
AddrInUse, // Addr在使用中
AddrNotAvailable, // Addr不可用
NetworkDown, // 网络故障
BrokenPipe, // 管道破裂
AlreadyExists, // 已经存在
WouldBlock, // 将阻止
NotADirectory, // 不是个目录
IsADirectory, // 是个目录
DirectoryNotEmpty, // 目录不为空
ReadOnlyFilesystem, // 只读文件系统
FilesystemLoop, // 文件系统循环
StaleNetworkFileHandle, // 过时的网络文件句柄
InvalidInput, // 无效输入
InvalidData, // 无效数据
TimedOut, // 超时
WriteZero, // 写入零
StorageFull, // 存储已满
NotSeekable, // 不可搜索
FilesystemQuotaExceeded, // 超过文件系统配额
FileTooLarge, // 文件过大
ResourceBusy, // 资源繁忙
ExecutableFileBusy, // 可执行文件忙
Deadlock, // 僵局,死锁
CrossesDevices, // 跨设备
TooManyLinks, // 链接太多
InvalidFilename, // 无效文件名
ArgumentListTooLong, // 参数列表太长
Interrupted, // 被中断
Unsupported, // 不被支持
UnexpectedEof, // 出乎意料的Eof
OutOfMemory, // 内存不足
Other, // 其他
}
远远不能覆盖全部(ErrorKind 仅用于标准库 I/O 模块中可能出现的的错误类型)
标准库中定义了一组 parse 相关的错误类型。
这些错误类型与 FromStr trait 相关,即:把字符串解析到其他类型时可能会出现的错误类型:
//
use std::net::IpAddr;
fn main() {
let s = "100eee";
if let Err(e) = s.parse::() {
// e 这里是 ParseIntError
println!("Failed conversion to i32: {e}");
}
let addr = "127.0.0.1:8080".parse::();
if let Err(e) = addr {
// e 这里是 AddrParseError
println!("Failed conversion to IpAddr: {e}");
}
}
// 输出
Failed conversion to i32: invalid digit found in string
Failed conversion to IpAddr: invalid IP address syntax
在 Rust 中用 Result 作为函数返回值,在上层中处理的典型方式。
//
// 定义自己的错误类型,一般是一个枚举,因为可能有多种错误
enum HereError {
Error1,
Error2,
Error3,
}
// 一个函数返回Err
fn bar() -> Result {
Err(HereError::Error3)
}
fn foo() {
match bar() {
Ok(_) => {}
Err(err) => match err { // 在上层中通过match进行处理
HereError::Error1 => {}
HereError::Error2 => {}
HereError::Error3 => {}
},
}
}
通常在当前模块中定义错误类型(枚举类型,错误种类往往不止一个)。若接口返回了这个错误类型,上层就要 match 这个枚举类型进行错误处理。
到目前为止,并没给自定义的错误类型 HereError 实现 Debug、Display 和 Error trait,所以我们的错误类型还仅限于自己玩。
为了把它纳入 Rust 生态体系,需要给它实现这 3 个 trait。但没必要自己手动去实现,社区的工具 crate: thiserror 可实现这个目的。
前面介绍了错误类型的定义和处理的基本方式,系统性地介绍错误的传递。
若函数中有可能会出错,那它的返回值就默认约定为 Result。
先对比其他语言是怎么处理的。
Rust :把异常情况独立成一个维度,放在 Result
//
fn foo(num: u32) -> Result {
if num == 10 {
Ok("Hello world!".to_string())
} else {
Err("I'm wrong!".to_string())
}
}
代码中的错误类型部分被定义为 String 类型(实际可定义成任意类型)。
把错误定义成 u32 类型。
//
fn foo(num: u32) -> Result {
if num == 10 {
Ok("Hello world!".to_string())
} else {
Err(100)
}
}
有时函数中的错误情况可能不止一种,返回类型惯用是 enum:
//
enum MyError {
Error1,
Error2,
Error3,
}
fn foo(num: u32) -> Result {
match num {
10 => Ok("Hello world!".to_string()),
20 => Err(MyError::Error1),
30 => Err(MyError::Error2),
_ => Err(MyError::Error3),
}
}
这里 Result 的 E 部分,类型就是自定义的 MyError。
另一种常用办法,让函数返回 Result<_, Box
//
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct MyError;
impl fmt::Display for MyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self)
}
}
impl Error for MyError {}
fn foo(num: u32) -> Result> {
match num {
10 => Ok("Hello world!".to_string()),
_ => {
let my_error = MyError;
Err(Box::new(my_error))
}
}
}
把错误独立到另一个维度来处理,得到了相当大的灵活性和安全性:可借助类型系统来检查正常情况与异常情况的不同返回,大大减少了编码出错的机率。
有了这套优秀的错误处理底层设施后,整个 Rust 生态上层建筑逐渐结构性地构建起来了,大家都遵从这个约定,用同样的方式来传递和处理错误,形成了一个健康良好的生态。
常用 Result 上的 map_err 方法手动转换错误类型:
//
use std::fs::File;
use std::io::Read;
fn read_file() -> Result {
match File::open("example.txt").map_err(|err| format!("Error opening file: {}", err)) {
Ok(mut file) => {
let mut contents = String::new();
match file
.read_to_string(&mut contents)
.map_err(|err| format!("Error reading file: {}", err))
{
Ok(_) => Ok(contents),
Err(e) => {
return Err(e);
}
}
}
Err(e) => {
return Err(e);
}
}
}
要在 read_file() 中打开一个文件,并读取文件全部内容到字符串中。
过程中,可能出现两个 I/O 错误:打开文件错误、读取文件错误。
用 map_err 将这两个 I/O 错误的类型都转换成了 String 类型,来和函数返回类型签名相匹配。然后,对两个操作的 Result 进行了 match 匹配。这里的两个文件操作可能的错误都是 std::io::Error 类型的。
一个函数中会产生不同的错误类型,用 map_err 显式地把不同的错误类型转换成同一种错误类型。
除了每次对 Result 进行 match 处理外,Rust 还流行一种方式:对 Result 进行链式处理。
将打开文件并读取内容,改写成链式调用的风格:
//
use std::fs::File;
use std::io::Read;
fn read_file() -> Result {
File::open("example.txt")
.map_err(|err| format!("Error opening file: {}", err))
.and_then(|mut file| {
let mut contents = String::new();
file.read_to_string(&mut contents)
.map_err(|err| format!("Error reading file: {}", err))
.map(|_| contents)
})
}
fn main() {
match read_file() {
Ok(n) => println!("{}", n),
Err(err) => println!("Error: {}", err),
}
}
链式风格比用 match 处理的简洁很多。
这里用到 map_err、and_then、map 三种链式操作,可在不解开 Result 包的情况下直接对里面的内容进行处理。关于这几个方法的详细内容,参考第 8 讲。
第 5 行 File::open() 执行完,若 Result 是 Err,那在第 6 行 map_err() 后,不会再走 and_then() 操作,而是直接从 read_file() 函数中返回这个 Result 了。若第 5 行的产生的 Result 是 Ok,就会跳过第 6 行,进入第 7 行执行。
进入第 7 行后,会消解前面产生的 Result,把 file 对象传进来使用。
再看第 9 行产生的 Result,若这个 Result 实例是 Err,那执行完第 10 行后,就直接从闭包返回了,返回的是 Err 值,这个值会进一步作为 read_file() 函数的返回值返回。若 Result 实例是 Ok,就会跳过第 10 行,执行第 11 行,第 11 行将 contents 字符串 move 进来作为内层闭包的返回值,并进一步以 Ok(contents) 的形式作为 read_file() 函数的返回值返回。
链式处理比 match 操作优美太多,但理解起来也困难太多。慢慢理解,熟能生巧。
match 写法,怎么简化?
//
use std::fs::File;
use std::io::Read;
fn read_file() -> Result {
match File::open("example.txt").map_err(|err| format!("Error opening file: {}", err)) {
Ok(mut file) => {
let mut contents = String::new();
match file
.read_to_string(&mut contents)
.map_err(|err| format!("Error reading file: {}", err))
{
Ok(_) => Ok(contents),
Err(e) => {
return Err(e);
}
}
}
Err(e) => {
return Err(e);
}
}
}
上面的第 13~15 行和第 18~20 行,都是把错误 Err(e) 返回到上一层。
//
Err(e) => {
return Err(e);
}
Rust 用 ?操作符来简化这种场景。
//
use std::fs::File;
use std::io::Read;
fn read_file() -> Result {
let mut file =
File::open("example.txt").map_err(|err| format!("Error opening file: {}", err))?;
let mut contents = String::new();
file.read_to_string(&mut contents)
.map_err(|err| format!("Error reading file: {}", err))?;
Ok(contents)
}
神奇!行数缩短了这么多。
? 操作符大体上等价于一个 match 语句。
//
let ret = a_result?;
等价于
let ret = match a_result {
Ok(ret) => ret,
Err(e) => return Err(e), // 注意这里有一个return语句。
};
若 result 的值是 Ok,就解包;若是 Err,就提前返回 Err。
--> 一种防御式编程,遇到了错误就提前返回。让代码大大简化,少很多层括号。
这里的 e 是这个 a_result 里 Err(e) 中的 e。这个实例的类型是什么呢?
用 return 返回它,它是不是和函数中定义的返回类型中的错误类型一致呢?这个问题其实很重要。上例,明确地用 map_err 把 io::Error 转换成了 String 类型,所以没问题。
把 map_err 去掉试试。
//
use std::fs::File;
use std::io::Read;
fn read_file() -> Result {
let mut file =
File::open("example.txt")?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
// 编译器报错了:
error[E0277]: `?` couldn't convert the error to `String`
--> src/lib.rs:6:34
|
4 | fn read_file() -> Result {
| ---------------------- expected `String` because of this
5 | let mut file =
6 | File::open("example.txt")?;
| ^ the trait `From` is not implemented for `String`
|
= note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait
= help: the following other types implement trait `From`:
>
>>
>>
>
>
>
= note: required for `Result` to implement `FromResidual>`
error[E0277]: `?` couldn't convert the error to `String`
--> src/lib.rs:8:39
|
4 | fn read_file() -> Result {
| ---------------------- expected `String` because of this
...
8 | file.read_to_string(&mut contents)?;
| ^ the trait `From` is not implemented for `String`
|
= note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait
= help: the following other types implement trait `From`:
>
>>
>>
>
>
>
= note: required for `Result` to implement `FromResidual>`
提示:? 操作符不能把错误类型转换成 String 类型。
--> 用 ?的常见错误,错误类型不一致。
错误提示:? 操作符用 From trait 尝试对错误类型做隐式转换,列出了几种已经实现了的可以转换到 String 的错误类型。Rust 处理 ? 操作符时,会尝试对错误类型进行转换,看能不能自动把错误类型转换到函数返回类型中的那个错误类型。若不行,就报错。(第 11 讲,如何用 From
按要求对 std::io::Error 实现一下这个转换:
//
use std::fs::File;
use std::io::Read;
impl From for String {
fn from(err: std::io::Error) -> Self {
format!("{}", err)
}
}
fn read_file() -> Result {
let mut file =
File::open("example.txt")?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
// 咦,不通过,提示:
error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate
--> src/lib.rs:4:1
|
4 | impl From for String {
| ^^^^^--------------------^^^^^------
| | | |
| | | `String` is not defined in the current crate
| | `std::io::Error` is not defined in the current crate
| impl doesn't use only types from inside the current crate
|
= note: define and implement a trait or new type instead
违反第 9 讲说过的 trait 孤儿规则。
怎么解决呢?重新定义一个自己的类型:
//
use std::fs::File;
use std::io::Read;
struct MyError(String); // 用newtype方法定义了一个新的错误类型
impl From for MyError {
fn from(err: std::io::Error) -> Self {
MyError(format!("{}", err))
}
}
fn read_file() -> Result {
let mut file =
File::open("example.txt")?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
行了。
在第 4 行用 newtype 模式定义了一个自定义错误类型,里面包了 String 类型,在第 6 行对它实现 From
若出现打开文件错误或读取文件错误,? 操作符会自动把 std::io::Error 类型转换到 MyError 类型上去,从 read_file() 函数里返回。不需每次手动写 map_err 转换错误类型了。--> 不错的解决方案。
用 ? 操作符,可在函数的嵌套调用中实现冒泡式的错误向上传递的效果。
通常我们编写的软件有很多依赖,在每个依赖甚至每个模块中,可能都有对应的错误子系统设计。一般会以一个 crate 为边界暴露出对象的错误类型及可能相关的处理接口。因此,如果我们从依赖树的角度来看,你编写的软件的错误系统也是以树的形式组织起来的,是一个层级系统。
层级错误系统中,某一层出现的错误,有的在本层处理,有的用冒泡的方式传递到更上层来处理。 ?操作符是编写冒泡错误处理范式的便捷设施。
从下层传上来的错误,该在哪个层次进行处理?由具体的软件架构设计决定,具体问题具体分析。
完整的错误系统包括:错误的构造和表示、错误的传递、错误的处理。
错误的构造和表示,很棒的库:thiserror。(thiserror - Rust)
自定义的错误类型得实现 std::error::Error 这个 trait,才是在生态意义上讲合格的错误类型。
完整的软件,有非常多的错误类型,自己实现太繁琐。
库 thiserror,可以一体化标注式地生成那些样板代码。
用 thiserror 的方式:
//
use thiserror::Error; // 引入宏
#[derive(Error, Debug)] // 这里derive Error宏
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,
}
用 thiserror,可直接在枚举上 drive Error 宏。大宏下面,还可用 #[error("")]、#[from] 等属性宏对枚举的变体做更多的配置。
通过这样的标注,把目标类型转换成了一个合格的被 Rust 生态认识的错误类型。
Rust 中用 ? 操作符进行错误的冒泡传递。
? 返回的错误类型可能与函数返回值定义的错误类型不一样,这时,要手动做 map_err,手动实现 From
错误处理:对传过来的错误进行处理。
anyhow crate,快速(无脑)地接收和处理错误。统一用 Result
以前要自己定义模块级的 Result,才能简写 std::result::Result。模块层级多了后,光维护这些 Result 类型,都是一件头痛的事情。
//
struct MyError;
type Result = std::result::Result;
现在不需要自定义 Result type 。直接用 anyhow::Result
//
fn foo() -> anyhow::Result {}
实际是在更高的层次上定义了一种错误接收协议——任何模块,大家用 anyhow::Result 就行了,更方便。
用 anyhow::Result
std、anyhow 和 thiseror 可以无缝配合起来使用。
不再为错误类型的不一致,即向上传递的错误类型与函数返回值的错误类型不一致,而头痛了。
//
use anyhow::Result;
fn get_cluster_info() -> Result {
let config = std::fs::read_to_string("cluster.json")?;
let map: ClusterMap = serde_json::from_str(&config)?;
Ok(map)
}
注意,第 4 行返回的错误类型和第 5 行返回的错误类型是不同的,但都能无脑地扔给 anyhow::Result,因为它们都实现了 std::error::Error trait。
用 anyhow::Result
//
match root_cause.downcast_ref::() {
Some(DataStoreError::Censored(_)) => Ok(..),
None => Err(error),
}
anyhow::Result
隐含的知识点,anyhow::Error 保留了错误实例的原始类型信息,能做正确的错误处理分派。
anyhow 提供了辅助宏,简化错误实例的生成。
anyhow! 宏,快速构造出一次性的能被 anyhow::Result
//
return Err(anyhow!("Missing attribute: {}", missing));
anyhow 这个 crate 直接把 Rust 的错误处理的体验提升了一个档次,以统一的形式设计项目的错误处理系统。std、anyhow 和 thiserror 一起构成了 Rust 语言错误处理的最佳实践。
注:更多 anyhow 的资料,请查阅链接。(anyhow - Rust)
系统性地介绍了 Rust 语言的错误处理相关的知识,介绍了 错误处理的最佳实践。
Rust 本身只提供了一些基础设施,Result
社区 thiserror、anyhow 等优秀的 crate,使 Rust 语言错误处理的工程体验提升了一个层次,既好用又优美。这两个库充分展现了 Rust 语言强大的表达能力,anyhow 主要通过 Rust 强大的类型系统实现,thiserror 主要通过 Rust 强大的宏能力实现,而宏正是我们下节课要讲到的内容。
错误处理是软件中非常重要的组成部分,是软件稳定性和安全性的根源所在。Rust 语言强迫我们必须做完善的错误处理,这也是 Rust 语言与众不同的地方。
//