错误处理(Error Handling)

本文大部分内容翻译自:The Rust Programming Language

Rust将错误分为两大类:可恢复的(recoverable)和 不可恢复的(unrecoverable)错误。

  • 可恢复的错误:比如文件未找到的错误,我们很可能只想向用户报告问题并重试操作。
  • 不可恢复的错误:bug出现的征兆,比如试图访问一个超过数组末端的位置,因此我们要立即停止程序。

大多数语言并不区分这两种错误,并采用类似异常这样方式统一处理他们。Rust没有异常。与之相对的是,它有Result<T, E>类型,用于处理可恢复的错误,还有panic!宏,在程序遇到不可恢复的错误时停止执行。

1. 用panic!处理不可恢复的错误

执行panic!宏时,程序会打印出一个错误信息,展开并清理栈数据,然后退出。

出现这种情况的场景通常是检测到一些类型的bug,而且程序员并不清楚该如何处理它。

当出现panic时:

  • 程序默认会开始展开(unwinding),即Rust会回溯栈,并清理它遇到的每一个函数的数据。这个回溯并清理的过程有很多工作。
  • 另一种选择是直接终止(abort)。这会不清理数据就退出程序,程序所使用的内存需要由操作系统来清理。
    • 如果你需要项目的最终二进制文件越小越好,panic时通过在Cargo.toml[profile]部分增加panic = 'abort',可以由展开切换为终止。例如,如果你想要在release模式中 panic时直接终止:
      1
      2
      [profile.release]
      panic = 'abort'

1.1. 调用panic!

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

运行后的输出:

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

最后两行包含panic!调用造成的错误信息,第一行显示了panic提供的信息并指明了源码中panic出现的位置:

  • src/main.rs:2:5表明这是src/main.rs文件的第二行第五个字符

在这个例子中,被指明的那一行是我们代码的一部分,而且查看这一行的话就会发现panic!宏的调用。在其他情况下,panic!可能会出现在我们的代码所调用的代码中。

错误信息报告的文件名和行号可能指向别人代码中的panic!宏调用,我们可以使用panic!被调用的函数的backtrace来寻找代码中出问题的地方。

1.2. panic!的backtrace

例:

1
2
3
4
5
fn main() {
let v = vec![1, 2, 3];

v[99];
}

代码尝试访问vector的第一百个元素(这里的索引是99因为索引从0开始),不过它只有三个元素。这种情况下Rust会panic。[]应当返回一个元素,不过如果传递了一个无效索引,就没有可供Rust返回的正确的元素。

C语言中,尝试读取数据结构末尾之后的值是未定义行为(undefined behavior)。即使该内存位置不属于这个数据结构,也可能得到该位置的值。这被称为缓冲区溢出(buffer overread),可能会导致安全漏洞,比如攻击者可以像这样操作索引来读取储存在数据结构之后不被允许访问的数据。

为了保护程序远离这类漏洞,当尝试读取一个索引不存在的元素,Rust会停止执行并拒绝继续。运行上面的程序会出现如下编译错误:

1
2
3
4
5
6
$ cargo run
Compiling panic v0.1.0 (file:///projects/panic)
Finished dev [unoptimized + debuginfo] target(s) in 0.27s
Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

错误指向main.rs的第4行,这里我们尝试访问索引99。下面的说明(note)行提醒我们可以设置RUST_BACKTRACE环境变量来获得backtrace。

backtrace是一个列表,显示执行到目前位置所有被调用的函数。Rust的backtrace跟其他语言中的一样,阅读backtrace的关键是从头开始读,直到发现你编写的文件。这就是问题的根源所在:这一行往上是你的代码所调用的代码,往下是调用你的代码的代码。这些行可能包含核心Rust代码,标准库代码或用到的crate代码。

RUST_BACKTRACE环境变量设置为任何不是0的值来获取backtrace,可以看到类似下面的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
stack backtrace:
0: rust_begin_unwind
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/std/src/panicking.rs:483
1: core::panicking::panic_fmt
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/panicking.rs:85
2: core::panicking::panic_bounds_check
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/panicking.rs:62
3: <usize as core::slice::index::SliceIndex<[T]>>::index
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/slice/index.rs:255
4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/slice/index.rs:15
5: <alloc::vec::Vec<T> as core::ops::index::Index<I>>::index
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/alloc/src/vec.rs:1982
6: panic::main
at ./src/main.rs:4
7: core::ops::function::FnOnce::call_once
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/ops/function.rs:227
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

这里有大量的输出。为了获取含有这些信息的backtrace,必须启用debug标识。不使用--release参数运行cargo buildcargo run时debug标识会默认启用。

上面输出的backtrace中, 第12行指向了我们项目中造成问题的行:src/main.rs的第4行。

如果你不希望程序panic,第一个提到我们编写的代码行的位置是你应该开始调查的,以便查明是什么值,如何在这个地方引起了 panic。

上例中,我们故意编写会panic的代码来演示如何使用backtrace,修复这个panic的方法就是不要尝试在一个只包含三个项的vector 中请求索引是100的元素。当将来你的代码出现了panic,你需要搞清楚在特定的场景下代码中执行了什么操作和什么值导致了panic,以及应当如何处理才能避免这个问题。

2. 用Result处理可恢复的错误

2.1. Result枚举

大部分错误并没有严重到需要程序完全停止执行。例如,如果因为打开一个并不存在的文件而失败,我们可能想要创建这个文件,而不是终止进程。

Result枚举有两个成员,OkErr

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

TE是泛型类型参数:

  • T表示成功时返回的Ok成员中的数据的类型
  • E代表失败时返回的Err成员中的错误的类型

因为Result有这些泛型类型参数,我们可以将Result类型和标准库中为其定义的函数用于很多不同的场景,这些情况中需要返回的成功值和失败值可能会各不相同。

例:

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

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

如何知道File::open返回一个Result呢?我们可以查看标准库API文档,或者可以直接问编译器:如果给f某个我们知道不是函数返回值类型的类型注解,然后尝试编译代码,编译器会告诉我们类型不匹配。然后错误信息会告诉我们f的类型应该是什么。

例如:

1
let f: u32 = File::open("hello.txt");

编译会给出如下输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ cargo run
Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0308]: mismatched types
--> src/main.rs:4:18
|
4 | let f: u32 = File::open("hello.txt");
| --- ^^^^^^^^^^^^^^^^^^^^^^^ expected `u32`, found enum `Result`
| |
| expected due to this
|
= note: expected type `u32`
found enum `Result<File, std::io::Error>`

For more information about this error, try `rustc --explain E0308`.
error: could not compile `error-handling` due to previous error

这就告诉我们了File::open函数的返回值类型是Result<T, E>

  • 泛型参数T放入了成功值的类型std::fs::File,它是一个文件句柄
  • E被用在失败值上时,类型是std::io::Error

这个返回值类型说明File::open调用可能会成功并返回一个可以进行读写的文件句柄,也可能会失败,例如文件可能并不存在,或者可能没有访问文件的权限。

File::open需要一个方式告诉我们是成功还是失败,并同时提供给我们文件句柄或错误信息。而这些信息正是Result枚举可以提供的。

File::open成功的情况下,变量f的值将会是一个包含文件句柄的Ok实例;在失败的情况下,f的值会是一个包含更多关于出现了何种错误信息的Err实例。

根据File::open返回值进行不同处理:

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

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

let f = match f {
Ok(file) => file,
Err(error) => panic!("Problem opening the file: {:?}", error),
};
}

这里我们告诉Rust当结果是Ok时,返回Ok成员中的file值,然后将这个文件句柄赋值给变量f。match之后,我们可以利用这个文件句柄来进行读写。

match的另一个分支处理从File::open得到Err值的情况。在这种情况下,我们选择调用panic!宏。如果当前目录没有一个叫做hello.txt的文件,当运行这段代码时会看到如下来自panic!宏的输出:

