【Rust 学习记录】9. 错误处理

TwoSix Lv3

前言

Rust 里的错误主要分为两种:1. 不可恢复错误:主要指的就是程序Bug之类的用户不可见的错误,例如尝试访问超过数组长度的下标;2. 可恢复错误,例如文件没找到等,可以提示用户再次查找。Rust 对这两种错误进行了区分,并针对不同的场景提供了许多的特性来处理

不可恢复错误与Panic!

Panic!宏

介绍

Panic!宏是专门用于处理某个错误被检测到,而程序员不知道该怎么处理的情景。Panic!宏会首先打印一段错误提示信息,然后沿着调用的栈反向遍历,清理等待执行的指令以及它们的数据,清理完毕后退出程序。

提示

PS: Rust程序打包时会默认附带很多信息来支持Panic!的栈展开清理操作,如果你不需要 Rust 自己清理,可以接受把内存交由操作系统来回收,并且需要打包的二进制包体尽可能小的话,可以在Cargo.toml[profile]区域添加panic = 'abort'来讲panic的默认行为从展开切换为直接终止。例如

1
2
[profile.release]
panic = 'abort'

使用

1
2
3
fn main() {
panic!("crash and burn");
}

运行这段代码,会得到报错

1
thread 'main' panicked at 'crash and burn', src\main.rs:2:5note: run with RUST_BACKTRACE=1 environment variable to display a backtrace

第一句很简单,输出了我们的报错信息,并告诉了我们panic的位置在main.rs第2行第5个字符。

第二句则提示我们可以用环境变量RUST_BACKTRACE=1显示回溯信息,是什么意思呢?我们可以试一下。设置环境变量后再运行

1
2
// windows系统下,powersell环境的命令
$env:RUST_BACKTRACE=1; cargo run

我们可以看到新的报错信息

1
2
3
4
5
6
7
8
9
stack backtrace:
0: std::panicking::begin_panic_handler
at /rustc/8460ca823e8367a30dda430efda790588b8c84d3/library\std\src\panicking.rs:575
1: core::panicking::panic_fmt
at /rustc/8460ca823e8367a30dda430efda790588b8c84d3/library\core\src\panicking.rs:64
2: test_error::main
at .\src\main.rs:2
3: core::ops::function::FnOnce::call_once<void (*)(),tuple$<> >
at /rustc/8460ca823e8367a30dda430efda790588b8c84d3\library\core\src\ops\function.rs:250

可以看到这就是我们panic栈展开的时候的具体信息,首先是panic函数,再是panic标准化输出,然后是我们自己的文件.\src\main.rsmain函数,最后我也不清楚,可能是main函数入口吧。

我们就可以根据这个回溯信息,一个一个查找到错误发生的地方,进行排除(感觉用的不会很多?)

另外,后面我们还得运行代码,如果不想看到这么一长串,记得把这个环境变量设回0

可恢复错误与Result

Result枚举类型

简单使用

其实我们在之前就已经使用过了Result了,在编写随机数字时,我们在处理用户输入的时候就用了expect函数,那时候我们就简单的提到了,Result是一个枚举类型,包含OkErr两个变体。它的定义是这样的:

1
2
3
4
enum Result<T,E>{
Ok(T),
Err(E),
}

其中T,E是两个泛型参数,下一章就会讨论到泛型。总之就是1. T包含了Ok里的值,跟随着程序执行成功时返回对应的值;2. E包含了错误类型,跟随着执行失败的时候返回

当一个可能会运行失败的函数,可以将Result作为返回结果,例如标准库里的打开文件操作

1
2
3
4
5
6
7
8
9
10
11
use std::fs::File;

fn main() {
let f = File::open("hello.txt");
let f = match f{
Ok(file) => file,
Err(error) => {
panic!("There was a problem opening the file: {:?}", error)
}
};
}

File::open函数回返回一个Result类型,所以我们需要用matchf进行额外的处理,取出Result里的值,才能正确的获得打开文件,这迫使程序员必须用match枚举所有可能性,不然就无法正常编译~

处理不同错误

文件打开可能有多种错误,可能是文件不存在,可能是没有文件的读权限,那我们怎么从Err里分辨多种错误呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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(error) => panic!("Problem creating the file: {:?}", error)
}
},
other_error => panic!("Problem opening the file: {:?}", other_error)
}
}
};
}

这段代码多了不少东西,我们一个一个来说

  1. 通过use引入了io标准库的标准io相关错误类型ErrorKind
  2. error错误类型的值可以通过 .kind 函数获取它的错误类型,错误类型也是一个枚举类型
  3. 所以可以通过 match 匹配错误类型,我们针对找不到文件的类型作了特殊处理——创建一个文件(创建文件的同时也要处理创建是否成功的Result类型),对于其他类型不知道怎么处理,就调用 panic!

可以看见,写一段 rust 代码突然变得繁琐起来了,本来我们只要几行代码就搞定的东西,Rust 却逼我们写了一堆 match 来处理报错,可读性也损失了不少。

书上提到了 Rust 提供了一个闭包的特性解决这个问题,简化代码,增加错误处理的可读性,以下是一个代码示例。但具体在后面讲解闭包的时候再作解释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use std::fs::File;
use std::io::ErrorKind;

fn main() {
let f = File::open("hello.txt").map_err(|error|{
if error.kind() == ErrorKind::NotFound{
File::create("hello.txt").unwrap_or_else(|error|{
panic!("Problem creating the file: {:?}", error);
})
}
else{
panic!("Problem opening the file: {:?}", error);
}
});
}

快捷处理方式

每次都写 match 和 panic! 来处理实在太麻烦了,Rust 当然也提供了一些快捷方式——expectunwrap

