Rust学习笔记7 - 错误处理

本文是 Rust 学习笔记系列第七篇,参考 Rust 指南的第 9 章,主要讲错误处理,将涉及 panic!Result<T, E>

Rust 对可靠性的保证也包括错误处理的部分。错误是在软件开发中无法避开的问题,Rust 提供了多种用于解决出错情况的特性。大部分情况下,Rust 会在编译时告知可能存在的问题,要求你在编译时就解决该问题。这些要求可以让你的程序变得更加健壮,因为它会确保你在发布代码之前可以发现问题并且采取合适的解决方法。

Rust 将错误分为两类:可恢复的错误与不可恢复的错误。可恢复的错误指可以合理的告知用户并且要求重试的错误,比如找不到文件;不可恢复的错误是缺陷的同义词,比如尝试数组下标越界等。

大部分语言不会区分这两者,通过异常(Exception)机制等方式提供统一的解决方法。但是 Rust 中没有实现异常,而是提供了 Result<T, E> 类型用于处理可恢复错误,用 panic! 宏来在遭遇不可恢复错误时,让程序中止。

不可恢复错误与 panic!

有些情况下,当代码出错时,开发者没法解决这些问题。Rust 提供 panic! 宏用于应对这种场景。当执行到 panic! 宏时,程序会打印错误信息,释放并清理所使用的内存,然后结束进程。通常发生在检测出某种缺陷,并且开发者并不明确如何去解决这个错误的时候。

默认情况下,程序在 panic 时,会释放栈(按顺序弹出栈空间,并清理所使用的数据),但是这个过程也是一个不小的开销。另一种方法是就是立即中止执行。那些被程序所占用的内存需要由操作系统来清理。如果需要让编译出的二进制文件尽量小,可以在 Cargo.toml[profile] 节中添加 panic = 'abort' 配置,比如 :

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

我们可以在程序中直接使用 panic!

1
2
3
fn main() {
panic!("Oops!");
}

执行代码会得到如下输出:

1
2
3
4
5
6
$ cargo run
Compiling panic_example v0.1.0 (/projects/panic_example)
Finished dev [unoptimized + debuginfo] target(s) in 0.45s
Running `target/debug/panic_example`
thread 'main' panicked at 'Ooops!', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

查看 panic! 发生的调用栈

如果发生的 panic 并不是由我们自己编写的 panic! 触发的,我们就需要通过调用栈来查找到底是哪里导致的这个问题。

1
2
# 通过设置变量 RUST_BACKTRACE=1 可以让程序打印出详细的调用栈
RUST_BACKTRACE=1 cargo run

可恢复的错误与 Result

大部分错误并没有严重到需要程序中止。有时候函数执行失败时,其原因可以轻易地理解并做出应对。比如当找不到文件时,可能会尝试创建一个文件,而不是让程序中止。

Result 的定义如下:

1
2
3
4
5
6
enum Result<T, E> {
// 表示成功的结果,包括T类型的数据
Ok(T),
// 表示失败的结果,包括E类型的原因
Err(E),
}

我们来试试文件读取的代码:

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

fn main() {
// 这时候 f 是 Result 类型的
let f = File::open("hello.txt");

let f = match f {
// 如果成功,将打开的文件赋值给新的变量 f
Ok(file) => file,
// 如果失败,就直接中止程序
Err(error) => panic!("Cannot open the file: {:?}", error),
};
}

匹配不同错误类型

File::open 返回的结果中,错误为标准库提供的 io::Error 类型。其中 kind 方法可以得到一个 io::ErrorKind 枚举类型的值,可以用于判断错误原因。

我们尝试更详细的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::fs::File;
use std::io::ErrorKind;

fn main() {
let f = File::open("hello.txt");

let f = match f {
Ok(file) => file,
// 如果打开失败,匹配 kind() 的结果
Err(error) => match error.kind() {
// 如果原因是找不到文件,尝试创建文件
ErrorKind::NotFound => match File::create("hello.txt") {
// 如果创建成功,则返回创建的文件
Ok(created_file) => created_file,
// 否则中止程序
Err(create_err) => panic!("Problem creating the file {:?}", create_err),
},
// 如果是其他原因,中止程序
other_error => panic!("Problem opening the file: {:?}", other_error),
},
};

println!("File: {:?}", f);
}

出错时中止的快捷方法:unwrapexpect

使用 match,我们可以正常进行错误判断,并根据不同情况进行处理,不过这些 panic! 稍微有点繁琐。Result<T, E> 类型中有一些函数可以帮我们简化这个任务。

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

fn main() {
// unwrap会在成功时直接返回结果数据,失败时直接panic,输出标准的信息
let f = File::open("hello.txt").unwrap();

// expect会在成功时直接返回结果数据,失败时直接panic,以参数传入的字符串开头
let f = File::open("hello.txt").expect("Failed to open hello.txt");
}

传递错误

有时候你实现了一个函数,但是并不想在函数内部决定如何处理错误,而是交给调用者来处理,这时候我们需要将错误传递到函数外面。

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

fn read_username_from_file() -> Result<String, io::Error> {
let f = File::open("hello.txt");

// 要调用 `read_to_string`,文件必须是可变的
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),
}
}

传递错误的快捷方法:?

下面的例子和刚刚那个一样,只是使用了 ? 来快捷传递错误。

1
2
3
4
5
6
7
8
9
10
11
fn read_username_from_file() -> Result<String, io::Error> {
// 打开文件成功时返回文件,失败时直接让函数返回错误结果
let mut f = File::open("hello.txt")?;

let mut s = String::new();
// 读取到字符串,失败时直接让函数返回错误结果
f.read_to_string(&mut s)?;

// 返回成功读取的字符串
Ok(s)
}

