引言
很多编程语言中都有特定的错误处理方式,大体上来说,主要分为如下两类:
- 异常机制 try-catch(代表语言:Java/JavaScript/Python 等);
- 返回错误 return-error(Go/Rust 等)。
由于我们主要使用的语言是 Python 和 Go,所以上述两种错误方式都有一定的实践经验,就不多赘述了。今天主要来看看 Rust 中错误处理方式,学习另外一种优雅简洁的错误处理思路。在 Rust 中,我们主要还是依靠标准库提供的一些组合子、宏以及 ?
操作符来逐步减少样板代码的,从而让代码更加紧凑,我们只需要关注核心业务逻辑即可,让错误的传递更加轻松简单。当然,讲得不一定对,仅供参考。
在开始之前,我们先来回顾下以上两种错误处理方式的优缺点,做个简单的对比:
在 Rust 中,提供了如下几种方式来处理错误或者异常:
吐槽
Go 目前作为我们的主力语言,其中不乏各种错误处理的场景。来来来,我们先看看 Go 里面错误处理的方式,随便找个项目截取了一段代码:
1 | func BatchGetInstabookResrouce(ctx context.Context, businessIDs []int64) (map[int64]*Resource, error) { |
还有这个截图,超过 100+ 处写了 if err != nil
这样的判断:
其实非常啰嗦,尤其是很多函数都可能返回错误,并且还希望把底层错误传递给调用方的情况下,我们就不得不写很多这样的判断。当然,我们知道社区的一些 Go 项目中通过 panic + 上层捕获的方式避免错误层层传递,但是这种做法也不太符合 Go 官方推荐的错误处理方式,且频繁 panic 的开销也不可忽略。
Option
在 Rust 中,我们通过 Option<T>
表达存在性,如果查找的内容存在,则会返回 Some(T)
,否则返回 None
。调用方必须通过 unwrap
或者利用组合子或者是显式条件判断的方式获取想要的结果。
其中关于 Option<T>
的定义如下:
1 | enum Option<T> { |
使用示例如下:
1 | // Searches `haystack` for the Unicode character `needle`. If one is found, the |
接下来看如何使用上述函数实现一些功能:
1 | fn main_find() { |
当然,我们看到上面的 extension 函数在实现时,使用了显式的条件判断,但其实我们还可以利用组合子(combinators)来简化,比如 map
和 and_then
,示例如下:
1 | fn extension_map_impl(file_name: &str) -> Option<&str> { |
当然,关于 map
和 and_then
实现也很简单。除了这两个外,还有很多其它的组合子,熟练使用它们可以在一定程度上帮我们减少一些冗余编码,让代码更加紧凑。
1 | pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Option<U> { |
Result
函数调用的结果无非就是成功 or 失败。当然,如果我们不关心失败的原因的话,可以使用 Option<T>
作为返回结果。但在很多场景下,我们还要告诉调用方,失败的原因是什么,这时就需要给调用方返回具体的错误了。所以 Result<T>
就是为这种场景而生。它的定义如下:
1 | enum Result<T, E> { |
简单的使用示例如下:
1 | fn double_number(number_str: &str) -> Result<i32, ParseIntError> { |
当然,和 Option<T>
类似,Result<T>
也定义了很多关联的组合子,以及 unwrap
等方法,目的也是为了减少显式错误判断代码片段。这里就不赘述了,感兴趣可以阅读下参考文章。
案例探究
有了基本概念后,接下来我们将以一个小小的案例,来对比 Python/Go/Rust 三种语言的实现方式,当然,最终我们要关注的是它们关于错误(或者异常)处理方面的区别。
这个小任务的目标很简单,就是编写一个小函数,输入指定的国家,返回对应的新冠肺炎感染数据。为了简单起见,收集了 2020-04-20 的数据保存在了 csv 文件中(参见此处)。
关注核心业务逻辑
我们先实现核心业务逻辑,把错误处理的问题放一放,后面再完善。
Python
1 | def main(): |
Go
1 | func main() { |
Rust
1 | /// Record represents a row in the target csv file |
完善错误处理
想让我们的程序更加健壮,就必须要处理可能出现的异常或者错误。
Python
在上面的 Python 的实现中,在 open
文件时,就可能出现相关异常。我们在实践中,可以在调用的地方使用 try...except
捕获异常即可。修改如下:
1 | def main(): |
Go
而对于 Go 的示例,则有两处可能出现报错,即打开文件和反序列化 csv 文件时分别对应两种类型的错误。此外,我们还有必要定义一个业务错误,反馈没有找到结果。改造后如下:
1 | func main() { |
显然,对于 Go 而言,只能做到这一步了。但依然有一些问题:
- 有很多样板代码:
if err != nil
,这在一定程度上影响了代码的可读性; - 容易丢失底层错误类型,且排查错误时,很难得到错误链,需要借助第三方包实现;
- 错误类型判断不够优雅。
Rust
我们先看看用 Rust 模拟下 Go 的错误处理方式。
1 | fn main() { |
显然,还是不够优雅,且会丢失底层的错误类型,导致我们在 main 函数中只能拿到错误消息。因此,有必要自定义错误类型,示例如下:
1 |
|
然后使用 ?
代替显式的错误判断逻辑,重构后如下:
1 | fn search<P: AsRef<Path>>(filepath: P, country: &str) -> Result<Record, CliError> { |
怎么样,看起来是不是简洁了不少,并且 main 函数中可以根据 CliError
的枚举项判断具体的错误类型,从而执行不同的操作。不过这里的 ?
操作符是什么鬼?可以把它看成 Rust 错误处理的语法糖,它的实现原理类似这样:
1 | match ::std::ops::Try::into_result(x) { |
做了两件事情:
- 抽象了错误判断逻辑,遇到错误时提前返回;
- 自动调用
From::from
完成错误类型的转换。
完整的项目参见:covid。