unwrap

unwarp可以在返回的是 Ok 时直接返回值,是 Err 时直接自动帮你触发 panic! 报错。

1
2
3
4
5
use std::fs::File;

fn main() {
let f = File::open("hello2.txt").unwrap();
}

没有文件时,会直接触发报错

1
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "系统找不到指定的文件。" }'

expect

expect的效果和unwarp是差不多的,不同点在于expect可以让程序员自己指定一个报错的提示信息

1
thread 'main' panicked at 'Failed to open hello2.txt: Os { code: 2, kind: NotFound, message: "系统找不到指定的文件。" }', src\main.rs:4:38

可以看见 paniked at xxx 的地方发生了变化

错误传播

上面提到了可能执行失败的函数会返回一个错误类型,那我们自己写的函数怎么返回呢?以下是一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use std::fs::File;
use std::io;
use std::io::Read;

fn my_function() -> 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),
}
}
fn main() {
my_function().unwrap();
}

这里我们定义了一个函数,返回类型是 Result 枚举,其中两个关于Ok和Err的泛型参数被我们规定为了字符串和标准io错误(这里用标准io错误是因为我们函数里相关的错误都是io错误,当然你可以自己定义其他错误,作一个额外处理然后返回)。

函数里首先对 hello.txt 文件进行读取,读取成功的话取出 Ok 里的文件句柄 file,失败的话则用 return 关键字提前结束函数,返回 Err 类型,然后读取文件内容,存到字符串里,同时也要处理是否读取成功,最后一个 match 表达式不写分号即可直接返回值,不用 return。

PS: 这里有个小细节,f 必须是可变的才能读取内容。为什么呢?我只读文件没有修改文件内容啊。因为这里读取到的 f 只是一个句柄,而我们真正读取内容的时候,需要修改偏移量什么的吧。因此需要可变。

?运算符

因为错误传播太常见了,所以这一块有着一点语法糖——?运算符

我们直接看一段例子

1
2
3
4
5
6
fn my_function() -> Result<String, io::Error>{
let mut f = File::open("hello.txt")?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}

是不是感觉瞬间干净了不少

?运算符的工作和我们之前的match是差不多的,首先,它只能用于Reult类型的后面,且只能用于返回值的Result类型的函数。在值为Ok的时候,它会把Ok的值返回作为这个表达式的结果。在值为Err的时候,它会调用return把错误类型作为整个程序的返回。

最后我们手动构建了一个Result的Ok变体用于运行成功的返回。

?运算符也和match表达式有一点点不同。那就是它对于错误类型,也就是Err(e)里的e,会偷偷调用一次from函数,把错误类型转换成我们函数所返回的错误类型,这对于我们有不同类型的错误,但函数返回的类型只有一种时很有用,如果是match还需要我们手动进行额外处理。

当然,我们还能把代码写的更短,在实际项目中,给文件句柄赋予一个 f 变量其实也是多余的

1
2
3
4
5
fn my_function() -> Result<String, io::Error>{
let mut s = String::new();
File::open("hello.txt")?.read_to_string(&mut s)?;
Ok(s)
}

各种报错方法的使用场景

关于panic!和Result

调用 panic! 代表程序已经无法从这个错误中恢复了,所以选择自行了解。所以当你觉得在某种情况下无论如何也恢复不了,就可以代替使用这个函数的人直接 panic!,不然大多时候都可以选择返回一个 Result,交由使用者自己决定是不是 panic!

当然,还有另外一种情况,那就是用户的操作违反了你所编写的一些程序的“约定”,且这个非法操作会破坏你代码的一些原有行为,难以恢复,又或者是可能会触及到你代码的一些安全漏洞,这时候就可以选择直接 panic! 以终止用户的非法行为。例如数组访问越界,防止用户因为数组访问越界,访问到不属于这个数组的内存数据,以此造成一些安全问题的时候,我们通常会直接 panic!

关于expect和unwrap

对于早期开发和测试

使用 expect 和 unwrap 处理错误固然方便,但也代表我们失去了处理不同错误的机会,因为它会帮助我们直接把程序 panic! 掉。

所以一般情况下,我们都会把 expect 和 unwrap 当成是一个占位符,告诉后面,这里有个错误需要处理,但在开发的早期和需要测试的时候,我们为了方便会选择直接把程序 panic! 掉,既提高了开发效率,也可以方便测试,毕竟程序都直接崩溃了,这里的bug你总不能忽视了吧?快修bug去。

当然在后期交付用户的时候,为了用户友好性,当然不能一点小错误就直接 panic!,这时候我们就可以一个一个的查询之前留下的 expect 和 unwrap,去处理更细致的报错。

对于你确定不会发生报错的时候

在某些时候,你确定100%不可能会出现 Err 变体,那当然也能直接用 unwrap 节省代码量。例如

1
2
3
4
use std::net::IpAddr;
fn main() {
let home: IpAddr = "127.0.0.1".parse().unwrap();
}

127.0.0.1 总不可能是个非法IP地址了吧?这还要我去处理各种报错就有点不合理了。


以上,就是错误处理的全部内容了,大致总结一下就是

  1. panic! 和 Result——硬处理和软处理的方式
  2. Result处理的优化——expect, unwrap 和 ?运算符
  3. 错误类型——error.kind()
  4. 一些基本的错误处理场景原则

第10章开始就是 trait,泛型和生命周期了。

  • 标题: 【Rust 学习记录】9. 错误处理
  • 作者: TwoSix
  • 创建于 : 2023-04-06 22:23:58
  • 更新于 : 2024-07-04 23:52:28
  • 链接: https://twosix.page/2023/04/06/【Rust-学习记录】9-错误处理/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论