利用 ?,我们甚至可以将代码简化为:

1
2
3
4
5
6
7
8
9
10
fn read_username_from_file() -> Result<String, io::Error> {
let mut s = String::new();

// 打开文件,失败时直接让函数返回错误结果
// 打开成功时,调用 read_to_string 方法
File::open("hello.txt")?.read_to_string(&mut s)?;

// 返回成功读取的字符串
Ok(s)
}

? 只能用于返回 Result 的函数中(或者其他实现了 Try 特性的类型),在其他的函数中,还得写代码判断 Result 的结果。

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

fn read_username_from_file() -> Result<String, io::Error> {
let mut f = File::open("hello.txt")?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}

fn main() {
// 这里使用 ? 会导致编译错误
let f = read_username_from_file()?;

println!("File: {:?}", f);
}

不过,main 函数是比较特殊的,它的返回值类型有一定的约束。其中 ()Result<T, E> 都是允许的返回类型。

我们可以这样修改 main,让它里面可以使用 ?

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

fn read_username_from_file() -> Result<String, io::Error> {
let mut f = File::open("hello.txt")?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}

fn main() -> Result<(), Box<dyn Error>> {
let f = read_username_from_file()?;

println!("File: {:?}", f);

Ok(())
}

这里 Box<dyn Error> 类型被称为是特性对象,这里我们只需要知道它可以指代任意类型的错误。

到底要不要 panic!

所以我们到底啥时候该调用 panic!,啥时候返回 Result 呢?调用 panic! 会使一个可恢复的问题变成不可恢复的错误,所以通常情况下,我们会选择返回 Result。但是在少数情况下,使用 panic! 更加合适。

示例代码、原型代码、测试代码

当为了说明一些概念而编写示例时,进行完善的错误处理会让例子更加复杂。这时候使用 unwrap 或者 except 来简单提示此处可能会产生错误会更好些。

与之类似,当你在原型代码中尚未决定如何处理一些错误时,可以先使用 unwrap 或者 except 来表示此处会产生错误,在后面再通过具体的处理代码来替换掉即可。

在测试中,panic! 是测试失败的标志。即使不是测试的目标,也可以用 unwrap 或者 except 来让错误不可恢复。

当你比编译器了解更多信息时

另一种适合使用 unwrap 的场景是,你有一些其他的逻辑可以保证 Result 只会是 Ok 的。但是编译器可能不知道这些逻辑,他仍然会要求你处理错误情况。

比如:

1
2
3
4
use std::net::IpAddr;

// 这里我们知道 127.0.0.1 是有效的IP地址,但是编译器只会识别为一般的字符串。
let home: IpAddr = "127.0.0.1".parse().unwrap();

错误处理指南

建议在可能导致程序进入「错误的状态」时使用 panic!,「错误的状态」是指某些假设、保证、约定、等式不成立的状态,比如无效的值、自相矛盾的值、缺失的值等等传入程序、并满足以下某些条件:

  • 「错误的状态」并不是预期偶尔发生的情况
  • 这之后的代码需要依赖正确的状态执行
  • 没有好的方式将其转换为所使用的类型

如果某人使用了你的代码,并且传入某些无效的值,最好的选择就是使用 panic! 告诉他代码中存在缺陷,并让其修复该问题。类似地,在调用外部代码时失去控制导致无效状态产生时,也经常使用 panic!

然而,如果失败结果也是预期结果之一时,使用 Result 就更加合适了,比如,解析某种格式的数据或者 HTTP 请求达到频率限制。这些都是程序所预期的错误场景,需要在程序中有明确处理逻辑。

当代码对某些值执行操作时,应该首先验证这些值是否有效,否则抛出 panic!。这主要是出于安全考虑:尝试在无效的数据上进行操作会使代码暴露出漏洞。这也是标准库会在你尝试越界访问内存时使用 panic! 的原因。函数通常存在一些约束:函数的行为仅在满足特定条件的情况下保证正常。当这些约束被打破时应该抛出 panic!,因为这属于调用方的代码缺陷,调用方的开发者需要修复这个问题。函数的约束应该在 API 文档中描述清楚。

然而,在函数中加入大量的错误检查逻辑有些啰嗦且烦人。幸好,我们可以依赖 Rust 的类型系统(编译器的类型检查)来完成大多数检查。如果函数有特定的参数类型,那么编译器会为你保证传入的参数值有正确的类型。如果是某个确切类型而不是 Option,那么程序的预期就是有正确类型的值,而不是没有值,就不需要再处理 Some 或者 None 的情况了。

创建自定义的类型用于验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 创建猜数字的结构,用于保存数值
pub struct Guess {
// value不是公开的,所以模块外部无法使用 Guess { value } 来构造
value: i32,
}

impl Guess {
// 创建示例时,会为我们检查数值的范围
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {}.", value);
}
Guess { value }
}

// 这样在函数外部使用 value() 函数得到的值就一定是 1-100 的数字了
pub fn value(&self) -> i32 {
self.value
}
}

这样我们只要把相应的函数的参数约束为 Guess 类型,我们就一定能够得到 1 - 100 的数字。

总结

Rust 中错误处理相关的功能是设计来帮助你编写健壮的代码的。 panic! 宏将标志你的程序将进入无法处理的状态,会中止程序执行,而不是尝试继续在该状态下执行。Result 枚举使用 Rust 的类型系统来代表程序中可能执行失败,但是可以进行相应的恢复处理的情况。可以使用 Result 来告诉调用方存在一些错误的情况需要处理,使用 panic! 来告诉调用方他的调用不满足函数的约束/使用前提,可以活用 Rust 的类型系统来直接约束函数的参数。

# Rust
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×