引言
本文内容主要翻译自 Andrew Gallant 的文章 Error Handling in Rust。
如同大多数的编程语言,Rust 中也需要通过特定的方式处理错误。众所周知,目前常见的错误处理方式主要分为两种:
- 异常机制(C#/Java/Python 等);
- 返回错误(Go/Rust 等)。
本文将会综合全面地介绍 Rust 中的错误处理,通过循序渐进地引导,带领初学者扎实地掌握这块知识。如果我们不熟悉标准库的话,我们可能会使用较为愚蠢的方式处理错误,这种会比较繁琐,产生很多样板代码。所以本文会演示如何借助标准库让错误处理更加优雅和简洁。
Rust 错误处理
说明
本文的代码放在作者的 博客仓库。
Rust Book, Error Handling 中也有关于错误处理的部分,可以参照阅读。
本文篇幅巨长,主要是因为写了很多关于 Sum Types 和组合子(Combinators)的内容作为开头,逐步讲解 Rust 错误处理方式的改进。因此,如果你认为没有必要的话,也可以略过。以下是作者提供的简要指南:
- 如果你是 Rust 新手,对于系统编程和富类型系统(expressive type systems)不太熟悉的话,推荐你从头开始阅读(如果是完全没有了解过 Rust 的童鞋,推荐先阅读下 Rust Book;
- 如果你从来没有了解过 Rust,但是对于函数式语言很熟悉(看到「代数数据类型(algebaric data types)」和「组合子(combinators)」不会让你感到陌生),那也许可以直接跳过基础部分,从「多错误类型」部分开始阅读,然后阅读「标准库中的 error traits」部分;
- 如果你已经拥有 Rust 编程经验,并且只想了解下错误处理的方式,那么可以直接跳到最后,看看作者给出的案例研究(当然,还有译者给出的实际案例)。
运行代码
如果想要直接运行样例代码,可以参考下面的操作:
1 2 3 | $ git clone git://github.com/BurntSushi/blog $ cd blog/code/rust-error-handling $ cargo run --bin NAME-OF-CODE-SAMPLE [ args ... ] |
TL;TR
文章太长,如果没耐心阅读的话,我们可以先来看看关于 Rust 错误处理的一些总结。以下是作者提供的「经验法则」,仅供参考,我们可以从中获得一些启发。
- 如果你正在编写简短的示例代码,并且可能会有不少错误处理的负担,这种场景下可以考虑使用
unwrap (比如Result::unwrap ,Option::unwrap 或者Option::expect 等)。阅读你代码的人应该知道如何以优雅的姿势处理错误(如果 TA 不会,把这篇文章甩给 TA 看); - 如果你正在写一个类似临时脚本一样的程序(quick-n-dirty program),直接用
unwrap 也不用觉得羞愧。不过需要注意的是:如果别人接手你这的代码,那可能是要不爽的哦(毕竟没有错误处理不够优雅); - 对于??的场景,如果你觉得直接用
unwrap 不太好(毕竟出错会直接 panic 掉),那么可以考虑使用Box 或者anyhow::Error 类型作为函数的错误返回类型。如果使用了anyhow crate,当使用 nightly 版本的 Rust 时,错误会自动拥有关联的 backtrace; - 要是上面的方法还不行,那么可以考虑自定义错误类型,并且实现
From 和Error trait,从而可以顺利地使用? 操作符,让错误处理更加优雅(要是你连From 和Error trait 也不愿意动手实现的话,可以使用 thiserror 来自动生成); - 如果你正在编写一个库,并且可能会产生错误,推荐你自定义错误类型,并且实现
std::error::Error trait,并且实现合适的From trait,从而方便库的调用者编写代码(因为基于 Rust 的相干性原则(coherence rules),调用者不能为库中定义的错误类型实现From ,因此这是库作者的职责); - 学会使用
Option 和Result 中定义的组合子,有时候只是使用它们可能比较索然无味,但是可以通过合理地组合使用? 操作符合组合子来改善代码。and_then ,map 和unwrap_or 是作者比较喜欢的几个组合子。
总结一下流程如下:
错误定义流程
基础知识
错误处理可以看成是利用 分支判断(case analysis) 逻辑来指示一次计算成功与否。优雅的错误处理方式,关键就是要考虑减少显式编写分支判断逻辑的代码,同时还能保持代码的可组合性(就是让调用方有错误处理的决定权,调用方可以在约到错误时
保持代码的可组合性非常重要,否则我们可能在任何遇到不可预期的异常时出现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | // file: panic-simple // // Guess a number between 1 and 10. // If it matches the number I had in mind, return true. Else, return false. fn guess(n: i32) -> bool { if n < 1 || n > 10 { panic!("Invalid number: {}", n); } n == 5 } fn main() { guess(11); } |
如果尝试运行上述代码,程序会直接崩溃,并且会吐出下面的消息:
1 | thread '<main>' panicked at 'Invalid number: 11', src/bin/panic-simple.rs:5 |
下面这个例子,对于用户输入更加不可预知。它预期用户输入一个整数字符串,然后将其转换成整数后再乘以 2,打印出结果:
1 2 3 4 5 6 7 8 9 10 11 12 | // file: unwrap-double use std::env; fn main() { let mut argv = env::args(); let arg: String = argv.nth(1).unwrap(); // error 1 let n: i32 = arg.parse().unwrap(); // error 2 println!("{}", 2 * n); } // $ cargo run --bin unwrap-double 5 // 10 |
如果我们输入的是 0 个参数(error 1),或者干脆输入不可转换成整数的字符串(error 2),则会导致程序 panic 掉。
Unwrap 详解
Option 类型
1 2 3 4 | enum Option<T> { None, Some(T), } |
在 Rust 中,我们可以利用
1 2 3 4 5 6 7 8 9 10 11 12 | // file: option-ex-string-find // // Searches `haystack` for the Unicode character `needle`. If one is found, the // byte offset of the character is returned. Otherwise, `None` is returned. fn find(haystack: &str, needle: char) -> Option<usize> { for (offset, c) in haystack.char_indices() { if c == needle { return Some(offset); } } None } |
温馨提示:不要在你的代码中使用上述代码,直接使用标准库提供的
我们注意到,上述函数在找到匹配的字符后,不会直接返回
Oops,看起来有点无聊哦,不过故事还没唠完。我们来看看怎么使用
1 2 3 4 5 6 7 | fn main_find() { let file_name = "foobar.rs"; match find(file_name, '.') { Some(i) => println!("File extension: {}", &file_name[i+1..]), None => println!("No file extension found."), } } |
上述代码对于
啊喂,等下,在前面提到的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | enum Option<T> { None, Some(T), } impl<T> Option<T> { fn unwrap(self) -> T { match self { Option::Some(val) => val, Option::None => panic!("called `Option::unwrap()` on a `None` value"), } } } |
组合 Option 值
在前面的
查找文件扩展名是常见的操作,所以有必要将其封装成另一个函数:
1 2 3 4 5 6 7 8 9 10 11 | // file: option-ex-string-find // // Returns the extension of the given file name, where the extension is defined // as all characters succeeding the first `.`. // If `file_name` has no `.`, then `None` is returned. fn extension_explicit(file_name: &str) -> Option<&str> { match find(file_name, '.') { None => None, Some(i) => Some(&file_name[i+1..]), } } |
温馨提示:不要使用上述代码,使用标准库中定义的 extension 方法代替
代码很简单,不过需要特别注意的是
事实上,在
Rust 中可以轻松定义一个组合子,从而将这种模式抽象出来:
1 2 3 4 5 6 7 | // file: option-map fn map<F, T, A>(option: Option<T>, f: F) -> Option<A> where F: FnOnce(T) -> A { match option { None => None, Some(value) => Some(f(value)), } } |
当然,在标准库中,
有了上面的组合子,我们可以对
1 2 3 4 5 6 7 | // file: option-ex-string-find // Returns the extension of the given file name, where the extension is defined // as all characters succeeding the first `.`. // If `file_name` has no `.`, then `None` is returned. fn extension(file_name: &str) -> Option<&str> { find(file_name, '.').map(|i| &file_name[i+1..]) } |
另外一种常见的模式是在
如你所期,这样的分支判断逻辑并非限定于文件扩展名,它可以用于任何
1 2 3 4 5 6 7 | // file: option-unwrap-or fn unwrap_or<T>(option: Option<T>, default: T) -> T { match option { None => default, Some(value) => value, } } |
使用起来非常简单:
1 2 3 4 5 | // file: option-ext-string-find fn main() { assert_eq!(extension("foobar.csv").unwrap_or("rs"), "csv"); assert_eq!(extension("foobar").unwrap_or("rs"), "rs"); } |
温馨提示:
还有一个值得特别注意的组合子
那么,接下来我们要挑战的任务是从给定的文件路径中找到文件扩展名。先来看看显式判断的写法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | // file: option-ex-string-find fn file_path_ext_explicit(file_path: &str) -> Option<&str> { match file_name(file_path) { None => None, Some(name) => match extension(name) { None => None, Some(ext) => Some(ext), } } } fn file_name(file_path: &str) -> Option<&str> { // implementation elided unimplemented!() } |
看起来貌似可以使用
1 2 3 4 5 6 7 8 | // file: option-and-then fn and_then<F, T, A>(option: Option<T>, f: F) -> Option<A> where F: FnOnce(T) -> Option<A> { match option { None => None, Some(value) => f(value), } } |
现在,我们可以重构
1 2 3 | fn file_path_ext(file_path: &str) -> Option<&str> { file_name(file_path).and_then(extension) } |
使用
Result 类型
1 2 3 4 | enum Result<T, E> { Ok(T), Err(E), } |
1 | type Option<T> = Result<T, ()>; |
以上是让
正如
1 2 3 4 5 6 7 8 9 10 | // file : result-def impl<T, E: ::std::fmt::Debug> Result<T, E> { fn unwrap(self) -> T { match self { Result::Ok(val) => val, Result::Err(err) => panic!("called `Result::unwrap()` on an `Err` value: {:?}", err), } } } |
这个基本和
下面,来看个例子。
解析整数
Rust 标准库让字符串转整数非常容易:
1 2 3 4 5 6 7 8 9 | // file: result-num-unwrap fn double_number(number_str: &str) -> i32 { 2 * number_str.parse::<i32>().unwrap() } fn main() { let n: i32 = double_number("10"); assert_eq!(n, 20); } |
此处,我们需要对使用
1 | thread '<main>' panicked at 'called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }', /home/rustbuild/src/rust-buildbot/slave/beta-dist-rustc-linux/build/src/libcore/result.rs:729 |
这看起来很不优雅,如果 panic 发生在我们使用的库中,自然会更加烦恼。所以,我们应当尝试在函数返回错误,让调用方自由决定做什么。这就意味着要修改
1 2 3 | impl str { fn parse<F: FromStr>(&self) -> Result<F, F::Err>; } |
Emm,至少我们知道需要使用
接下来看看,我们怎么指定返回类型呢?上面的
我们需要看看
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | // file: result-num-to-unwrap use std::num::ParseIntError; fn double_number(number_str: &str) -> Result<i32, ParseIntError> { match number_str.parse::<i32>() { Ok(n) => Ok(2 * n), Err(err) => Err(err), } } fn main() { match double_number("10") { Ok(n) => assert_eq!(n, 20), Err(err) => println!("Error: {:?}", err), } } |
这样看起来就好多了,但是我们还是写了很多代码,尤其是分支判断逻辑又出现了。那该怎么办呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 | // file: result-num-no-unwrap-map use std::num::ParseIntError; fn double_number(number_str: &str) -> Result<i32, ParseIntError> { number_str.parse::<i32>().map(|n| 2 * n) } fn main() { match double_number("10") { Ok(n) => assert_eq!(n, 20), Err(err) => println!("Error: {:?}", err), } } |
Result 类型别名
看标准库中,经常会发现类似
1 2 3 4 5 6 7 8 9 | // file: result-num-no-unwrap-map-alias use std::num::ParseIntError; use std::result; type Result<T> = result::Result<T, ParseIntError>; fn double_number(number_str: &str) -> Result<i32> { unimplemented!(); } |
为什么需要这么做呢?假如我们有很多函数都要返回
在标准库最典型的应用就是
小插曲:unwrap 并非邪恶
虽然上面花了很多篇幅在讲怎么避免使用
-
在示例代码、临时脚本(qunk-n-dirty code)中。有时候我们在写的临时脚本程序,对于错误处理并不需要严肃处理,所以即便使用
unwrap 也没什么毛病; -
想要通过 panic 表明程序中存在 bug。当你需要在代码中阻止某些行为发生时(比如从空的 stack 中弹出元素),可以考虑使用
panic ,这样可以暴露出程序中的 bug。
可能还有很多其它的场景,不在此一一列举了。另外,当我们使用
归根结底,作者的观点是:保持良好的判断力。对于任何事情都需要权衡,我们需要根据场景来判断使用什么方法。
处理多种错误类型
到目前为止,我们学习了很多关于使用
组合 Option 和 Result
至此,我们已经学习了很多关于
然而,现实代码并非如此干净。有时,我们会有
现在,让我们复习下前面提到的例子:
1 2 3 4 5 6 7 8 9 10 11 | use std::env; fn main() { let mut argv = env::args(); let arg: String = argv.nth(1).unwrap(); // error 1 let n: i32 = arg.parse().unwrap(); // error 2 println!("{}", 2 * n); } // $ cargo run --bin unwrap-double 5 // 10 |
鉴于我们已经学会使用
这里比较特别的是,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | // file: error-double-string use std::env; fn double_arg(mut argv: env::Args) -> Result<i32, String> { argv.nth(1) .ok_or("Please give at least one argument".to_owned()) .and_then(|arg| arg.parse::<i32>().map_err(|err| err.to_string())) } fn main() { match double_arg(env::args()) { Ok(n) => println!("{}", n), Err(err) => println!("Error: {}", err), } } |
在这个例子中有几个新的知识点值得介绍。第一点是使用
1 2 3 4 5 6 7 | // file: option-ok-def fn ok_or<T, E>(option: Option<T>, err: E) -> Result<T, E> { match option { Some(v) => Ok(v), None => Err(err), } } |
这里使用的另一个组合子是
这里之所以使用
组合子的限制
IO 和解析命令行输入是很常见的操作,所以我们将会继续讲一些和 IO 有关的例子来展开错误处理。
先从简单的例子开始吧,假设我们需要读取一个文件,将其中每一行转成数字形式,然后再乘以 2 后输出。
虽然之前强调不要随意使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | // file: io-basic-unwrap use std::fs::File; use std::io::Read; use std::path::Path; fn file_double<P: AsRef<Path>>(file_path: P) -> i32 { let mut file = File::open(file_path).unwrap(); // error 1 let mut contents = String::new(); file.read_to_string(&mut contents).unwrap(); // error 2 let n: i32 = contents.trim().parse().unwrap(); // error 3 2 * n } fn main() { let doubled = file_double("foobar"); println!("{}", doubled); } |
温馨提示:这里使用了和
这里有三种可能的错误会发生:
- 打开文件;
- 从文件中读取数据;
- 将文本数据转换成数字。
前两个错误可以使用
接下来,开始重构
首先我们要做的决策是:我们是应该使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | // file: io-basic-error-string use std::fs::File; use std::io::Read; use std::path::Path; fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> { File::open(file_path) .map_err(|err| err.to_string()) .and_then(|mut file| { let mut contents = String::new(); file.read_to_string(&mut contents) .map_err(|err| err.to_string()) .map(|_| contents) }) .and_then(|contents| { contents.trim().parse::<i32>() .map_err(|err| err.to_string()) }) .map(|n| 2 * n) } fn main() { match file_double("foobar") { Ok(n) => println!("{}", n), Err(err) => println!("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 24 25 26 27 | // file: io-basic-error-string-early-return use std::fs::File; use std::io::Read; use std::path::Path; fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> { let mut file = match File::open(file_path) { Ok(file) => file, Err(err) => return Err(err.to_string()), }; let mut contents = String::new(); if let Err(err) = file.read_to_string(&mut contents) { return Err(err.to_string()); } let n: i32 = match contents.trim().parse() { Ok(n) => n, Err(err) => return Err(err.to_string()), }; Ok(2 * n) } fn main() { match file_double("foobar") { Ok(n) => println!("{}", n), Err(err) => println!("Error: {}", err), } } |
关于是使用组合子好,还是上面的写法好,仁者见仁。当然,上面的写法明显更加直观易懂,当发生错误后直接终止函数执行,返回错误接即可。
看起来像是退步了一样,先前我们一再强调优雅错误处理的关键就是减少显式的分支判断逻辑,显然这里我们违背了之前的原则。不过,我们还有别的办法来解决显式分支判断过多的问题,下面来看看吧。
使用 try! 宏或者 ? 操作符
早期版本的 Rust 中(1.12 版本及以前),错误处理的基石之一是
以下是一个简单版本的
1 2 3 4 5 6 7 | // file: try-def-simple macro_rules! try { ($e:expr) => (match $e { Ok(val) => val, Err(err) => return Err(err), }); } |
我们来看看借助于
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | // file: io-basic-error-try use std::fs::File; use std::io::Read; use std::path::Path; fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> { let mut file = try!(File::open(file_path).map_err(|e| e.to_string())); let mut contents = String::new(); try!(file.read_to_string(&mut contents).map_err(|e| e.to_string())); let n = try!(contents.trim().parse::<i32>().map_err(|e| e.to_string())); Ok(2 * n) } fn main() { match file_double("foobar") { Ok(n) => println!("{}", n), Err(err) => println!("Error: {}", err), } } |
而在 Rust 1.13 之后,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | // file: io-basic-error-question use std::fs::File; use std::io::Read; use std::path::Path; fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> { let mut file = File::open(file_path).map_err(|e| e.to_string())?; let mut contents = String::new(); file.read_to_string(&mut contents).map_err(|e| e.to_string())?; let n = contents.trim().parse::<i32>().map_err(|e| e.to_string())?; Ok(2 * n) } fn main() { match file_double("foobar") { Ok(n) => println!("{}", n), Err(err) => println!("Error: {}", err), } } |
自定义错误类型
在深入学习标准库 error trait 之前,接下来我们要做的是将
- 容易污染代码,搞得到处都是错误消息字符串;
- 字符串会丢失信息(比如错误类型,错误来源等)。
举个例子,
所以接下来,我们要定义自己的错误类型,尽可能避免将下层错误给丢掉,这样如果调用方需要查看错误详情,也不至于无计可施。
我们可以通过
1 2 3 4 5 6 7 8 9 10 11 | // file: io-basic-error-custom use std::io; use std::num; // We derive `Debug` because all types should probably derive `Debug`. // This gives us a reasonable human readable description of `CliError` values. #[derive(Debug)] enum CliError { Io(io::Error), Parse(num::ParseIntError), } |
紧接着,我们只需要将之前例子中的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | // file: io-basic-error-custom use std::fs::File; use std::io::Read; use std::path::Path; fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, CliError> { let mut file = File::open(file_path).map_err(CliError::Io)?; let mut contents = String::new(); file.read_to_string(&mut contents).map_err(CliError::Io)?; let n: i32 = contents.trim().parse().map_err(CliError::Parse)?; Ok(2 * n) } fn main() { match file_double("foobar") { Ok(n) => println!("{}", n), Err(err) => println!("Error: {:?}", err), } } |
标准库中的错误处理 trait
标准库定义了两种错误处理必备的 trait:
-
std::error::Error ,专门用于表示错误; -
std::convert::From ,更加通用的接口,用于两种类型之间的转换。
Error trait
1 2 3 4 5 6 7 8 9 | use std::fmt::{Debug, Display}; trait Error: Debug + Display { /// A short description of the error. fn description(&self) -> &str; /// The lower level cause of this error, if any. fn cause(&self) -> Option<&Error> { None } } |
这个 trait 是非常通用的,也是所有表示错误的类型都要实现的 trait。总的来说,这个 trait 可以让我们做这么几件事情:
- 获取错误表示的
Debug 表示(需要实现std::fmt::Debug trait); - 获取错误的
Display 表示(需要实现std::fmt::Display trait); - 获取错误的简要描述(
description 方法); - 获取错误链(可以通过
cause 方法获得)。
由于所有的错误都实现了
接下来,我们可以为
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 | // file: error-impl use std::io; use std::num; use std::error; use std::fmt; // We derive `Debug` because all types should probably derive `Debug`. // This gives us a reasonable human readable description of `CliError` values. #[derive(Debug)] enum CliError { Io(io::Error), Parse(num::ParseIntError), } impl fmt::Display for CliError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { // Both underlying errors already impl `Display`, so we defer to // their implementations. CliError::Io(ref err) => write!(f, "IO error: {}", err), CliError::Parse(ref err) => write!(f, "Parse error: {}", err), } } } impl error::Error for CliError { fn description(&self) -> &str { // Both underlying errors already impl `Error`, so we defer to their // implementations. match *self { CliError::Io(ref err) => err.description(), // Normally we can just write `err.description()`, but the error // type has a concrete method called `description`, which conflicts // with the trait method. For now, we must explicitly call // `description` through the `Error` trait. CliError::Parse(ref err) => error::Error::description(err), } } fn cause(&self) -> Option<&dyn error::Error> { match *self { // N.B. Both of these implicitly cast `err` from their concrete // types (either `&io::Error` or `&num::ParseIntError`) // to a trait object `&Error`. This works because both error types // implement `Error`. CliError::Io(ref err) => Some(err), CliError::Parse(ref err) => Some(err), } } } |
From trait
先来看看
1 2 3 | trait From<T> { fn from(T) -> Self; } |
看起来超级简单吧,
1 2 3 | let string: String = From::from("foo"); let bytes: Vec<u8> = From::from("foo"); let cow: ::std::borrow::Cow<str> = From::from("foo"); |
好吧,看起来字符串之间的转换使用
1 | impl<'a, E: Error + 'a> From<E> for Box<dyn Error + 'a> |
这就一折任意实现了
1 2 3 4 5 6 7 8 9 10 11 12 13 | // file: from-example-errors use std::error::Error; use std::fs; use std::io; use std::num; // We have to jump through some hoops to actually get error values. let io_err: io::Error = io::Error::last_os_error(); let parse_err: num::ParseIntError = "not a number".parse::<i32>().unwrap_err(); // OK, here are the conversions. let err1: Box<Error> = From::from(io_err); let err2: Box<Error> = From::from(parse_err); |
上面的
是时候复习下我们的老伙计
try! 和 ? 内部实现
1 2 3 4 5 6 | macro_rules! try { ($e:expr) => (match $e { Ok(val) => val, Err(err) => return Err(::std::convert::From::from(err)), }); } |
1 2 3 4 | match ::std::ops::Try::into_result(x) { Ok(v) => v, Err(e) => return ::std::ops::Try::from_error(From::from(e)), } |
让我们来回忆下之前用于读取文件并转换成整数的代码:
1 2 3 4 5 6 7 8 9 10 11 | use std::fs::File; use std::io::Read; use std::path::Path; fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> { let mut file = File::open(file_path).map_err(|e| e.to_string())?; let mut contents = String::new(); file.read_to_string(&mut contents).map_err(|e| e.to_string())?; let n = contents.trim().parse::<i32>().map_err(|e| e.to_string())?; Ok(2 * n) } |
既然我们已经知道
1 2 3 4 5 6 7 8 9 10 11 12 13 | // file: io-basic-error-try-from use std::error::Error; use std::fs::File; use std::io::Read; use std::path::Path; fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, Box<dyn Error>> { let mut file = File::open(file_path)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; let n = contents.trim().parse::<i32>()?; Ok(2 * n) } |
现在,我们已经越来越接近完美的错误处理模式了。由于
- 分支判断;
- 控制流;
- 错误类型转换。
但是,还有一个小瑕疵没有解决。
组合自定义错误类型
首先要为我们的
1 2 3 4 5 6 7 8 9 10 11 | impl From<io::Error> for CliError { fn from(err: io::Error) -> CliError { CliError::Io(err) } } impl From<num::ParseIntError> for CliError { fn from(err: num::ParseIntError) -> CliError { CliError::Parse(err) } } |
然后将
1 2 3 4 5 6 7 8 | // file: io-basic-error-custom-from fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, CliError> { let mut file = File::open(file_path)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; let n: i32 = contents.trim().parse()?; Ok(2 * n) } |
如果我们想要让
1 2 3 4 5 | enum CliError { Io(io::Error), ParseInt(num::ParseIntError), ParseFloat(num::ParseFloatError), } |
然后实现错误类型转换 trait 即可:
1 2 3 4 5 6 7 8 9 10 11 | impl From<num::ParseIntError> for CliError { fn from(err: num::ParseIntError) -> CliError { CliError::ParseInt(err) } } impl From<num::ParseFloatError> for CliError { fn from(err: num::ParseFloatError) -> CliError { CliError::ParseFloat(err) } } |
给库作者的一些建议
如果我们需要在自己的库中报告自定义的错误,那可能就需要自定义错误类型。我们可以决定要不要暴露错误实现(类似
对于我们的自定义错误,都应当实现
此外,我们也需要为自定义错误类型提供
最后,我们也可以根据情况定义
案例研究:阅读人口数据的小程序
接下来,将构建一个命令行程序,用于查询指定地方的人口数据。目标很简单:接收输入的位置,输出关联的人口数据。看起来挺简单,不过还是有很多可能出错的地方!
下面我们需要使用的数据来自 数据科学工具箱,可以从 这里 获取世界人口数据,或者从 这里 获取美帝人口数据。
它在 GitHub 上
代码托管在 GitHub 上,如果已经安装好 了 Rust 和 Cargo,可以直接克隆下来运行:
1 2 3 4 | git clone git://github.com/BurntSushi/rust-error-handling-case-study cd rust-error-handling-case-study cargo build --release ./target/release/city-pop --help |
不过下面我们将一点点地编写好这个程序~
配置
首先创建新的项目:
1 2 3 4 5 6 7 8 9 10 11 12 | [package] name = "city-pop" version = "0.1.0" authors = ["Andrew Gallant <[email protected]>"] [[bin]] name = "city-pop" [dependencies] csv = "0.*" docopt = "0.*" rustc-serialize = "0.*" |
紧接着就可以编译运行了:
1 2 3 | cargo build --release ./target/release/city-pop #Outputs: Hello, world! |
参数解析
我们使用的命令行解析工具是 Docopt,它可以从使用帮助中生成合适的命令行解析器。完成命令行解析后,就可以将应用参数解码到相应的参数结构体上了。接下来看看下面的示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | extern crate docopt; extern crate rustc_serialize; static USAGE: &'static str = " Usage: city-pop [options] <data-path> <city> city-pop --help Options: -h, --help Show this usage message. "; struct Args { arg_data_path: String, arg_city: String, } fn main() { } |
好啦,开始撸代码吧。依据 Docopt 的文档,我们可以使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | // These use statements were added below the `extern` statements. // I'll elide them in the future. Don't worry! It's all on Github: // https://github.com/BurntSushi/rust-error-handling-case-study //use std::io::{self, Write}; //use std::process; //use docopt::Docopt; fn main() { let args: Args = match Docopt::new(USAGE) { Err(err) => { writeln!(&mut io::stderr(), "{}", err).unwrap(); process::exit(1); } Ok(dopt) => match dopt.decode() { Err(err) => { writeln!(&mut io::stderr(), "{}", err).unwrap(); process::exit(1); } Ok(args) => args, } }; } |
看起来还是不够简洁,一种可以改进的方式是编写一个宏,将消息打印到
1 2 3 4 5 6 7 | macro_rules! fatal { ($($tt:tt)*) => {{ use std::io::Write; writeln!(&mut ::std::io::stderr(), $($tt)*).unwrap(); ::std::process::exit(1) }} } |
此处使用
代码看起来好多了,不过还是有一处显式分支判断逻辑:
1 2 3 4 5 6 7 | let args: Args = match Docopt::new(USAGE) { Err(err) => fatal!("{}", err), Ok(dopt) => match dopt.decode() { Err(err) => fatal!("{}", err), Ok(args) => args, } }; |
谢天谢地,
1 2 3 | let args: Args = Docopt::new(USAGE) .and_then(|d| d.decode()) .unwrap_or_else(|err| err.exit()); |
编写业务逻辑
我们需要解析 csv 数据,并将符合条件的行打印出来即可。接下来看看怎么完成这个任务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | // This struct represents the data in each row of the CSV file. // Type based decoding absolves us of a lot of the nitty gritty error // handling, like parsing strings as integers or floats. struct Row { country: String, city: String, accent_city: String, region: String, // Not every row has data for the population, latitude or longitude! // So we express them as `Option` types, which admits the possibility of // absence. The CSV parser will fill in the correct value for us. population: Option<u64>, latitude: Option<f64>, longitude: Option<f64>, } fn main() { let args: Args = Docopt::new(USAGE) .and_then(|d| d.decode()) .unwrap_or_else(|err| err.exit()); let file = fs::File::open(args.arg_data_path).unwrap(); let mut rdr = csv::Reader::from_reader(file); for row in rdr.decode::<Row>() { let row = row.unwrap(); if row.city == args.arg_city { println!("{}, {}: {:?}", row.city, row.country, row.population.expect("population count")); } } } |
来来来,我们来梳理下有哪些错误(看看
-
fs::File::open 会返回io::Error ; -
csv::Reader::decode 每次解码一条记录,该操作会产生csv::Error (参见Iterator 实现中关联的Item 类型); - 如果
row.population 为None ,那么调用expect 会导致 panic。
那还有别的错误吗?万一城市不存在呢?类似于
接下来,我们来看看两种处理这些错误的方式。先从
使用 Box 处理错误
接下来开始我们的重构吧,首先需要把我们的业务逻辑抽到一个单独的函数中处理,依然保留
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | struct Row { // unchanged } struct PopulationCount { city: String, country: String, // This is no longer an `Option` because values of this type are only // constructed if they have a population count. count: u64, } fn search<P: AsRef<Path>>(file_path: P, city: &str) -> Vec<PopulationCount> { let mut found = vec![]; let file = fs::File::open(file_path).unwrap(); let mut rdr = csv::Reader::from_reader(file); for row in rdr.decode::<Row>() { let row = row.unwrap(); match row.population { None => { } // skip it Some(count) => if row.city == city { found.push(PopulationCount { city: row.city, country: row.country, count: count, }); }, } } found } fn main() { let args: Args = Docopt::new(USAGE) .and_then(|d| d.decode()) .unwrap_or_else(|err| err.exit()); for pop in search(&args.arg_data_path, &args.arg_city) { println!("{}, {}: {:?}", pop.city, pop.country, pop.count); } } |
虽然我们已经移除了一处
为了能够用合适的方式处理错误,需要对代码做如下几处修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | fn search<P: AsRef<Path>> (file_path: P, city: &str) -> Result<Vec<PopulationCount>, Box<Error+Send+Sync>> { let mut found = vec![]; let file = fs::File::open(file_path)?; let mut rdr = csv::Reader::from_reader(file); for row in rdr.decode::<Row>() { let row = row?; match row.population { None => { } // skip it Some(count) => if row.city == city { found.push(PopulationCount { city: row.city, country: row.country, count: count, }); }, } } if found.is_empty() { Err(From::from("No matching cities with a population were found.")) } else { Ok(found) } } |
这里我们使用
1 2 3 4 5 6 7 | / We are making use of this impl in the code above, since we call `From::from` // on a `&'static str`. impl<'a, 'b> From<&'b str> for Box<Error + Send + Sync + 'a> // But this is also useful when you need to allocate a new string for an // error message, usually with `format!`. impl From<String> for Box<Error + Send + Sync> |
现在我们已经知道怎么使用
从 stdin 中读取
为应用添加从
- 调整程序可以接收一个城市参数,而人口数据可以从
stdin 中读取; - 修改
search 函数,这样可以接收一个可选的路径,当为None 时,需要从stdin 中读取数据。
我们先来调整下命令行结构体和使用帮助:
1 2 3 4 5 6 7 8 9 10 11 12 | static USAGE: &'static str = " Usage: city-pop [options] [<data-path>] <city> city-pop --help Options: -h, --help Show this usage message. "; struct Args { arg_data_path: Option<String>, arg_city: String, } |
1 2 3 4 5 6 7 8 9 10 11 | fn search<P: AsRef<Path>> (file_path: &Option<P>, city: &str) -> Result<Vec<PopulationCount>, Box<Error+Send+Sync>> { let mut found = vec![]; let input: Box<io::Read> = match *file_path { None => Box::new(io::stdin()), Some(ref file_path) => Box::new(fs::File::open(file_path)?), }; let mut rdr = csv::Reader::from_reader(input); // The rest remains unchanged! } |
使用自定义错误类型
根据前面的分析,有三种类型的错误,所以我们的自定义错误类型如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | enum CliError { Io(io::Error), Csv(csv::Error), NotFound, } impl fmt::Display for CliError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { CliError::Io(ref err) => err.fmt(f), CliError::Csv(ref err) => err.fmt(f), CliError::NotFound => write!(f, "No matching cities with a \ population were found."), } } } impl Error for CliError { fn description(&self) -> &str { match *self { CliError::Io(ref err) => err.description(), CliError::Csv(ref err) => err.description(), CliError::NotFound => "not found", } } } |
在我们正式使用
1 2 3 4 5 6 7 8 9 10 11 | impl From<io::Error> for CliError { fn from(err: io::Error) -> CliError { CliError::Io(err) } } impl From<csv::Error> for CliError { fn from(err: csv::Error) -> CliError { CliError::Csv(err) } } |
接下来完成
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | fn search<P: AsRef<Path>> (file_path: &Option<P>, city: &str) -> Result<Vec<PopulationCount>, CliError> { let mut found = vec![]; let input: Box<io::Read> = match *file_path { None => Box::new(io::stdin()), Some(ref file_path) => Box::new(fs::File::open(file_path)?), }; let mut rdr = csv::Reader::from_reader(input); for row in rdr.decode::<Row>() { let row = row?; match row.population { None => { } // skip it Some(count) => if row.city == city { found.push(PopulationCount { city: row.city, country: row.country, count: count, }); }, } } if found.is_empty() { Err(CliError::NotFound) } else { Ok(found) } } |
独创案例研究:新冠数据查询
2020 年的新冠肺炎对人类社会来说,是一场突如其来的打击。接下来我们写一个简单的命令行程序,用来根据指定的国家返回感染人数、死亡人数等数据。可以在这个仓库查看完整源码。
数据是在 2020 年 4 月 22 日从知乎上获得的,整理成了 csv 文件,可以在 此处 查看。
这里我们使用了的命令行处理工具是
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | use structopt::StructOpt; /// Opt collects the command line arguments #[derive(Debug, StructOpt)] #[structopt(name = env!("CARGO_PKG_NAME"))] #[structopt(version = env!("CARGO_PKG_VERSION"))] #[structopt(about = env!("CARGO_PKG_DESCRIPTION"))] #[structopt(author = env!("CARGO_PKG_AUTHORS"))] struct Opt { /// Query data of which country #[structopt(value_name = "COUNTRY")] country: String, /// Input data file #[structopt(long, short, parse(from_os_str), value_name = "DATA_PATH")] data_path: Option<PathBuf>, /// Don't show noisy messages #[structopt(long, short)] quiet: bool, } |
紧接着是我们需要根据 csv 文件编写用于表达记录的结构体,具体代码如下:
1 2 3 4 5 6 7 8 9 10 | /// Record represents a row in the target csv file #[derive(Debug, Deserialize)] struct Record { country: String, number_of_newly_diagnosis: u32, number_of_cumulative_diagnosis: u32, number_of_current_diagnosis: u32, number_of_deaths: u32, number_of_cures: u32, } |
错误类型的话,在上面的示例也做过分析,基本类似,所以这里直接给出自定义错误类型如下,不过为了避免手动实现
1 2 3 4 5 6 7 8 9 | #[derive(Error, Debug)] enum CliError { #[error(transparent)] Io(#[from] io::Error), #[error(transparent)] Csv(#[from] csv::Error), #[error("no matching record found")] NotFound, } |
然后是我们的核心业务逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | fn search<P: AsRef<Path>>(input: &Option<P>, country: &str) -> Result<Record, CliError> { let input: Box<dyn io::Read> = match input { None => Box::new(io::stdin()), Some(p) => Box::new(fs::File::open(p)?), }; let mut rdr = csv::Reader::from_reader(input); for r in rdr.deserialize() { let record: Record = r?; if record.country == country { return Ok(record); } } Err(CliError::NotFound) } |
最后在
1 2 3 4 5 6 7 8 9 10 | fn main() { let opt = Opt::from_args(); match search(&opt.data_path.map(|x| x.as_path().to_owned()), &opt.country) { Ok(r) => println!("{:?}", r), Err(e) => { println!("{}", e); process::exit(1); } } } |
代码写完后,我们可以通过运行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | covid 0.1.0 0xE8551CCB <[email protected]> A handful cli to query covid-19 infections in the world. USAGE: covid [FLAGS] [OPTIONS] <COUNTRY> FLAGS: -h, --help Prints help information -q, --quiet Don't show noisy messages -V, --version Prints version information OPTIONS: -d, --data-path <DATA_PATH> Input data file ARGS: <COUNTRY> Query data of which country |
使用示例如下:
1 2 3 4 5 | $ cargo run -- -d=assets/covid-19-infections-20200422.csv 美国 Record { country: "美国", number_of_newly_diagnosis: 36386, number_of_cumulative_diagnosis: 825306, number_of_current_diagnosis: 704558, number_of_deaths: 45075, number_of_cures: 75673 } $ cargo run -- -d=assets/covid-19-infections-20200422.csv 不存在 no matching record found |
总结
好啦,终于翻译完了,内容很多,不过我们只要把握好主线即可快速掌握作者想要给我们传达的信息。
原文
- Error Handling in Rust
声明
- 本文链接: https://ifaceless.github.io/2020/06/02/rust-error-handling
- 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!