1
2
3
4
5
6
$ cargo run
Compiling error-handling v0.1.0 (file:///projects/error-handling)
Finished dev [unoptimized + debuginfo] target(s) in 0.73s
Running `target/debug/error-handling`
thread 'main' panicked at 'Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:8:23
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Option枚举一样,Result枚举和其成员也被导入到了prelude中,所以就不需要在match分支中的OkErr之前指定 Result::

2.2. 匹配不同的错误

商界中的代码不管File::open因为什么原因失败都会panic!。我们真正希望的是对不同的错误原因采取不同的行为:

  • 如果File::open因为文件不存在而失败,我们希望创建这个文件并返回新文件的句柄
  • 如果File::open因为任何其他原因失败,例如没有打开文件的权限,我们仍然希望panic!
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::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!("Problem creating the file: {:?}", e),
},
other_error => {
panic!("Problem opening the file: {:?}", other_error)
}
},
};
}

File::open返回的Err成员中的值类型io::Error,它是一个标准库中提供的结构体。这个结构体有一个返回 io::ErrorKind值的kind方法可供调用。

io::ErrorKind是标准库提供的枚举,它的成员对应io操作可能导致的不同错误类型。我们感兴趣的成员是 ErrorKind::NotFound,它表示尝试打开的文件并不存在。

error.kind()还有一个内层match。我们希望在内层match中检查的条件是error.kind()的返回值是否为ErrorKindNotFound成员。如果是,则尝试通过File::create创建文件。因为File::create也可能会失败,还需要增加一个内层 match语句。

外层 match的最后一个分支保持不变,当文件不能被打开,会打印出一个不同的错误信息。


闭包(Closure)与unwrap_or_else方法

上面用来很多match。

match确实很强大,不过也非常的基础。闭包(closure)可用于很多Result<T, E>定义的方法。在处理代码中的Result<T, E>值时这些方法可能会更加简洁。

例如:

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

fn main() {
let f = File::open("hello.txt").unwrap_or_else(|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表达式,且更容易阅读。在处理错误时,还有很多这类方法可以消除大量嵌套的match表达式。


2.3. 失败时panic的简写:unwrapexpect

2.3.1. upwrap

match能够胜任它的工作,不过它可能有点冗长,并且不总是能很好的表明其意图。Result<T, E>类型定义了很多辅助方法来处理各种情况,其中之一叫做unwrap,它的实现就类似于之前例子中的match语句:

  • 如果Result是成员Okunwrap会返回Ok中的值
  • 如果Result是成员Errunwrap会为我们调用panic!

例:

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

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

如果调用这段代码时不存在hello.txt文件,我们将会看到unwrap调用panic!时提供的错误信息:

1
2
3
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Error {
repr: Os { code: 2, message: "No such file or directory" } }',
src/libcore/result.rs:906:4

2.3.2. expect

还有另一个类似于unwrap的方法,它还允许我们提供panic!的错误信息:expect

使用expect而不是unwrap,并提供一个好的错误信息,可以表明你的意图并更易于追踪panic的根源。

例:

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

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

expectunwrap的使用方式一样:返回文件句柄或调用panic!宏。expect在调用panic!时使用的错误信息是我们传递给expect的参数,而不像unwrap那样使用默认的panic!信息。

例如:

1
2
thread 'main' panicked at 'Failed to open hello.txt: Error { repr: Os { code:
2, message: "No such file or directory" } }', src/libcore/result.rs:906:4

因为这个错误信息以我们指定的文本开始,Failed to open hello.txt,将会更容易找到代码中的错误信息来自何处。如果在多处使用unwrap,则需要花更多的时间来分析到底是哪一个unwrap造成了 panic,因为所有的unwrap调用都打印相同的信息。

2.3.3. 传播错误

当程序中会调用一些可能操作失败的函数时,除了在这个函数中处理错误外,还可以选择让调用者知道这个错误并决定该如何处理。这被称为传播(propagating)错误。这样能更好的控制代码调用,因为比起你代码所拥有的上下文,调用者可能拥有更多信息或逻辑来决定应该如何处理错误。

