0%

Rust 权威指南(08):错误处理

为了应对软件中那些几乎无法避免的错误,Rust 提供了许多特性来处理这类出了问题的场景。在大部分情况下,Rust 会迫使你意识到可能出现错误的地方,并在编译阶段确保它们得到妥善的处理。在 Rust 中,将错误分为两类:可恢复错误不可恢复错误

不可恢复错误与 panic!

程序会在 panic! 宏执行时打印出一段错误提示信息,展示并清理当前的调用栈,然后退出程序。这种情况一般发生在 某个错误被检测到,但程序员却不知道该如何处理 的时候。

panic 发生时,程序默认会进行栈展开,Rust 会沿着调用栈的反向顺序遍历所有调用函数,并依次清理这些函数中的数据,这需要我们在二进制中存储许多额外信息。除了展开,还可以选择立即终止程序,可以在 Cargo.toml 文件的 [profile] 区域添加 panic='abort' 来将 panic 的默认行为从栈展开切换为终止。

1
2
3
fn main() {
panic!("crash and burn");
}
1
2
3
# cargo run
thread 'main' panicked at 'crash and burn', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

通过设置环境变量 RUST_BACKTRACE 可以得到回溯信息,进而确定触发错误的原因。回溯信息包含了到达错误点的所有调用函数列表:

1
2
3
4
5
6
7
8
9
10
11
12
# RUST_BACKTRACE=1 cargo run
thread 'main' panicked at 'crash and burn', src/main.rs:2:5
stack backtrace:
0: rust_begin_unwind
at /rustc/69f9c33d71c871fc16ac445211281c6e7a340943/library/std/src/panicking.rs:575:5
1: core::panicking::panic_fmt
at /rustc/69f9c33d71c871fc16ac445211281c6e7a340943/library/core/src/panicking.rs:65:14
2: panic_test::main
at ./src/main.rs:2:5
3: core::ops::function::FnOnce::call_once
at /rustc/69f9c33d71c871fc16ac445211281c6e7a340943/library/core/src/ops/function.rs:251:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

为了获取带有调试信息的回溯,必须启用调试符号(debug symbol),在运行 cargo build 或者 cargo run 时,如果没有附带 --release 标志,那么调试符号就是默认开启的。

可恢复错误与 Result

大部分错误其实都没有严重到需要整个程序停止运行的地步,可以使用 Result<T, E> 枚举类型来处理可能失败的情况,它定义了两个变体 Ok、Err。Result 枚举及其变体已经通过预导入模块被自动地引入当前作用域中。

1
2
3
4
enum Result<T, E> {
Ok(T),
Err(E)
}
  • T 代表了 Ok 变体中包含的值类型,该变体中的值会在执行成功时返回
  • E 则代表了 Err 变体中包含的错误类型,该变体中的值会在执行失败时返回
1
2
3
4
5
6
7
8
9
10
11
12
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)
},
};
}
1
2
3
# cargo run
thread 'main' panicked at 'there was a problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:9:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

如下代码还会根据不同的失败原因做出不同的反应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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!("tried to create file but failed: {:?}", e),
},
other_error => panic!("there was a problem opening the file: {:?}", other_error),
},
};
}

这段代码出现了很多 match,在处理错误时,类型 Result<T, E> 定义了许多辅助方法来应对各种任务。有许多方法可以简化嵌套的 match 表达式

失败时触发 panic 的快捷方式:unwrap 和 expect

unwrap 方法的作用是:当 Result 的返回值是 Ok 变体时,unwrap 就会返回 Ok 内部的值,而当 Result 的返回值是 Err 变体时,unwrap 则会调用 panic! 宏。expect 方法允许我们在 unwrap 的基础上指定 panic! 所附带的错误提示信息。使用 expect 并附带上一段清晰的错误提示信息可以阐明你的意图,并使你容易追踪到 panic 的起源。

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

fn main() {
let f = File::open("test.txt").unwrap();
let f = File::open("test.txt").expect("Failed to open txt");
}

传播错误

当编写的函数中包含了一些可能执行失败的调用时,除了可以在函数中处理这个错误,还可以将错误返回给调用者,让它们决定如何做进一步处理。这个过程也称为 传播错误。与编写代码时的上下文环境相比,调用者可能会拥有更多的信息和逻辑来决定应该如何处理错误。

调用下面代码的用户将需要处理包含了 username 的 Ok 值,或者包含了 io::Error 实例的 Err 值。

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


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),
}
}

fn main() {
println!("{:?}", read_username_from_file());
}

传播错误的模式在 Rust 编程中非常常见,所以 Rust 专门提供了一个 ? 运算符来简化它的语法。将 ? 放置在 Result 值后面,可以实现和 match 表达式处理 Result 一样的功能:

  • Result 的值是 Ok,那么包含在 Ok 变体中的值就会作为该表达式的结果返回并继续执行程序
  • Result 的值是 Err,那么该值会作为程序的结果返回,如同使用了 return 一样将错误传播给调用者

但是 match 表达式与 ? 还是有一个区别:被 ? 运算符所接收的错误值会隐式地被 from 函数(定义在标准库的 From trait 中)处理,用于在错误类型之间进行转换。即它会尝试将传入的错误类型转换为当前函数的返回错误类型。只要每个错误类型都实现了转换为错误类型的 from 函数,? 运算符就会自动处理所有的转换过程。

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


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() {
println!("{:?}", read_username_from_file());
}

更加简化的写法则为:

1
2
3
4
5
6
fn read_username_from_file() -> Result<String, io::Error> {

let mut s = String::new();
File::open("hello.txt")?.read_to_string(&mut s)?;
Ok(s)
}

当然最简化的写法则是直接调用标准库函数 fs::read_to_string(),它可以直接完成:打开文件、读读取文件内容到 String 的工作。

? 运算符只能用于那些拥有 Result 返回类型(准确的说法是 Result、Option 或者任何实现了 std::ops::Try 的类型)的函数。

对于特殊的 main 函数而言,可用的返回类型除了 () 之外,还有 Result<T, E>。下面代码的 Box<dyn Error> 被称为 trait 对象。

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


fn main() -> Result<(), Box<dyn Error>> {
let f = File::open("hello.txt")?;
Ok(())
}

要不要使用 panic!

只要你认为你自己可以替代调用者决定某种情形是不可恢复的,那么就可以使用 panic!。当选择返回一个 Result 值时,就将这种选择权给了调用者。

在示例、原型程序中使用 unwrapexpect 会非常方便,大家约定俗成地将 unwrap 之类可能会导致 panic 的方法理解为某种占位符,用来标明那些需要由应用程序进一步处理的错误。根据上下文环境的不同,具体的处理方法也会不同。

当一个函数返回 Result 值时,编译器会要求我们处理 Err 变体可能出现的情形,即使此时逻辑上不可能出现错误(编译器在编译阶段无法确定程序逻辑的正确)。假如你人工确保程序代码永远不会真正返回 Err 变体,那么也可以使用 unwrap。

错误处理的指导原则

当某个错误可能会导致代码处于损坏状态时,推荐在代码中使用 panic 来处理错误。损坏状态意味着设计中的一些假设、保证、约定或不可变性出现了被打破的情形。但是假如错误是可预期的,那么就应该返回一个 Result 而不调用 panic。

在所有函数中都进行错误检查和处理可能会有些冗长和麻烦,但幸运的是,可以借助 Rust 的类型系统(即编译器所做的类型检查)来自动完成某些检测工作。