写点什么

Rust 从 0 到 1- 错误处理 -Result

用户头像
关注
发布于: 2021 年 05 月 20 日
Rust从0到1-错误处理-Result

大部分错误并没有严重到需要我们停止程序,这时候我们需要对失败的操作进行响应或处理。譬如,当打开一个并不存在的文件时,打开文件的操作会失败,此时我们可能想要创建一个新文件,而不是终止程序。Rust 提供了 Result 类型用来处理这种场景,它是一个枚举类型:

enum Result<T, E> {    Ok(T),    Err(E),}
复制代码

T 和 E 是泛型,后面章节会详细讨论泛型。在这里我们只需要知道 T 代表成功时返回结果的数据类型,而 E 代表失败时返回的错误类型。因为 Result 使用了泛型,因此标准库中的函数在错误处理时可以统一返回 Result 类型,而我们可以根据实际的应用场景,对函数返回成功和失败进行不同处理。下面我们看一个返回 Result 类型结果的函数例子:

use std::fs::File;
fn main() { let f = File::open("hello.txt");}
复制代码

File::open 返回结果的类型是 Result。我们可以查阅官方标准库 API 文档,一般 IDE 也会提示我们,甚至于我们忘记了的话,最后还有编译器提醒我们!例如,如果打开文件的操作成功,那么泛型参数 T 的类型会是 std::fs::File,如果失败,泛型参数 E 类型会是 std::io::Error。

对于 File::open 函数来说,调用可能会成功并返回一个可以进行读写的文件句柄,但是调用也可能会失败:譬如,文件不存在,或者没有访问文件的权限等等。因此 File::open 函数需要通过一种方式告诉我们它执行成功还是失败了,包括执行成功时的文件句柄或失败时的错误信息,这也是其它类似的函数也需要的。 Result 正是为处理这种场景提供了一种统一的方式。下面我们利用前面介绍的 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 the file: {:?}", error), };}
复制代码

与 Option 一样,Result 和其成员在 prelude 也被默认引入了,因此我们就不需要在 Ok 和 Err 之前使用前缀 Result::(说明经常被用到)。上例中当调用成功时,我们获取 Ok 成员中的值 file,然后将这个文件句柄赋值给变量 f。之后,我们可以利用这个文件句柄来对文件进行读写;当调用失败时,我们使用 panic! 宏终止程序,并输出错误原因 ,类似下面的结果:

$ 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:23note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
复制代码

处理不同的错误

前面的例子中不管 File::open 函数调用失败的原因是什么都会执行 panic! 终止程序。然而,通常我们会希望对不同原因的错误采取不同的处理:譬如,如果是因为文件不存在,我们希望创建一个新文件并返回它的句柄;如果是因为除此之外的其它原因失败,我们仍然执行 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) } }, };}
复制代码

上面的例子中 Err 成员中的值类型是 io::Error,它是 Rust 标准库中提供的结构体,提供了一个 kind 方法,返回 io::ErrorKind 枚举类型,它的成员对应 IO 操作可能导致的不同错误类型。在例子中我们关注的是 ErrorKind::NotFound,它意味着目标文件不存在。除了对于 File::open 结果的 match 以外,在内部还有一个对于 error.kind() 错误类型的 match 。

另外,在 error.kind() 的返回值是 ErrorKind::NotFound 的时候,我们希望尝试通过 File::create 方法创建文件。然而因为 File::create 方法也可能会失败,因此我们又嵌套了一个 match 语句,并在文件创建失败时打印不同的错误信息。

例子中总共嵌套了三层 match!match 确实很强大,但这种嵌套的方式感觉并不是这么优美,而 Result<T, E> 有很多接受闭包(closure)的方法(底层也是通过 match 实现,后面章节我们会更详细介绍闭包),可以让代码看起来更简洁,让我们先来体验一下利用闭包的写法:

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); } });}
复制代码

unwrap 和 expect

match 能够实现我们的需求,不过代码看上去会不这么简洁并且有时候,譬如嵌套比较多的时候,可读性也没那么好。因此, Result<T, E> 类型内置了很多方法来处理各种场景。我们下面介绍其中较为常用的两个,一个是 unwrap,它的功能类似前面例子中 match 语句,即如果 Result 是成员 Ok,unwrap 会返回其中的值。如果 Result 是成员 Err,unwrap 就会调用 panic!,参考下面的例子:

use std::fs::File;
fn main() { let f = File::open("hello.txt").unwrap();}
复制代码

如果调用 File::open 成功,就会将文件句柄赋值给 f ,如果失败将会看到 panic! 提供的错误信息,类似下面这样:

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
复制代码

