Rust 将错误组合成两个主要类别:可恢复错误(recoverable)和 不可恢复错误(unrecoverable)。可恢复错误通常代表向用户报告错误和重试操作是合理的情况,比如未找到文件。不可恢复错误通常是 bug 的同义词,比如尝试访问超过数组结尾的位置。
大部分语言并不区分这两类错误,并采用类似异常这样方式统一处理他们。Rust 并没有异常,但是,有可恢复错误 Result<T, E>
,和不可恢复(遇到错误时停止程序执行)错误 panic!
。
9.1 panic! 与不可恢复的错误
突然有一天,代码出问题了,而你对此束手无策。对于这种情况,Rust 有 panic!
宏。当执行这个宏时,程序会打印出一个错误信息,展开并清理栈数据,然后接着退出。出现这种情况的场景通常是检测到一些类型的 bug,而且程序员并不清楚该如何处理它。
对应panic时的栈展开或终止
当出现 panic 时,程序默认会开始 展开(unwinding),这意味着 Rust 会回溯栈并清理它遇到的每一个函数的数据,不过这个回溯并清理的过程有很多工作。另一种选择是直接 终止(abort),这会不清理数据就退出程序。那么程序所使用的内存需要由操作系统来清理。如果你需要项目的最终二进制文件越小越好,panic 时通过在 Cargo.toml 的 [profile]
部分增加 panic = 'abort'
,可以由展开切换为终止。例如,如果你想要在release模式中 panic 时直接终止:
[profile.release]
panic = 'abort'
在一个简单的程序中调用 panic!
:
fn main() {
panic!("crash and burn");
}
运行
在这个例子中,被指明的那一行是我们代码的一部分,而且查看这一行的话就会发现 panic!
宏的调用。在其他情况下,panic!
可能会出现在我们的代码所调用的代码中。错误信息报告的文件名和行号可能指向别人代码中的 panic!
宏调用,而不是我们代码中最终导致 panic!
的那一行。
使用panic!的backtrace
看看另一个因为我们代码中的 bug 引起的别的库中 panic!
的例子,而不是直接的宏调用。
fn main() {
// panic!("crash and burn");
let v = vec![1, 2, 3];
v[99];
}
这里尝试访问 vector 的第一百个元素(这里的索引是 99 因为索引从 0 开始),不过它只有三个元素。这种情况下 Rust 会 panic。[]
应当返回一个元素,不过如果传递了一个无效索引,就没有可供 Rust 返回的正确的元素。
结果
这指向了一个不是我们编写的文件,libcore/slice/mod.rs。其为 Rust 源码中
slice
的实现。这是当对 vectorv
使用[]
时 libcore/slice/mod.rs 中会执行的代码,也是真正出现panic!
的地方。现在不是这样的了。
9.2 Result 与可恢复的错误
大部分错误并没有严重到需要程序完全停止执行。有时,一个函数会因为一个容易理解并做出反应的原因失败。例如,如果因为打开一个并不存在的文件而失败,此时我们可能想要创建这个文件,而不是终止进程。
回忆枚举Result
enum Result<T, E> {
Ok(T),
Err(E),
}
T
和 E
是泛型类型参数;第十章会详细介绍泛型。现在你需要知道的就是 T
代表成功时返回的 Ok
成员中的数据的类型,而 E
代表失败时返回的 Err
成员中的错误的类型。因为 Result
有这些泛型类型参数,我们可以将 Result
类型和标准库中为其定义的函数用于很多不同的场景,这些情况中需要返回的成功值和失败值可能会各不相同。
调用一个返回 Result
的函数,因为它可能会失败:
use std::fs::File;
fn main() {
let f = File::open("hello.txt");
}
如何知道 File::open
返回一个 Result
呢?
VS code中配置完Rust环境后,可以看到返回值
可以查看 标准库 API 文档,或者可以直接问编译器!如果给 f
某个我们知道 不是 函数返回值类型的类型注解,接着尝试编译代码,编译器会告诉我们类型不匹配。然后错误信息会告诉我们 f
的类型 应该 是什么。让我们试试!我们知道 File::open
的返回值不是 u32
类型的,所以将 let f
语句改为如下:
use std::fs::File;
fn main() {
let f : u32 = File::open("hello.txt");
}
结果
这就告诉我们了 File::open
函数的返回值类型是 Result<T, E>
。
机智
这个返回值类型说明 File::open
调用可能会成功并返回一个可以进行读写的文件句柄。这个函数也可能会失败:例如,文件可能并不存在,或者可能没有访问文件的权限。File::open
需要一个方式告诉我们是成功还是失败,并同时提供给我们文件句柄或错误信息。而这些信息正是 Result
枚举可以提供的。
当 File::open
成功的情况下,变量 f
的值将会是一个包含文件句柄的 Ok
实例。在失败的情况下,f
的值会是一个包含更多关于出现了何种错误信息的 Err
实例。
match一下
use std::fs::File;
fn main() {
let f = File::open("hello.txt");
let f = match f {
Ok(file) => file,
Err(error) => {
panic!("problem opening ths file: {:?}", error);
}
};
}
注意与 Option
枚举一样,Result
枚举和其成员也被导入到了 prelude 中,所以就不需要在 match
分支中的 Ok
和 Err
之前指定 Result::
。
运行
匹配不同的错误
我们真正希望的是对不同的错误原因采取不同的行为:如果 File::open
因为文件不存在而失败,我们希望创建这个文件并返回新文件的句柄。如果 File::open
因为任何其他原因失败,例如没有打开文件的权限,我们仍然希望像示例那样 panic!
。
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
,它代表尝试打开的文件并不存在。所以 match
的 f
匹配,不过对于 error.kind()
还有一个内部 match
。
我们希望在匹配守卫中检查的条件是 error.kind()
的返回值是 ErrorKind
的 NotFound
成员。如果是,则尝试通过 File::create
创建文件。然而因为 File::create
也可能会失败,还需要增加一个内部 match
语句。当文件不能被打开,会打印出一个不同的错误信息。外部 match
的最后一个分支保持不变,这样对任何除了文件不存在的错误会使程序 panic。
失败时panic 的简写:unwrap和expect
match
能够胜任它的工作,不过它可能有点冗长并且不总是能很好的表明其意图。Result<T, E>
类型定义了很多辅助方法来处理各种情况。其中之一叫做 unwrap。
如果 Result
值是成员 Ok
,unwrap
会返回 Ok
中的值。如果 Result
是成员 Err
,unwrap
会为我们调用 panic!
。
use std::fs::File;
fn main() {
let f = File::open("hello.txt").unwrap();
}
如果调用这段代码时不存在 hello.txt 文件,我们将会看到一个 unwrap
调用 panic!
时提供的错误信息:
还有另一个类似于 unwrap
的方法它还允许我们选择 panic!
的错误信息:expect
。使用 expect
而不是 unwrap
并提供一个好的错误信息可以表明你的意图并更易于追踪 panic 的根源。expect
的语法看起来像这样:
use std::fs::File;
fn main() {
let f = File::open("hello.txt").expect("Failed to open hello.txt");
}
expect
与 unwrap
的使用方式一样:返回文件句柄或调用 panic!
宏。expect
用来调用 panic!
的错误信息将会作为参数传递给 expect
,而不像unwrap
那样使用默认的 panic!
信息。
因为这个错误信息以我们指定的文本开始,Failed to open hello.txt
,将会更容易找到代码中的错误信息来自何处。如果在多处使用 unwrap
,则需要花更多的时间来分析到底是哪一个 unwrap
造成了 panic,因为所有的 unwrap
调用都打印相同的信息。
传播错误
当编写一个可能会失败的操作的函数时,除了在这个函数中处理错误外,还可以选择让调用者知道这个错误并决定该如何处理。这被称为 传播(propagating)错误,这样能更好的控制代码调用,因为比起你代码所拥有的上下文,调用者可能拥有更多信息或逻辑来决定应该如何处理错误。
如果文件不存在或不能读取,这个函数会将这些错误返回给调用它的代码:
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),
}
}
首先让我们看看函数的返回值: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
,类似于之前示例中的 match
,唯一的区别是当 Err
时不再调用 panic!
,而是提早返回并将 File::open
返回的错误值作为函数的错误返回值传递给调用者。如果 File::open
成功了,我们将文件句柄储存在变量 f
中并继续。
接着我们在变量 s
中创建了一个新 String
并调用文件句柄 f
的 read_to_string
方法来将文件的内容读取到 s
中。read_to_string
方法也返回一个 Result
因为它也可能会失败:哪怕是 File::open
已经成功了。所以我们需要另一个 match
来处理这个 Result
:如果 read_to_string
成功了,那么这个函数就成功了,并返回文件中的用户名,它现在位于被封装进 Ok
的 s
中。如果read_to_string
失败了,则像之前处理 File::open
的返回值的 match
那样返回错误值。不过并不需要显式的调用 return
,因为这是函数的最后一个表达式。
调用这个函数的代码最终会得到一个包含用户名的 Ok
值,或者一个包含 io::Error
的 Err
值。
传播错误的简写:?运算符
下面示例展示了一个 read_username_from_file
的实现,它实现了与之前示例中的代码相同的功能,不过这个实现使用了 ?
运算符:
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)
}
Result
值之后的 ?
被定义为与之前示例 中定义的处理 Result
值的 match
表达式有着完全相同的工作方式。如果 Result
的值是 Ok
,这个表达式将会返回 Ok
中的值而程序将继续执行。如果值是 Err
,Err
中的值将作为整个函数的返回值,就好像使用了 return
关键字一样,这样错误值就被传播给了调用者。
?
运算符所使用的错误值被传递给了 from
函数,它定义于标准库的 From
trait 中,其用来将错误从一种类型转换为另一种类型。当 ?
运算符调用 from
函数时,收到的错误类型被转换为定义为当前函数返回的错误类型。这在当一个函数返回一个错误类型来代表所有可能失败的方式时很有用,即使其可能会因很多种原因失败。只要每一个错误类型都实现了 from
函数来定义如将其转换为返回的错误类型,?
运算符会自动处理这些转换。
进一步缩短代码
use std::io;
use std::io::Read;
use std::fs::File;
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)
}
在 s
中创建新的 String
被放到了函数开头;这一部分没有变化。我们对 File::open("hello.txt")?
的结果直接链式调用了 read_to_string
,而不再创建变量 f
。仍然需要 read_to_string
调用结尾的 ?
,而且当 File::open
和 read_to_string
都成功没有失败时返回包含用户名 s
的 Ok
值。
use std::io;
use std::io::Read;
use std::fs::File;
fn read_username_from_file() -> Result<String, io::Error> {
fs::read_to_string("hello.txt")
}
将文件读取到一个字符串是相当常见的操作,所以 Rust 提供了名为 fs::read_to_string
的函数,它会打开文件、新建一个 String
、读取文件的内容,并将内容放入 String
,接着返回它。
太优雅了。
?运算符可被用于返回Result的函数
?
运算符可被用于返回值类型为 Result
的函数,atch
的 return Err(e)
部分要求返回值类型是 Result
,所以函数的返回值必须是 Result
才能与这个 return
相兼容。
看看在 main
函数中使用 ?
运算符会发生什么,如果你还记得的话其返回值类型是()
:
use std::fs::File;
fn main() {
let f = File::open("hello.txt")?;
}
语法糖太多了
9.3 panic!还是不panic!
那么,该如何决定何时应该 panic!
以及何时应该返回 Result
呢?如果代码 panic,就没有恢复的可能。你可以选择对任何错误场景都调用 panic!
,不管是否有可能恢复,不过这样就是你代替调用者决定了这是不可恢复的。选择返回 Result
值的话,就将选择权交给了调用者,而不是代替他们做出决定。调用者可能会选择以符合他们场景的方式尝试恢复,或者也可能干脆就认为 Err
是不可恢复的,所以他们也可能会调用 panic!
并将可恢复的错误变成了不可恢复的错误。因此返回 Result
是定义可能会失败的函数的一个好的默认选择。
有一些情况 panic 比返回 Result
更为合适,不过他们并不常见。
示例、代码原型和测试都非常适合panic
当你编写一个示例来展示一些概念时,在拥有健壮的错误处理代码的同时也会使得例子不那么明确。
类似地,在我们准备好决定如何处理错误之前,unwrap
和expect
方法在原型设计时非常方便。当我们准备好让程序更加健壮时,它们会在代码中留下清晰的标记。
当我们比编译器知道更多的情况
当你有一些其他的逻辑来确保 Result
会是 Ok
值时,调用 unwrap
也是合适的。
错误处理指导原则
在当有可能会导致有害状态的情况下建议使用 panic!
—— 在这里,有害状态是指当一些假设、保证、协议或不可变性被打破的状态,例如无效的值、自相矛盾的值或者被传递了不存在的值 —— 外加如下几种情况:
- 有害状态并不包含 预期 会偶尔发生的错误
- 在此之后代码的运行依赖于不处于这种有害状态
- 当没有可行的手段来将有害状态信息编码进所使用的类型中的情况
创建自定义类型进行有效性验证
总结
Rust 的错误处理功能被设计为帮助你编写更加健壮的代码。panic!
宏代表一个程序无法处理的状态,并停止执行而不是使用无效或不正确的值继续处理。Rust 类型系统的 Result
枚举代表操作可能会在一种可以恢复的情况下失败。可以使用 Result
来告诉代码调用者他需要处理潜在的成功或失败。在适当的场景使用 panic!
和 Result
将会使你的代码在面对不可避免的错误时显得更加可靠。
参考:错误处理 - Rust 程序设计语言 简体中文版 (bootcss.com)