例:

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

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),
}
}
  • 函数的返回值:Result<String, io::Error>

    • 这意味着函数返回一个Result<T, E>类型的值,其中泛型参数T的具体类型是String,E的具体类型是io::Error
      • 如果这个函数没有出任何错误成功返回,函数的调用者会收到一个包含String的Ok值。
      • 如果函数遇到任何错误,函数的调用者会收到Err值,它储存了一个与这个问题相关信息的io::Error实例。这里选择 io::Error作为函数的返回值,是因为它正好是函数体中那两个可能会失败的操作的错误返回值:File::open函数和read_to_string方法。
  • 函数体以调用File::open函数开始,接着使用match处理返回值Result

    • 如果File::open成功了,模式变量file中的文件句柄就变成了可变变量f中的值,接着函数继续执行。
    • Err的情况下,我们没有调用panic!,而是使用return关键字提前结束整个函数,并将来自File::open的错误值(现在在模式变量e中)作为函数的错误值传回给调用者。
  • f中有了一个文件句柄,函数接着在变量s中创建一个新String,并调用文件句柄fread_to_string方法来将文件的内容读取到s中。

    • read_to_string方法也返回一个Result,因为它也可能会失败(哪怕File::open已经成功了)。所以我们需要另一个 match来处理这个Result
      • 如果read_to_string成功了,那么这个函数就成功了,并返回文件中的内容,会被封装进Oks中。
      • 如果read_to_string失败了,则像之前处理File::open的返回值的match那样返回错误值。

调用这个函数的代码最终会得到一个包含文件内容的Ok值,或包含io::ErrorErr值。我们没有足够的信息知晓调用者具体会如何处理这些值,所以将所有的成功或失败信息向上传播,让他们选择合适的处理方法。

这种传播错误的模式在Rust中非常常见,因此Rust提供了?问号运算符来使其更易于处理。

2.4. 传播错误的简写:?运算符

2.4.1. ?运算符

例:

1
2
3
4
5
6
7
8
9
10
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)
}
  • File::open调用结尾的?,把Ok中的值返回给变量f
  • 如果出现了错误,?运算符会提早返回整个函数,并将一些Err值传播给调用者
  • 同理也适用于read_to_string调用结尾的?

Result值之后的?与之前例子中处理Result值的match表达式有完全相同的工作方式:

  • 如果Result的值是Ok,这个表达式将会返回Ok中的值,程序继续执行。
  • 如果值是ErrErr中的值将作为整个函数的返回值,就好像使用了return关键字一样,这样错误值就被传播给了调用者。

之前例子中的match表达式与问号运算符所做的有一点不同:?运算符所使用的错误值被传递给了from函数。

  • from函数定义于标准库的 From trait中,用来将错误从一种类型转换为另一种类型。
  • ?运算符调用from函数时,收到的错误类型被转换为“当前函数的返回类型所指定的错误类型”。
    • 当使用函数返回的单个错误类型,来代表所有可能失败的方式时,这很有用。
    • 只要每一个错误类型都实现了from函数,即定义了如何将自身转换为返回的错误类型,?运算符就会自动处理这些转换。

?运算符消除了大量样板代码,使函数的实现更简单。我们甚至可以在?之后使用链式方法调用来进一步缩短代码:

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

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

在上面代码中:

  • File::open("hello.txt")?的结果直接链式调用了read_to_string,而不再创建变量f
  • 仍然需要read_to_string调用结尾的?
  • File::openread_to_string都成功时,返回包含文件内容sOk

甚至还有一个更短的写法:

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

fn read_username_from_file() -> Result<String, io::Error> {
fs::read_to_string("hello.txt")
}

将文件读取到一个字符串是相当常见的操作,所以Rust提供了名为fs::read_to_string的函数,它会:

  • 打开文件
  • 新建一个String
  • 读取文件的内容,并将内容放入String
  • 接着返回String

2.4.2. 哪里可以使用?运算符

?运算符被定义为从函数中提早返回一个值,它只能被用于返回值与?作用的值相兼容的函数。

