54 KiB
错误处理
在软件中,错误是家常便饭,因此 Rust 有数个用于处理某些东西出了岔子情形的特性。在许多情况下,Rust 都要求编程者知晓某种错误的可能性,进而在代码编译之前就采取一些措施。这样的要求通过确保编程者在将其代码部署到生产环境之前,会发现错误并对其进行恰当处理,而使得他们的程序更为健壮!
Rust 将错误分组为两个主要类别: 可恢复(recoverable) 与 不可恢复(unrecoverable) 错误。对于可恢复错误,比如 文件未找到 错误,大多数情况下只要将该故障汇报给用户,并重试该操作。而不可恢复错误则总是代码错误的表征,像是尝试访问超出数组末端的某个位置,进而因此就要立即停止该程序。
大多数语言都没有区分这两种错误,而以同样方式,使用诸如异常的机制处理这两种错误。Rust 没有异常。相反,Rust 有着用于可恢复错误的类型 Result<T, E>
,以及在程序发生了不可恢复错误时,停止程序执行的 panic!
宏(the panic!
macro)。本章将首先涵盖对 panic!
的调用,并在随后讲解那些返回的 Result<T, E>
值。此外,这里会对在决定是否要尝试从错误中恢复,还是要停止程序的执行时的诸多考虑,进行探讨。
带 panic!
的不可恢复错误
Unrecoverable Errors with panic!
某些时候,在代码中不好的事情发生了,而对其无计可施。在这些情形下,Rust 有着 panic!
宏。在 panic!
宏执行时,程序就会打印一条失败消息,释放(unwind)并清理掉栈,并在随后退出。在侦测到某种类别的代码错误,且在编写程序时刻,尚不清楚怎样处理这个故障时,就会触发一个程序中止(invoke a panic)。
对程序终止进行响应的栈解除或栈终止(Unwinding the Stack or Aborting in Response to a Panic)
默认情况下,在程序终止发生时,程序就开始 解除栈(unwinding),这是指 Rust 对栈进行回退,并清理他所遇到的各个函数的数据。然而,这样的回退与清理,是很多的工作量。那么因此 Rust 就允许编程者选择立即 终止(aborting) 的替代方案,该替代方案会不加清理的结束程序。程序曾用过的内存,这时就需要由操作系统来清理。如过在项目中,需要将生成的二进制执行文件构造得尽可能小,你们就可以通过把
panic= 'abort'
,添加到Cargo.toml
文件中恰当的[profile]
小节,而从程序中止的栈解除切换为立即终止。比如,若想要在发布模式中,于程序中止时立即终止,那么就要添加这个:
[profile.release]
panic = 'abort'
下面就来在一个简单程序中,尝试调用 panic!
宏:
文件名:src/main.rs
fn main() {
panic! ("崩溃并燃烧");
}
在运行该程序时,就会看到下面这样的东西:
$ cargo run
Compiling error_handling_demo v0.1.0 (/home/lenny/rust-lang/error_handling_demo)
Finished dev [unoptimized + debuginfo] target(s) in 0.40s
Running `target/debug/error_handling_demo`
thread 'main' panicked at '崩溃并燃烧', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
这个到 panic!
的调用,引起了包含在最后两行中的错误消息。第一行给出了程序中止消息及源码里程序中止发生的位置:src/main.rs:2:5
表示是在这里的 src/main.rs
文件的第二行、第五个字符处。
在此情况下,所指出的那行,就是这里代码的一部分,而在前往到那行时,就会看到那个 panic!
宏调用。在别的情况下,panic!
调用可能会在所编写代码调用的代码中,那么由该错误消息报告出的文件名与行号,就会是 panic!
被调用所在之处的其他人的代码,而不会是最终引起那个 panic!
调用的自己编写的代码行。这里可以使用该 panic!
调用来自那些函数的回溯,来弄清楚此处代码的哪个部分导致了该问题。接下来就要详细讨论这种回溯。
运用 panic!
回溯
现在来看一下另一个示例,看看由于代码中的编码错误,而非由于在代码中直接调用 panic!
宏时,来自库的 panic!
调用到底会是什么样子。下面清单 9-1 有一些尝试访问某个矢量中超出了有效索引范围索引的代码。
文件名:src/main.rs
fn main() {
let v = vec! [1, 2, 3];
v[99];
}
清单 9-1:尝试访问某个超出了矢量末端的元素,这会导致一个到 panic!
的调用
这里正尝试访问这里矢量的第 100 个元素(由于索引开始于零处,故那是在索引 99 处),但这个矢量只有 3 个元素。在此情况下,Rust 就会中止。使用 []
被认为是要返回一个元素的,但在传递某个无效索引时,这里就没有 Rust 可返回的正确元素。
在 C 语言中,尝试读取超出某种数据结构,属于未定义的行为。那么就可以会得到与该数据结构中元素对应的、内存中那个位置处的任何东西,即便该内存不属于那个数据结构。这就叫做 缓冲区重读取(a buffer overread),并能在攻击者可以这样的方式操作索引,来读取存储在该数据结构之后的、本不应允许他们读取的数据时,导致安全漏洞。
Rust 为保护程序免受这类漏洞的危害,就会在尝试位于某个不存在索引处的元素时,停止程序的执行而拒绝继续下去。来尝试运行一下上面的代码看看:
$ cargo run lenny@vm-manjaro
Compiling error_handling_demo v0.1.0 (/home/lenny/rust-lang/error_handling_demo)
Finished dev [unoptimized + debuginfo] target(s) in 0.47s
Running `target/debug/error_handling_demo`
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
处。接下来的注解行,讲到这里可将 RUST_BACKTRACE
环境变量,设置为获取究竟发生什么,才导致了这个错误。所谓 回溯(a backtrace),即为到此已调用的全部函数的清单。Rust 中的回溯,与其他语言中的回溯完成的事情一样:阅读回溯的冠军,就是要从顶部开始,一直要读到自己编写的文件为止。那便是该问题缘起之处。在那个点位之上的那些行,就是所编写代码曾调用过的代码;而所编写代码之下的那些行,则是调用所编写代码的代码。这些前前后后的行,就可能包含核心 Rust 代码、 标准库代码,或者正使用着的代码箱。下面就来通过将 RUST_BACKTRACE
环境变量,设置为除 0
之外的任何值,尝试获取到回溯。下面清单 9-2 展示了与将会看到的类似输出。
$ RUST_BACKTRACE=1 cargo run lenny@vm-manjaro
Finished dev [unoptimized + debuginfo] target(s) in 0.02s
Running `target/debug/error_handling_demo`
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/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/std/src/panicking.rs:584:5
1: core::panicking::panic_fmt
at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/panicking.rs:142:14
2: core::panicking::panic_bounds_check
at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/panicking.rs:84:5
3: <usize as core::slice::index::SliceIndex<[T]>>::index
at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/slice/index.rs:242:10
4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/slice/index.rs:18:9
5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/alloc/src/vec/mod.rs:2591:9
6: error_handling_demo::main
at ./src/main.rs:4:5
7: core::ops::function::FnOnce::call_once
at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/ops/function.rs:248:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
清单 9-2:在设置了 RUST_BACKTRACE
环境变量时,由到 panic!
调用生成的回溯被显示了出来
那可是很多的输出了!具体看到的输出,可能根据操作系统与 Rust 版本而有所不同。为从此信息中获得回溯,就要开启那些调试符号。在不带 --release
标志使用 cargo build
或 cargo run
时,如同这里这样,这些调试符号默认就是开启的。
在上面清单 9-2 里的输出中,回溯所指向到这里项目中行的第 6 行,就是导致问题的行:即 src/main.rs
的第 4 行。在不想要这个程序中止时,就应在首个提到了自己所编写文件的行,所指向的那个位置,开始排查。在之前的清单 9-1 中,那里有意编写了会中止的代码,而修正程序中止的办法,就是不要请求某个超出那个矢量索引范围的元素。而在今后代码中止时,就需要搞清楚代码是在对什么值进行何种操作,而导致了中止,以及代码应该怎么做。
在本章的 要 panic!
或不要 panic!
小节,将回到 panic!
这个话题,并讨论在何时要用 panic!
,何时不应使用 panic!
来处理不同错误情形。接下来,就会看看怎样使用 Result
,从错误中恢复过来。
带有 Result
的可恢复错误
多数错误都没有严重到要求程序整个地停止运行。某些时候,在某个函数失败时,必定是由于某种可易于解释进而加以响应的原因。比如在尝试打开某个文件,而因为要打开的文件不存在,那个操作失败了时,那么可能希望创建该文件,而不是中止这个进程。
回顾第二章中的 处理潜在带有 Result
类型的程序失败 小节,其中的 Result
枚举被定义为有两个变种,Ok
与 Err
,如下所示:
enum Result<T, E> {
Ok<T>,
Err<E>,
}
这里的 T
与 E
,都属于泛型参数(generic type parameters):在第 10 章就会更深入讨论泛型。此刻需要明白的是,这里的 T
表示在操作成功情形下,那个 Ok
变种里返回值的类型,而这里的 E
,则表示在失效情形下,将返回的在 Err
变种里错误的类型。由于 Result
有着这些泛型参数,因此就可以在打算返回成功值与错误值有所区别的许多不同情形下,使用到这个 Result
及定义在其上的函数。
下面就来调用一个由于其会失败,而返回 Result
值的函数。在下面清单 9-3 中,是尝试打开一个文件。
文件名:src/main.rs
use std::fs::File;
fn main() {
let f = File::open("hello.txt");
}
清单 9-3:打开某个文件
怎样知道 File::open
会返回一个 Result
呢?这里就可以看看 标准库 API 文档,或者可以询问一下编译器!在赋予 f
一个明知 不是 该函数返回值类型的类型注解,并随后尝试编译该代码时,编译器就会告知,这两个类型不匹配。给出的错误消息,就会告诉 f
的类型是什么。来试试吧!这里已知 File::open
的返回类型不是 u32
,因此就把那个 let f
语句修改为下面这样:
let f: u32 = File::open("hello.txt");
现在尝试编译,就会给到接下来的输出:
$ cargo run lennyp@vm-manjaro
Compiling error_handling_demo v0.1.0 (/home/lennyp/rust-lang/error_handling_demo)
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_demo` due to previous error
这就是说,File::open
函数的返回类型,是个 Result<T, E>
。泛型参数 T
,在这里已被使用成功值的类型,std::fs::File
,即一个文件句柄(a file handle)填充。而用于错误值的类型 E
,则为 std::io::Error
。
这样的返回值类型,表示到 File::open
的调用,可能会成功而返回一个能够自该处读取,或写入到该处的文件句柄。该函数调用同样可能失败:比如该文件可能不存在,或可能没有访问该文件的权限。那么这个 File::open
函数,就需要具备已知告知其是否成功或失败的方式,与此同时给到一个文件句柄,或者错误信息。这样的信息,正是这个 Result
枚举所要表达的。
此示例中,在 File::open
成功处,变量 f
中的值就会是包含了一个文件句柄的 一个 Ok
实例。而在其失败的情况下,f
中的那个值,就会是包含了有关所发生错误类别的更多信息的一个 Err
实例。
这里就需要对清单 9-3 中代码进行添加,从而根据 File::open
所返回值,而采取不同措施。下面清单 9-4 就给出了一种使用基本工具,即在第 6 章中曾讨论过的 match
表达式,对那个 Result
进行处理的方法。
文件名:src/main.rs
use std::fs::File;
fn main() {
let f = File::open("hello.txt");
let f = match f {
Ok(file) => file,
Err(e) => panic! ("打开文件出现问题:{:?}", e),
};
}
清单 9-4:运用 match
表达式来处理可能返回的各个 Result
变种
请注意,与 Option
枚举类似,这个 Result
枚举及其变种,是已由 Rust 前奏(the prelude)带入到作用域中了的,因此这里无需在那两个 match
支臂中的 Ok
与 Err
变种之前,指明 Result::
。
在返回结果为 Ok
时,此代码就会返回从 Ok
变种抽出的那个内部的 file
值,且这里随后就把那个文件句柄值,指派给那个变量 f
。在这个 match
之后,就可以将这个文件句柄,用于读取或写入了。
而那个 match
的另一支臂,则处理了从 File::open
得到一个 Err
值的情形。在此示例中,选择了调用 panic!
宏。在当前目录中没有名为 hello.txt
的文件,并运行此代码时,就会看到来自那个 panic!
宏的如下输出:
$ cargo run lennyp@vm-manjaro
Finished dev [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/error_handling_demo`
thread 'main' panicked at '打开文件出现问题:Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:8:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
与往常一样,此输出告知了到底什么出错了。
匹配各异的错误
上面清单 9-4 中的代码,不论 File::open
因何而失败,都会 panic!
。然而,这里是要因应不同失败原因,而采取不同措施:在 File::open
因为那个文件不存在而失败时,就要创建该文件并返回到那个新建文件的句柄。在那个 File::open
因别的其他原因失败 -- 比如没有打开该文件的权限时,这里仍要该代码以清单 9-4 中所做的同样方式,panic!
掉。为此,这里就要添加一个内部的 match
表达式,如下清单 9-5 中所示。
文件名:src/main.rs
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let f = File::open("hello.txt");
let f = match f {
Ok(file) => file,
Err(e) => match e.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(error) => panic! ("创建该文件时出现问题:{:?}", error),
},
other_error => panic! ("打开文件出现问题:{:?}", other_error),
},
};
}
清单 9-5:以不同方式处理不同类别的错误
File::open
所返回的位于 Err
变种内部的值的类型为 io::Error
,他是一个由标准库提供的结构体。该结构体有个可供调用以获取到 io::ErrorKind
值的方法 kind
。而枚举 io::ErrorKind
亦是由标准库提供,并有着表示那些可能自某个 io
操作而引起的,不同类别错误的一些变种。这里打算使用的变种为 ErrorKind::NotFound
,表示了正尝试打开的文件尚不存在。因此这里既对 f
进行了匹配,而同时还有了在 e.kind()
上的一个内层匹配。
这里打算检查的那个内层匹配中的条件,则是由 e.king()
所返回的那个值,是否为 ErrorKind
枚举的 NotFound
变种。在 e.kind()
返回的值为 ErrorKind
的 NotFound
变种时,这里就尝试以 File::create
来创建该文件。然而由于 Fiel::create
仍会失败,因此这里就需要在那个内层 match
表达式中的第二个支臂。在该文件无法被创建出来时,就会打印出一条不同的错误消息。外层那个 match
表达式的第二支臂保持原样,因此该程序会在除了文件未找到错误之外的其他任何错误时,都会中止运行。
这种结合
Result<T, E>
运用match
表达式的替代方案那可是有好多的
match
!match
表达式是很有用,但同样也是很原始的。在第 13 章,就会了解到闭包(closures),这种与定义在Result<T, E>
上的众多方法一起使用的特性。在对代码中的Result<T, E>
值进行处理时,比起使用match
表达式,这样的闭包方式可以简练得多。 比如,下面就是编写与清单 9-5 中同样逻辑,不过却使用了闭包特性与unwrap_or_else
方法的另一种方式。
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let f = File::open("hello.txt").unwrap_or_else(|e| {
if e.kind() == ErrorKind::NotFound {
File::create("hello.txt").unwrap_or_else(|error| {
panic! ("创建文件时发生问题:{:?}", error);
})
} else {
panic! ("打开文件时出现问题:{:?}", e);
}
});
println! ("{:?}", f);
}
尽管此代码与清单 9-5 有着同样行为,但他并未包含任何的
match
表达式,且读起来更清楚。请在读完了第 13 章,并看看标准库文档中的这个unwrap_or_else
方法后,再回到这个示例。在对错误进行处理时,许多别的这些方法,都可以清理掉大量嵌套的match
表达式。
因错误而中止的快捷方式:unwrap
与 expect
Shortcuts for Panic on Error: unwrap
and expect
运用 match
运作足够良好,不过那样可能有点冗长,且不总是良好地传达了意图。这个 Result<T, E>
类型,其上本来就定义了许多用于完成各种各样的、更为具体任务的辅助方法。其中的 unwrap
方法,就是一个实现了刚好与前面清单 9-4 中所编写的 match
表达式类似的快捷方法。在 Result
的值为 Ok
变种时,unwrap
就会返回那个 Ok
内部的值。而在该 Result
为 Err
变种时,unwrap
则会代为调用 panic!
宏。下面就是运作中的一个 unwrap
示例:
文件名:src/main.rs
use std::fs::File;
fn main() {
let f = File::open("hello.txt").unwrap();
}
在没有 hello.txt
文件下运行此程序时,就会看到一条来自由这个 unwrap
方法做出的 panic!
宏调用的错误消息:
$ cargo run lennyp@vm-manjaro
Finished dev [unoptimized + debuginfo] target(s) in 0.03s
Running `target/debug/error_handling_demo`
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:4:37
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
同样,Result<T, E>
上的 expect
方法,则可实现对这条 panic!
错误消息的选取。使用 expect
而非 unwrap
并提供良好的错误消息,就能够传达到自己的意图,进而令到追踪程序中止缘由更为容易。expect
方法的语法如下所示:
文件名:src/main.rs
use std::fs::File;
fn main() {
let f = File::open("hello.txt").expect("打开 hello.txt 失败");
}
这里以与 unwrap
同样方式,使用了 expect
:用于返回文件句柄,或者对 panic!
宏进行调用。而在 expect
调用 panic!
时用到的错误消息,就将是这里传递给 expect
的那个参数,而不再是 unwrap
所用到的那个默认 panic!
消息了。下面就是该错误消息看起来的样子:
$ cargo run lennyp@vm-manjaro
Compiling error_handling_demo v0.1.0 (/home/lennyp/rust-lang/error_handling_demo)
Finished dev [unoptimized + debuginfo] target(s) in 1.23s
Running `target/debug/error_handling_demo`
thread 'main' panicked at '打开 hello.txt 失败: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:4:37
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
由于此错误消息是以这里所指定的,打开 hello.txt 失败
开始,因此就会更易于搞清楚,此错误消息来自代码中的何处。而若在多处使用 unwrap
,那么在要精准找出到底是那个 unwrap
导致了程序中止时,就会因为所有这些调用了 panic!
的 unwrap
,都打印出同样消息,而要耗费更多时间。
传递错误(Propagating Errors)
在某函数实现调用了可能失败的某些东西时,与其在该函数自身里头对错误进行处理,还可以将该错误返回给调用该函数的代码,这样调用该函数的代码就可以自己决定要做些什么。这就叫做 传递(propagating) 错误,而将更多的控制,给到调用该函数的代码,相比于当前实现的函数代码,调用代码中可能会有更多决定该错误应如何被处理的信息或逻辑。
比如,下面清单 9-6 就给出了一个从某个文件读取用户名的函数。在那个文件不存在或无法读取时,这个函数就会将这些错误返回给调用该函数的代码。
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let username_file_result = File::open("hello.txt");
let mut username_file = match username_file_result {
Ok(file) => file,
Err(e) => return Err(e),
};
let mut username = String::new();
match username_file.read_to_string(&mut username) {
Ok(_) => Ok(username),
Err(e) => Err(e),
}
}
清单 9-6:使用 match
将错误返回给调用代码的一个函数
虽然可以简单得多的方式,来重写该函数,不过为了对错误处理进行探索,因此这里就要通过亲自动手完成其大部分代码开头;在结束时,就会给出那更简短的方式。首先来看看该函数的返回值类型:Result<String, io::Error>
。这表示该函数要返回一个类型 Result<T, E>
的值,其中的泛型参数 T
已被具体类型 String
填充,而那个泛型 E
则已被具体类型 io::Error
填充。若此函数不带任何问题的成功运行,那么调用该函数的代码,就会收到一个保存着一个 String
的 Ok
值 -- 即该函数从那个文件中读取到的用户名。而在该函数出现任何问题时,那么调用代码就会收到一个,保存着包含了有关所出现问题更多信息的 io::Error
示例的 Err
值。这里之所以选择 io::Error
作为此函数的返回值,是因为在该函数的函数体中所调用的两个都可能失败的操作:File::open
与 read_to_string
,他们所返回的错误值都是这个 io::Error
类型。
该函数的函数体,是以调用 File::open
函数开始的。随后这里就以与清单 9-4 中类似方式,使用了一个 match
处理 File::open
返回的 Result
。在 File::open
成功时,那么在模式变量 file
中的文件句柄,就成为那个可变变量 f
中的值,且函数会继续执行。而在 Err
情形下,这里使用了 return
关键字,早早地就从这个函数 return
了出去,同时将来自 File::open
的那个错位值,此时是在模式变量 e
中,作为该函数的错误值,传回给调用该函数的代码。
因此在 username_file
有着一个文件句柄时,该函数随后就会创建一个在变量 username
中的新 String
,并调用 username_file
中文件句柄上的 read_to_string
方法,来将该文件中的内容,读取到 username
中。因为即使 File::open
运行成功,这个 read_to_string
仍可能失败,因此他同样会返回一个 Result
。那么这里就需要另一个 match
,来处理这个 Result
:在 read_to_string
成功时,那么接下来这个函数就成功执行了,进而就从这个文件,返回到此时位于封装在一个 Ok
中的 username
中的用户名来。而在 read_to_string
失败时,这里就会以与之前在那个处理 File::open
返回值的 match
中返回错误值的同样方式,返回现在这个 read_to_string
的错误值。不过,由于这是该函数中的最后一个表达式,因此这里无需显示地写下 return
。
调用此代码的代码,随后就会对收到的包含了用户名 Ok
值,或者包含了一个 io::Error
类型的 Err
值进行处理。至于要对这些值做何处理,则取决于调用代码了。在调用代码收到 Err
值时,他就可以采取好比调用 panic!
并崩溃掉该程序,可以使用某个默认用户名,或者从相比该文件的其他地方,查找该用户名等操作。这里没有关于那个调用代码确切地尝试要做什么的足够信息,因此这里就把全部的成功或错误信息,向上传递给调用代码,让调用代码进行适当处理。
由于在 Rust 中这样的传递错误模式是如此普遍,以致于 Rust 提供了问好操作符(the question mark operator, ?
),来令到错误传递更加容易。
传递错误的快捷方式:?
操作符
下面清单 9-7 给出了与清单 9-6 有着同样功能的一个 read_username_from_file
实现,只是此实现使用了 ?
操作符。
文件名:src/main.rs
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut username_file = File::open("hello.txt")?;
let mut username = String::new();
username_file.read_to_string(&mut username)?;
Ok(username)
}
清单 9-7:一个使用 ?
操作符将错误返回给调用代码的函数
那个放在某个 Result
值后面的 ?
,被定义为几乎与之前所定义的那些,用于处理清单 9-5 中那些 Result
值的 match
表达式,以同样方式运作。在 Result
的值为 Ok
时,那么那个 Ok
内部的值,就会从该表达式得以返回,且程序将继续运行。而在该 Result
值为一个 Err
时,则会如同之前曾用到的 return
关键字一样,将自这整个函数,返回这个 Err
值,进而这个错误值,就被传递给了调用代码。
清单 9-6 中的 match
表达式完成的事情,与这个 ?
操作符完成的事情有个不同点:调用了这个 ?
操作符的错误值,会经过定义在标准库中 From
特质(the From
trait in the standard library)中定义的 from
函数,而该函数被用于将一种类型的值,转换到另一种类型中。当 ?
操作符调用 from
函数时,被接收到的错误类型,就被转换为了定义在当前函数返回值类型中的类型了(即 Result<String, io::Error>
)。在某个函数可能失败,即便该函数的一些部分而不是整个函数,由于许多不同原因而失败,而返回一种表示这些全部失败方式的一种错误类型时,这个不同之处就会有用。
比如,这里本可将清单 9-7 中的 read_username_from_file
函数,修改为返回一个自己定义的名为 OurError
的定制错误类型。而在同时给 OurError
定义了 impl From<io::Error>
,以从 io::Error
构造出一个 OurError
的实例时,那么随后无需添加任何代码到这个函数,read_username_from_file
函数中的这些 ?
操作符,就会调用 from
并对那些错误类型进行转换。
在清单 9-7 的语境下,位于 File::open
调用末尾的那个 ?
,将返回一个 Ok
内部的值给变量 username_file
。而在有错误发生时,这个 ?
操作符,就会早早地从整个函数退出,并把任何的 Err
值给到调用代码。对于那个 read_to_string
调用末尾处的 ?
,适用这同样的情况。
?
操作符消除了很多样板代码(a lot of boilerplate),并令到此函数的实现更为简单。通过将这些方法调用在整个 ?
即刻链接起来,甚至可以进一步缩短此代码,如下清单 9-8 中所示。
文件名:src/main.rs
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut username = String::new();
File::open("hello.txt")?.read_to_string(&mut username)?;
Ok(username)
}
清单 9-8:在 ?
操作符后将方法调用链接起来
这里已将那个 username
中的新 String
的创建,挪到了该函数的开头;整个函数就整个部分未作改动。这里没有了变量 username_file
的创建,而是已将到 read_to_string
的函数调用,直接链接到了 File::open("hello.txt")?
的结果上。在 read_to_string
调用的末尾仍有一个 ?
,同时在这两个 File::open
与 read_to_string
调用都成功,而不返回错误时,这里就会返回一个包含了 username
的 Ok
值。功能仍旧与清单 9-6 和清单 9-7 中是一样的;这只是一种不同的、更为符合人体工程学的编写方式。
下面清单 9-9 给出了使用 fs::read_to_string
的一种甚至更加简短的方式。
文件名:src/main.rs
use std::fs;
use std::io;
fn read_username_from_file() -> Result<String, io::Error> {
fs::read_to_string("hello.txt")
}
清单 9-9:使用 fs::read_to_string
而非打开在读取那个文件
将某个文件读取到字符串中,是个相当常见的操作,因此标准库提供了便捷的打开文件、创建一个新 String
、读取文件内容、将内容放入到那个 String
,并将其返回的 fs::read_to_string
函数。当然,fs::read_to_string
的使用,并不能赋予到这里对全部错误处理加以解释的机会,因此这里才要走过前面那些常常的过程。
哪些地方可以使用 ?
操作符
?
操作符仅可用于那些返回值类型,与这个 ?
被用于的那个值类型兼容的函数中。这是由于 ?
操作符被定义为与在清单 9-6 中,所定义的那个 match
表达式类似方式,执行一个该函数早期阶段的退出。在清单 9-6 中,那个 match
使用的是一个 Result
值,同时那个先期返回支臂返回的是一个 Err(e)
值。那么那个函数的返回值类型,就必须是个 Result
,这样才与这个 return
兼容。
在下面清单 9-10 中,就要看看一个有着与其上使用了 ?
的类型值不兼容返回值的 main
函数中,使用 ?
操作符会收到的错误:
文件名:src/mian.rs
use std::fs::File;
fn main() {
let greating_file = File::open("hello.txt");
}
清单 9-10:尝试在返回 ()
的 main
函数中使用 ?
就不会编译
此代码是要打开一个文件,这就可能失败。那个 ?
操作符接续了由 File::open
所返回的 Return
值,然而这个 main
函数的返回值类型为 ()
,而非 Result
。那么在编译此代码时,就会得到以下的错误消息:
$ cargo run lennyp@vm-manjaro
Compiling error_handling_demo v0.1.0 (/home/lennyp/rust-lang/error_handling_demo)
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:48
|
3 | / fn main() {
4 | | let greating_file = 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_demo` due to previous error
此错误指出了只允许在返回 Result
、Option
或别的实现了 FromResidual
的类型的函数中,使用 ?
操作符。
而要修正这个错误,则有两个选择。一个选择是在没有修改函数返回值类型的限制时,那么就将其修改为与在其上使用 ?
操作符的值类型兼容。另一技巧,则是使用一个 match
表达式,或某个 Result<T, E>
的那些方法,来以某种恰当方式对这个 Result<T, E>
进行处理了。
这个错误消息还提到,?
还可与 Option<T>
类型的值一同使用。与在 Result
上使用 ?
一样,可在返回一个 Option
的函数中的 Option
上使用 ?
。在某个 Option<T>
上调用 ?
操作符的行为,与在 Result<T, E>
上其被调用时的行为类似:在该值为 None
时,None
就会在那个地方及早地从该函数被返回。而在该值为 Some
时,那么这个 Some
内部的值,就是该表达式的结果值,同时函数会继续执行。下面清单 9-11 有着一个在给定文本中找到第一行最后一个字符的函数示例:
fn last_char_of_first_line(text: &str) -> Option<char> {
text.lines().next()?.chars().last()
}
清单 9-11:在某个 Option<T>
的值上使用 ?
操作符
由于可能那里有个字符,不过同样坑能那里没有字符,因此此函数返回的是 Option<char>
。这个代码取那个 text
字符串切片参数,并在其上调用了 lines
方法,该方法返回的是对该字符串中那些文本行的一个迭代器。由于此函数是要对首个文本行进行检查,因此他调用了那个迭代器上的 next
,来从迭代器上获取头一个值。在 text
为空字符串时,那么这个到 next
的调用,就会返回 None
,这也就是这里使用 ?
来停止这个 last_char_of_first_line
函数,并自其返回 None
的情形。而在 text
不为空字符串时,next
就会返回一个包含了在 text
中第一行文本的字符串切片的 Some
值。
此时 ?
操作符会提取这个字符串切片,进而就可以在那个字符串切片上调用 chars
,来获取到他那些字符的一个迭代器。这里关心的是第一行文本中的最后一个字符,因此就要调用 last
来返回迭代器中的最后一个条目。因为首个文本行为空字符串是可能的,比如在 text
以空行开头却在其他行上有一些字符,如同在 "\nhi"
中一样,因此 last
得到一个就是个 Option
值。不过在首行上有最后一个字符时,这个字符就会在 Some
变种里被返回。中间的 ?
操作符,给到了一种表达此逻辑的简洁方式,运行在一个行里来实现该函数。若无法在 Option
上运用这个 ?
操作符,那么就必须使用更多方法调用,或 match
表达式来实现此逻辑。
注意在返回 Result
函数中的 Result
上,可以使用 ?
操作符,而在返回 Option
函数中的 Option
上,可使用 ?
操作符,但不能混用及进行匹配。?
操作符不会自动将 Result
转换为 Option
,或反过来将 Option
转换为 Result
;在这些情况下,是可以在 Result
上使用诸如 ok
,或在 Option
上使用 ok_or
这样的方法,来显示地完成转换。
到目前为止,这里使用过的所有 main
函数,返回的都是 ()
。由于 main
函数是可执行程序的进入与退出点,因此他是特殊的,而关于其返回值类型可以是什么,为了程序如预期那样执行,是有一些限制的。
幸运的是,main
函数同样可以返回 Result<(), E>
。下面清单 9-12 有着来自 9-10 的代码,不过这里将 main
函数的返回值类型,改成了 Result<(), Box<dyn Error>>
,并在最后添加了一个返回值 Ok(())
。现在该代码就会编译了:
use std::error::Error;
use std::fs::File;
fn main() -> Result<(), Box<dyn Error>> {
let greeting_file = File::open("hello.txt")?;
Ok(())
}
清单 9-12:将 main
修改为返回 Result<(), E>
,就实现了在 Result
值上 ?
操作符的使用
这里的 Box<dyn Error>
类型,是个 特质对象(trait object),在第 17 章中的 “运用特质对象实现不同类型的值” 小节,就会讲到这个特性。而现在,可将 Box<dyn Error>
理解为表示 “任何类别的错误”。由于 ?
操作符允许将任何 Err
值及早返回,因此将 ?
用在有着错误类型 Box<dyn Error>
的 main
函数中, 某个 Result
值上是允许的。即使这个 main
函数的函数体,将只会返回类型 std::io::Error
的那些错误,而经由指定 Box<dyn Error>
,即使将返回其他错误的代码添加到 main
的函数体,该函数签名 fn main() -> Result<(), Box<dyn Error>>
仍将无误。
在 main
函数返回了一个 Result<(), E>
时,那么若 main
返回的是 Ok(())
,则该可执行程序就会以值 0
退出,并在 main
返回 Err
值时,以非零值退出。C 语言编写的可执行程序,在退出时返回的是些整数:成功退出的程序返回整数 0
,而出错的程序返回某些非 0
的整数。Rust 从可执行程序返回的也是整数,从而与此约定兼容。
main
函数可能返回任何实现了 std::process::Termination
特质(the std::process::Termination
) 的任何类型,该特质包含了返回某个 ExitCode
的 report
函数。请参考标准库文档,了解更多有关实现自己类型 Termination
的信息。
现在既然已经讨论了调用 panic!
或返回 Result
的细节,那么就要回到怎样判断,在何种情形下,使用哪种方式属于恰当的话题了。
要 panic!
还是不要 panic!
那么该怎样确定,什么时候应该调用 panic!
,以及什么时候应该返回 Result
呢?在代码中止时,就没有办法恢复了。当然可以在任何错误情形下调用 panic!
,而不管存不存在可能的恢复方式,不过这个时候就是代码编写者本人,代替代码在做出这种情形为不可恢复的确定了。而在选择了返回某个 Result
值是,就赋予了调用代码(the calling code)各种选项。调用代码就可以根据其自身情况,而选择尝试恢复,或者他可以决定在此情形下的某个 Err
是不可恢复的,进而他就可以调用 panic!
而将可恢复错误,转变为不可恢复错误。这样看来,在对某个可能失败的函数进行定义时,返回一个 Result
就是良好的默认选择。
而在示例程序、原型代码及测试等中,那么比起返回 Result
,编写程序中止代码就要更合适。接下来就要探讨一下为何这样讲,随后就要讨论一些编译器无法搞清楚,但作为代码编写者的人类却明白程序失败不可能发生的情形。本章将以一些有关在库代码中,如何确定要不要中止程序的守则结束(in situations such as examples, prototype code, and tests, it's more approciate to write code that panics instead of returning a Result
. Let's explore why, then discuss situations in which the compiler can't tell that failure is impossible, but you as a human can. The chapter will conclude with some general guidelines on how to decide whether to panic in library code)。
示例程序、原型代码与测试
在编写用于演示某些概念的示例程序时,若同时包含一些健壮的错误处理代码,就会令到示例程序不那么明晰。在示例程序中,到某个诸如 unwrap
这样的可能会中止程序运行方法的调用,确信就表明那是一个是要这个应用程序对错误进行处理的占位符,而根据接下来代码所做的事情,这样的调用会有所不同。
与此类似,unwrap
与 expect
方法在构造原型程序,尚未准备好确定如何处理错误时,是十分方便的。这些方法在代码中留下了清楚的一些记号,这些记号在已做好准备让程序更为健壮时会用到。
而当方法调用在测试中失败时,即使那个方法并非正在测试的某些功能,也会希望整个测试失败。由于 panic!
正是将测试标记为失败的方式,那么调用 unwrap
或 expect
,则正是要用到的了。
相比于编译器,代码编写者掌握了更多信息的情形
在有着确保了 Result
将有着 Ok
值的其他某些逻辑,但编译器对此逻辑却一无所知时,调用 unwrap
或 expect
也是恰当的。这时将仍然有个需要处理的 Result
值:对于不论所调用的什么操作,即使在当前特定情形下,逻辑上失败绝无可能,但所调用的操作原本总体上仍是有失败可能。在经由亲自检查代码,而能确保绝不会有 Err
变种时,调用 unwrap
就是完美可接受的,且将自己设想的绝不会有 Err
变种的原因,在 expect
文本中撰写出来,这样做甚至更佳。下面就是一个示例:
fn main () {
use std::net::IpAddr;
let home: IpAddr = "127.0.0.1"
.parse()
.expect("硬编码的 IP 地址应是有效的");
}
这里是在通过解析硬编码字符串,创建一个 IpAddr
。可以看到 127.0.0.1
是个有效的 IP 地址,因此这里使用 expect
是可接受的。然而,有着一个硬编码的、有效的字符串,并未改变 parse
方法返回值类型:这里仍将得到一个 Result
类型,同时由于编译器不是足够聪明到发现这个字符串总是个有效的 IP 地址,那么编译器仍将要求,以 Err
变种是一种可能性那样,对这个 Result
进行处理。在这个 IP 地址为来自用户输入,而非这里的硬编码到程序中,进而 确实 有着失败可能时,无疑就要打算对这个 Result
进行更为健壮的处理了。这种提及这个 IP 地址为硬编码的假设,将提醒到在将来,需要从其他来源获取这个 IP 地址时,就要把 expect
修改为更好的错误处理代码。
错误处理守则
在代码可能以糟糕状态结束运行时,那么让代码中止运行就是明智的。在这种情形下,所谓 糟糕状态(a bad state) 就是在某种假设、保证、合约,或恒值已被破坏,譬如在无效值、矛盾值,或缺失值被传递到所编写代码 -- 加上以下的一项或多项:
- 糟糕状态是某些不期望的东西,他们与偶发的东西相反,比如用户输入的错误格式数据;
- 在此处之后的代码,需要依赖于不处在这种糟糕状态,而不是在接下来的每一步都检查这个问题;
- 没有以自己所使用的类型,来编码该信息的好办法。在第 17 章的 “将状态于行为编码为类型” 小节,就会贯穿一个这里所意指的示例。
在有人调用到咱们的代码,并传入了无意义的值时,在可以的情况下,最好返回一个错误,这样库用户就可以确定在那样的情况下,他们打算做什么。然而在继续执行下去会不安全或有危害的情形中,那么最佳选择就会时调用 panic!
,并警醒使用到咱们库的人他们代码中的错误,这样在他们开发过程中就可以修好那个代码错误。与此类似,在调用不在掌控中的外部代码,且该外部代码返回了无法修复的无效状态时,那么 panic!
通常就是恰当选择。
不过在失败为预期的时,那么相比于构造一个 panic!
调用,返回一个 Result
则更为恰当。这类示例包括给到解析器错误格式数据,或某个返回了表示已达到访问数限制的 HTTP 请求等。在这些情况下,返回一个 Result
就表示失败是一种调用代码必须确定如何处理的预期可能。
在所编写代码被使用无效值调用,而执行了某种可能将用户置于危险境地的操作时,那么代码就应首先对这些值进行检查,并在这些值无效时中止运行。这主要是处于安全原因:尝试运行于无效数据,就会将代码暴露于漏洞。这就是在尝试超出边界的内存访问时,标准库会调用 panic!
的主要原因:尝试访问不属于当前数据结构的内存,是个常见的安全问题。函数通常有着 合约(contracts):只在输入满足特定要求时,他们的行为才有保证。那么由于合约破坏总是表明调用者侧的代码错误,且这种错误并非是要调用代码必须显式处理的那种错误,因此在合约被破坏时的中止运行就说得通了。实际上,调用代码是没有恢复的合理方法的;调用的 代码编写者 需要修复该代码。应在函数的 API 文档中,解释函数的合约,尤其是在合约破坏会导致中止运行时。
但是,在全部的函数中,进行大量错误检查,则会显得冗长而烦人。幸运的是,可使用 Rust 的类型系统(并因此由编译器完成类型检查),来完成许多的检查。在函数有着作为参数的特定类型时,就可以在知悉编译器已经确保有着有效值的情况下,着手处理代码的业务逻辑。比如,在有着一个不同于 Option
的类型时,程序就期望有 某个东西(something) 而非 什么也没有(nothing)。代码这时就不必处理 Some
与 None
变种的两种情形:无疑将只有一种有着某个值的情形。尝试将无值传递给该函数的代码,甚至都不会编译,那么该函数就不必在运行时对那样的情况进行检查了。另一个示例则是使用某个诸如 u32
无符号整数,这就确保了参数绝不会是个负数。
创建用于验证的定制类型
接下来将这个运用 Rust 的类型系统,来确保有着有效值的概念,进行进一步拓展,而看看创建一个用于验证的定制类型。回顾在第二章中的猜数游戏,其中的代码要求用户猜出一个 1
与 100
之间的数字。在将用户猜的数字与那里的秘密数字比对之前,是绝无对用户猜数是否处于 1
与 100
之间,进行过验证的;那里只验证过猜数为正数。在这个示例中,后果并不是非常可怕:这里的输出 “太大了” 或 “太小了” 仍将正确。但引导用户朝向有效的猜数,并在用户猜出不在该范围的数,与用户敲入了比如一些字母时,而有不同的表现,将是一项有用的功能增强。
完成此功能增强的一种方式,将是将猜数解析为一个 i32
而非仅仅为一个 u32
,从而允许潜在的负数,并在随后键入一个该数位于范围中的检查,像下面这样:
loop {
// --跳过--
let guess: i32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => { println! ("请输入一个数字!"); continue },
};
if guess < 1 || guess > 100 {
println! ("秘密数字将在 1 和 100 之间");
continue;
}
match guess.cmp(&secret_number) {
// --跳过--
}
其中的 if
表达式,对这里的值是否超出范围进行了检查,告诉用户这个问题,并调用 continue
来开始下一次循环迭代而请求另一个猜数。在这个 if
表达式之后,就可继续进行 guess
与秘密数字之间的比较,获悉 guess
是在 1
与 100
之间。
然而这并非一种理想的方案:若程序只运行在 1
与 100
之间的值这一点至关重要,且程序有着许多有此要求的函数,而在每个函数中都进行这样的一个检查,就会显得冗长乏味(并可能影响性能)。
相反,这里可以构造一种新类型,并将那些验证放入某个函数,从而创建出该类型的一个示例,而非在各个地方重复这些验证。那样的话,这些函数就可以在他们的签名中,安全地使用这种新类型,并信心十足地使用他们接收到的那些值了。下面清单 9-13 给出了一种定义 Guess
类型的方式,在 new
函数接收到一个 1
与 100
之间的值时,这种方式下将只创建一个 Guess
的实例。
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic! ("Guess 类型值必须在 1 与 100 之间,收到的是 {}", value);
}
Guess { value }
}
pub fn value(&self) -> i32 {
self.value
}
}
清单 9-13:只会在值处于 1
与 100
之间,才继续执行的一个 Guess
类型
首先,这里定义了一个名为 Guess
,带有一个叫做 value
、保存了一个 i32
值字段的结构体。这就是要存储数字的地方。
随后这里在 Guess
上实现了一个名为 new
的关联函数,其创建出一个 Guess
类型的实例。这个 new
函数被定义为有着一个名为 value
、类型为 i32
的参数,以及要返回一个 Guess
类型值。new
函数体中的代码,对 value
进行了测试,从而确保 value
是在 1
与 100
之间。若 value
未通过此测试,那么就做出一个 panic!
调用,由于创建一个超出此范围的 Guess
会破坏 Guess::new
所依赖的合约,因此这就会警醒到编写调用代码的程序员,他们有个需要修复的代码错误。Guess::new
可能中止运行的条件,应在其公开的 API 文档中,进行说明。在第 14 章就会涉及到在所创建的文档中,表示 panic!
可能性的一些约定。在 value
通过该测试时,这里就会创建一个将 value
字段设置为那个 value
参数的新 Guess
类型值,并返回这个 Guess
类型值。
接下来,这里实现了一个名为 value
、借用了 self
,不带任何其他参数,并返回一个 i32
的方法。由于这类方法的目的,是要从一些字段获取数据并加以返回,因此有时就被叫做 取值方法(getter)。因为 Guess
结构体的这个 value
字段是私有的,那么这个公开方法就是必要的。这个 value
字段作为私有至关重要,这样使用这个 Guess
结构体的代码就不被允许直接设置 value
:该模组外部的代码,必须 使用 Guess::new
函数,来创建 Guess
的实例,这样就确保了 Guess
不会有未经 Guess::new
函数中条件检查的 value
。
现在某个有着一个 1
与 100
之间参数,或只返回 1
与 100
之间数字的函数,就可以在其函数签名中,声明他所取参数或其返回值为 Guess
类型而非 i32
类型,而不再需要在其函数体中完成任何额外检查了。
小结
Rust 的那些错误处理特性,被设计用于帮助编写更为健壮的代码。panic!
这个宏,发出了程序处于其无法处理状态的信号,并让咱们告知进程停下来,而不是尝试以无效或不正确的一些值继续运行。而 Result
这个枚举则使用了 Rust 的类型系统,来表示以代码可以从中恢复过来的某种方式的一些操作失败(the Result
enum uses Rust's type system to indicate that operations might fail in a way that your code could recover from)。还可使用 Result
来告诉调用了咱们代码的代码,需要处理潜在的成功与失败情形。在一些适当情形下,运用 panic!
与 Result
就会令到咱们的代码在各种不可避免的问题面前,更加可靠。
既然这里已经见识到标准库在 Option
与 Result
枚举上,运用到泛型的一些有用方式,那么接下来就要谈及泛型的原理,以及怎样在咱们的代码中运用泛型。