另外一个方法是 except,它允许我们自己定义误信息的提示内容。使用 expect 提供一个清晰明确的错误提示信息便于我们在出错后定位问题和处理问题,参考下面的例子:

use std::fs::File;
fn main() { let f = File::open("hello.txt").expect("Failed to open hello.txt");}
复制代码

除此之外 expect 的行为方式与 unwrap 一样,上例中的错误信息类似下面这样:

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
复制代码

错误的传播

我们除了可以像前面介绍的一样在当前函数中处理错误,还可以选择让调用我们函数的调用方知道这个错误并决定如何处理(类似 Java 里将抛出异常)。在 Rust 中这种行为被称为传播(propagating)错误,它给予了调用方更多的控制权。因为上层的调用者可能比底层的方法更贴近于应用场景,由他们来决定应该如何处理错误,可能会更为合适。参考下面的例子:

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");
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), }}
复制代码

上面的例子是从一个从文件中读取用户名的函数。如果文件不存在或不能读取,函数会将错误 Err(e) 返回给调用它的代码,如果执行成功则返回文包含文件内容的字符串 Ok(s) ,这也与函数定义的返回类型 Result<String, io::Error> 相配,其中的处理逻辑,包括 match 和 Result 的使用,在前面已经做过介绍。read_username_from_file 函数最终会返回一个包含用户名的 Ok ,或者一个包含 io::Error 的 Err 。调用者可以根据实际的应用场景进行相应的处理。例如,在发生错误时,调用者可以选择 panic! 终止程序或使用一个默认的用户名或从文件之外的其它地方读取用户名。read_username_from_file 本身没有足够的信息做出选择,所以将错误向上传播,让调用者选择如何去处理。这种场景非常的多,因此 Rust 提供了一种非常简洁的写法。

? 操作符

我们使用 ?  操作符对 read_username_from_file 函数进行重构:

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)}
复制代码

非常的简洁,如果 Result 的值是 Ok,将会返回 Ok 中的值,程序继续执行;如果 Result 的值是 Err,Err 将作为整个函数的返回值,就像前面例子中使用的 return 关键字一样,将错误传播给了调用者。

match 与 ?有一点不同:?  会将 Err 中的具体错误作为参数传递给 from 函数(定义于标准库的 From trait 中),将错误从一种类型转换为当前函数定义的返回的错误类型。这在当函数只返回一种错误类型时很有用,即使其可能会因很多种类型的错误失败,只要每一个错误类型都实现了 from,?  会自动处理这些转换。

? 操作符使得我们的代码变得非常简洁,甚至我们可以在 ? 之后进行链式函数调用(chaining method calls)来进一步精简代码:

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。最后当 File::open 和 read_to_string 都成功没有失败时返回包含文件内容的字符串 Ok(s)。其功能与前面的例子完全一样。说到这里,Rust 中甚至还有一个更简洁的写法:

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,接着返回它(包括错误)。当然,这样做我们就没有展示 Rust 错误处理方法的机会了。

? 可以在哪里使用

? 操作符可以被用于返回结果类型为 Result 的函数中,下面让我们看看在 main 函数中使用 ? 运算符会发生什么:

use std::fs::File;
fn main() { let f = File::open("hello.txt")?;}
复制代码

上面例子中的代码是无法编译通过的,会得到类似如下错误信息:

$ 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 `Try`) --> src/main.rs:4:13  |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 `Try` is not implemented for `()`  = note: required by `from_error`
error: aborting due to previous error
For more information about this error, try `rustc --explain E0277`.error: could not compile `error-handling`
To learn more, run the command again with --verbose.
复制代码

从编译器的提示信息我们可以看到 ? 只能在返回结果类型为 Result、Option 或者其它实现了 std::ops::Try 的类型的函数中使用。我们有两种方法可以修复这个问题,一是通过修改函数的返回值类型;另外一种是使用 match 或 Result 提供的方法对结果进行处理,返回与之相匹配的类型。在 Rust 中 main 函数是比较特殊的,其返回类型是有限制的。它有效的返回类型是 (),但是出于方便,另一个有效的返回类型是 Result<T, E>,因此我们可以做出如下修改:

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 对象,后面章节会做详细介绍,目前可以理解为允许返回任何类型的错误。

发布于: 2021 年 05 月 20 日阅读数: 7
用户头像

关注

公众号"山 顽石"欢迎大家关注;) 2021.03.23 加入

IT老兵,关注Rust、Java、前端、架构、大数据、AI、算法等;平时喜欢美食、旅游、宠物、健身、篮球、乒乓球等。希望能和大家交流分享。

评论

发布
暂无评论
Rust从0到1-错误处理-Result