例如之前的例子:

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

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

match作用于一个Result值,提早返回的分支返回了一个Err(e)值。函数的返回值必须是Result才能与这个return相兼容。

但在下面示例中,main函数的返回值与?作用的值不兼容,就会有编译错误:

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

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

这段代码打开一个文件,这可能会失败。?运算符作用于File::open返回的Result值,但main函数的返回类型是(),而不是Result。当编译这段代码,会得到如下错误信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ cargo run
Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
--> src/main.rs:4:36
|
3 | / fn main() {
4 | | let f = File::open("hello.txt")?;
| | ^ cannot use the `?` operator in a function that returns `()`
5 | | }
| |_- this function should return `Result` or `Option` to accept `?`
|
= help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling` due to previous error

这个错误指出,只能在返回Result,或者其它实现了FromResidual的类型的函数中使用?运算符。

为了修复这个错误,有两个选择:

  • 如果没有限制的话将函数的返回值改为Result<T, E>
  • 使用match或Result<T, E>的方法中合适的一个来处理Result<T, E>

错误信息也提到?可用于Option<T>值。就像对Result使用?一样,只能在返回Option的函数中对Option使用?。在 Option<T>上调用?运算符的行为与Result<T, E>类似:

  • 如果值是NoneNone会从函数中提前返回
  • 如果值是SomeSome中的值作为表达式的返回值,同时函数继续

例:从给定文本中返回第一行最后一个字符

1
2
3
fn last_char_of_first_line(text: &str) -> Option<char> {
text.lines().next()?.chars().last()
}
  • 这个函数返回Option<char>
  • 代码获取text string slice作为参数,并调用lines方法,返回一个字符串中每一行的迭代器。因为函数希望检查第一行,所以调用了迭代器next来获取迭代器中第一个值。
    • 如果text是空字符串,next调用会返回None,此时我们可以使用?来停止,并从last_char_of_first_line返回 None
    • 如果text不是空字符串,next会返回一个包含text中第一行的string slice的Some值。
  • ?会提取这个string slice,然后可以在string slice上调用chars来获取字符的迭代器。
  • 我们感兴趣的是第一行的最后一个字符,所以可以调用last来返回迭代器的最后一项。这是一个Option,因为有可能第一行是一个空字符串。

注意你可以在返回Result的函数中对Result使用?运算符,可以在返回Option的函数中对Option使用?运算符,但是不可以混合搭配。?运算符不会自动将Result转化为Option,反之亦然。在这些情况下,可以使用类似Resultok方法或者 Optionok_or方法来显式转换。

目前为止,我们所使用的所有main函数都返回()main函数是特殊的,因为它是可执行程序的入口点和退出点,为了使程序能正常工作,其可以返回的类型是有限制的。

幸运的是,main函数也可以返回Result<(), E>,例如:

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

Box<dyn Error>类型是一个trait对象,可以理解为“任何类型的错误”。在返回Box<dyn Error>错误类型的main函数中对 Result使用?是允许的,因为它允许任何Err值提前返回。

main函数返回Result<(), E>类型时:

  • 如果main返回Ok(()),可执行程序会以0值退出
  • 如果main返回Err,则会以非零值退出

成功退出的程序会返回整数0,运行错误的程序会返回非0的整数。Rust也会从二进制程序中返回与这个惯例相兼容的整数。

main函数也可以返回任何实现了std::process::Termination trait的类型。Termination trait是一个不稳定功能(unstable feature)(截至2022-02-24),只能用于Nightly Rust中,所以你不能在稳定版Rust(Stable Rust)中用自己的类型去实现,不过有朝一日应该可以!

3. 要不要panic!

  • 如果代码panic,就没有恢复的可能。你可以选择对任何错误场景都调用panic!,不管是否有可能恢复,不过这样就是你代替调用者决定了这是不可恢复的。
  • 选择返回Result值的话,就将选择权交给了调用者,而不是代替他们做出决定。调用者可能会选择符合他们场景的方式尝试恢复,或者也可能干脆就认为Err是不可恢复的,所以他们也可能会调用panic!并将可恢复的错误变成了不可恢复的错误。因此返回Result是定义可能会失败的函数的一个好的默认选择。

3.1. 示例、代码原型和测试都非常适合panic

当编写一个示例来解释一些概念时,拥有健壮的错误处理代码可能会使得例子不那么明确。例如,调用一个类似unwrap这样可能panic!的方法,其实是作为以后的错误处理程序的占位符,在真正实现错误处理的时候可能根据代码的具体功能来设计不同的处理方式。

类似地,在我们准备好决定如何处理错误之前,unwrapexpect方法非常方便。当我们准备好让程序更加健壮时,它们会在代码中留下清晰的标记。

如果方法调用在测试中失败了,我们希望这个测试都失败,即便这个方法并不是需要测试的功能。因为panic!会将测试标记为失败,此时调用unwrapexpect是恰当的。

3.2. 当我们比编译器知道更多的情况

当你有一些其他的逻辑来确保Result会是Ok时,仍然可以调用unwrap。即便在你的特定情况下,逻辑上不可能出现Err,但总体上讲,执行操作都有失败的可能,所以仍然要去处理Result值。

如果通过人工检查代码来确保永远也不会出现Err值,调用unwrap仍是完全可以接受的,例如:

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

let home: IpAddr = "127.0.0.1".parse().unwrap();

我们通过解析一个硬编码的字符来创建一个IpAddr实例。可以看出127.0.0.1是一个有效的IP地址,所以这里使用unwrap是可以接受的。但拥有一个硬编码的有效的字符串也不能改变parse方法的返回值类型:它仍然是一个Result值。编译器仍然会要求我们处理这个Result,好像还有可能出现Err那样。这是因为编译器还没有智能到可以识别出这个字符串总是一个有效的IP地址。如果IP地址字符串来源于用户而不是硬编码进程序中的话,那么就确实有失败的可能,这时就绝对需要我们以一种更健壮的方式处理Result了。

3.3. 错误处理指导原则

在有可能会导致有害状态的情况下,建议使用panic!

  • 有害状态是指一些假设、保证、协议或不可变性被打破的状态,例如无效的值、自相矛盾的值或者被传递了不存在的值,外加如下几种情况:
    • 有害状态是非预期的行为,而不是偶尔会发生的行为(例如用户输入了错误格式的数据)
    • 在此之后代码的运行需要依赖于非有害状态,而不是在每一步都检查是否有问题
    • 没有可行的手段来将有害状态信息编码进所使用的类型中

如果别人调用你的代码并传递了一个没有意义的值,最好的情况也许就是panic!,并警告使用你的库的人他的代码中有bug,以便他能在开发时就修复它。类似的,如果你正在调用不受你控制的外部代码,并且它返回了一个你无法修复的无效状态,那么panic!往往是合适的。

但当错误预期会出现时,返回Result仍要比调用panic!更为合适。例如解析器接收到格式错误的数据,或者HTTP请求返回了一个表明触发了限流的状态。在这些例子中,应该通过返回Result来表明失败预期是可能的,这样将有害状态向上传播,调用者就可以决定该如何处理这个问题。使用panic!来处理这些情况就不是最好的选择。

当代码对值进行操作时,应该首先验证值是有效的,并在其无效时panic!。这主要是出于安全的原因:尝试操作无效数据会暴露代码漏洞,这就是标准库在尝试越界访问数组时会panic!的主要原因:尝试访问不属于当前数据结构的内存是一个常见的安全隐患。

函数通常都遵循契约(contracts),他们的行为只有在输入满足特定条件时才能得到保证。当违反契约时panic是有道理的,因为这通常代表调用方的bug,而且这也不是那种你希望所调用的代码必须处理的错误。事实上所调用的代码也没有合理的方式来恢复,而是需要调用方的程序员修复其代码。函数的契约,尤其是违反它会造成panic的契约,应该在函数的API文档中得到解释。

虽然在所有函数中都拥有许多错误检查是冗长而烦人的,但可以利用Rust的类型系统(以及编译器的类型检查)为你进行很多检查。如果函数有一个特定类型的参数,可以在知晓编译器已经确保它拥有一个有效值的前提下进行你的代码逻辑。例如,如果你使用了一个非Option的类型,那么程序期望它是有值的,并且不是空值。你的代码无需处理SomeNone这两种情况,它只会有一种情况,就是绝对会有一个值。尝试向函数传递空值的代码甚至根本不能编译,所以你的函数在运行时没有必要判空。另外一个例子是使用像 u32这样的无符号整型,也会确保它永远不为负。

3.4. 创建自定义类型进行有效性验证

让我们使用Rust类型系统的思想来进一步确保值的有效性,并尝试创建一个自定义类型以进行验证。

在之前的Guessing Game中,代码要求用户猜测一个1到100之间的数字,在将其与秘密数字做比较之前,我们从未验证用户的猜测是否介于这两个数字之间,我们只验证它是否为正。在这种情况下,这种处理的影响并不是很严重,“Too high”或“Too low”的输出仍然是正确的。但是,引导用户进行有效的猜测、对不同的情况作出不同的处理,会更有用。比如在用户猜测超出范围的数字时,与用户键入非法值(比如字母)时,显示出不同的提示信息。

一种实现方式是将猜测解析成i32而不是u32,来默许输入负数,接着检查数字是否在范围内:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
loop {
// --snip--

let guess: i32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};

if guess < 1 || guess > 100 {
println!("The secret number will be between 1 and 100.");
continue;
}

match guess.cmp(&secret_number) {
// --snip--
}

if表达式检查了值是否超出范围,告诉用户出了什么问题,并调用continue开始下一次循环,请求另一个猜测。if表达式之后,就可以在知道guess在1到100之间的情况下与秘密数字作比较了。

但这并不是一个理想的解决方案:如果只允许1到100之间的值是一个绝对需要满足的要求,而且程序中的很多函数都有这样的要求,在每个函数中都有这样的检查将是非常冗余的(并可能潜在的影响性能)。

此时,我们可以创建一个新类型,然后将验证放入创建实例的函数中,而不是到处重复这些检查。这样就可以安全地在函数签名中使用新类型并相信他们接收到的值。下面例子展示了一个定义Guess类型的方法,只有在new函数接收到1到100之间的值时才会创建Guess的实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pub struct Guess {
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 }
}

pub fn value(&self) -> i32 {
self.value
}
}
  • 首先,我们定义了一个包含i32类型字段value的结构体Guess。这里是储存猜测值的地方。

  • 接着在Guess上实现了一个叫做new的关联函数来创建Guess的实例。

    • new接收一个i32类型的参数value并返回一个Guessnew函数中代码的测试确保了其值是在1到100之间。
      • 如果 value没有通过测试则调用panic!,这会警告调用这个函数的程序员有一个需要修改的bug,因为创建一个value超出范围的 Guess将会违反Guess::new所遵循的契约。Guess::new会出现panic的条件应该在其公有API文档中被提及。
      • 如果value通过了测试,我们新建一个Guess,其字段value将被设置为参数value的值,接着返回这个Guess
  • 接着,我们实现了一个借用了self的方法value,它没有任何其他参数,返回一个i32

    • 这类方法有时被称为getter,因为它的目的就是返回对应字段的数据。
    • 这样的公有方法是必要的,因为Guess结构体的 value字段是私有的。私有的字段value是很重要的,这样使用Guess结构体的代码将不允许直接设置value的值:调用者 必须使用Guess::new方法来创建一个Guess的实例,这就确保了不会存在一个value没有通过Guess::new函数的条件检查的Guess

于是,一个接收(或返回)1到100之间数字的函数就可以声明为接收(或返回)Guess的实例,而不是i32,同时其函数体中也无需进行任何额外的检查。

参考链接

[1] 错误处理:https://kaisery.github.io/trpl-zh-cn/ch09-00-error-handling.html