Rust & Python & Go 错误处理对比

引言

很多编程语言中都有特定的错误处理方式,大体上来说,主要分为如下两类:

  1. 异常机制 try-catch(代表语言:Java/JavaScript/Python 等);
  2. 返回错误 return-error(Go/Rust 等)。

由于我们主要使用的语言是 Python 和 Go,所以上述两种错误方式都有一定的实践经验,就不多赘述了。今天主要来看看 Rust 中错误处理方式,学习另外一种优雅简洁的错误处理思路。在 Rust 中,我们主要还是依靠标准库提供的一些组合子、宏以及 ? 操作符来逐步减少样板代码的,从而让代码更加紧凑,我们只需要关注核心业务逻辑即可,让错误的传递更加轻松简单。当然,讲得不一定对,仅供参考。

在开始之前,我们先来回顾下以上两种错误处理方式的优缺点,做个简单的对比:

image.png

在 Rust 中,提供了如下几种方式来处理错误或者异常:

image.png

吐槽

Go 目前作为我们的主力语言,其中不乏各种错误处理的场景。来来来,我们先看看 Go 里面错误处理的方式,随便找个项目截取了一段代码:

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
func BatchGetInstabookResrouce(ctx context.Context, businessIDs []int64) (map[int64]*Resource, error) {
instabookManuscriptsMap, err := rpcs.NewRemixServiceClient().BatchGetInstabookManuscriptById(ctx, businessIDs)
if err != nil {
return nil, err
}

playInfoMap, err := rpcs.NewRemixServiceClient().BatchInstabookPlayInfo(ctx, businessIDs)
if err != nil {
return nil, err
}

res := make(map[int64]*Resource, 0)
for businessID, playInfo := range playInfoMap {
resource, err := RenderInstabookResource(ctx, playInfo.Audio)
if err != nil {
return nil, err
}
resource.Audio.Url = playInfo.Audio.GetURL()
resource.Audio.AuditionDuration = playInfo.Audio.GetDuration()
manuscript := instabookManuscriptsMap[businessID]
resource.Manuscript = &manuscript
res[businessID] = resource
}
return res, nil
}

还有这个截图,超过 100+ 处写了 if err != nil 这样的判断:

image.png

其实非常啰嗦,尤其是很多函数都可能返回错误,并且还希望把底层错误传递给调用方的情况下,我们就不得不写很多这样的判断。当然,我们知道社区的一些 Go 项目中通过 panic + 上层捕获的方式避免错误层层传递,但是这种做法也不太符合 Go 官方推荐的错误处理方式,且频繁 panic 的开销也不可忽略。

Option

在 Rust 中,我们通过 Option<T> 表达存在性,如果查找的内容存在,则会返回 Some(T),否则返回 None。调用方必须通过 unwrap 或者利用组合子或者是显式条件判断的方式获取想要的结果。

其中关于 Option<T> 的定义如下:

1
2
3
4
enum Option<T> {
None,
Some(T),
}

使用示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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
}


fn extension(file_name: &str) -> Option<&str> {
match find(file_name, '.') {
None => None,
Some(i) => Some(&file_name[i+1..]),
}
}

接下来看如何使用上述函数实现一些功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn main_find() {
// 可以用来查找文件后缀名
// 1. 使用的是显式判断的方式
let file_name = "foobar.rs";
let res = extension(file_name, '.');

// 2. 使用 `unwrap` 获取,不过没有的话,会发生 panic
println!("File extension: {}", res.unwrap());


// 3. 使用 `unwrap_or*` 可以在没有时给个默认值
println!("File extension: {}", res.unwrap_or_default()); // 默认为 "", 需要类型 T 实现 `default::Default` trait
println!("File extension: {}", res.unwrap_or("rs")); // 默认为 "rs"
println!("File extension: {}", res.unwrap_or_else(|| "rs")); // 为 None 会执行闭包,返回默认值 "rs"
}

当然,我们看到上面的 extension 函数在实现时,使用了显式的条件判断,但其实我们还可以利用组合子(combinators)来简化,比如 mapand_then,示例如下:

1
2
3
4
5
6
7
fn extension_map_impl(file_name: &str) -> Option<&str> {
find(file_name, '.').map(|i| &file_name[i+1..])
}

fn extension_and_then_impl(file_name: &str) -> Option<&str> {
find(file_name, '.').and_then(|i| Some(&file_name[i+1..]))
}

当然,关于 mapand_then 实现也很简单。除了这两个外,还有很多其它的组合子,熟练使用它们可以在一定程度上帮我们减少一些冗余编码,让代码更加紧凑。

1
2
3
4
5
6
7
8
9
10
11
12
13
pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Option<U> {
match self {
Some(x) => Some(f(x)),
None => None,
}
}

pub fn and_then<U, F: FnOnce(T) -> Option<U>>(self, f: F) -> Option<U> {
match self {
Some(x) => f(x),
None => None,
}
}

Result

函数调用的结果无非就是成功 or 失败。当然,如果我们不关心失败的原因的话,可以使用 Option<T> 作为返回结果。但在很多场景下,我们还要告诉调用方,失败的原因是什么,这时就需要给调用方返回具体的错误了。所以 Result<T> 就是为这种场景而生。它的定义如下:

1
2
3
4
enum Result<T, E> {
Ok(T),
Err(E),
}

简单的使用示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
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),
}
}

当然,和 Option<T> 类似,Result<T> 也定义了很多关联的组合子,以及 unwrap 等方法,目的也是为了减少显式错误判断代码片段。这里就不赘述了,感兴趣可以阅读下参考文章。

案例探究

有了基本概念后,接下来我们将以一个小小的案例,来对比 Python/Go/Rust 三种语言的实现方式,当然,最终我们要关注的是它们关于错误(或者异常)处理方面的区别。

这个小任务的目标很简单,就是编写一个小函数,输入指定的国家,返回对应的新冠肺炎感染数据。为了简单起见,收集了 2020-04-20 的数据保存在了 csv 文件中(参见此处)。

关注核心业务逻辑

我们先实现核心业务逻辑,把错误处理的问题放一放,后面再完善。

Python

1
2
3
4
5
6
7
8
9
10
11
def main():
print(search(csv_filename, '美国'))


def search(path, country):
reader = csv.DictReader(open(path))
for row in reader:
if row['country'] == country:
return row

return None

Go

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
func main() {
r := search(filename, "美国")
fmt.Println(r)
}

type Record struct {
Country string `csv:"country"`
NumberOfNewlyDiagnosis int `csv:"number_of_newly_diagnosis"`
NumberOfCumulativeDiagnosis int `csv:"number_of_cumulative_diagnosis"`
NumberOfCurrentDiagnosis int `csv:"number_of_current_diagnosis"`
NumberOfDeaths int `csv:"number_of_deaths"`
NumberOfCures int `csv:"number_of_cures"`
}

func (r *Record) String() string {
return fmt.Sprintf("Record(Country='%s', NumberOfNewlyDiagnosis=%d, NumberOfCumulativeDiagnosis=%d, NumberOfCurrentDiagnosis=%d, NumberOfDeaths=%d, NumberOfCures=%d)", r.Country, r.NumberOfNewlyDiagnosis, r.NumberOfCumulativeDiagnosis, r.NumberOfCurrentDiagnosis, r.NumberOfDeaths, r.NumberOfCures)
}

func search(path, country string) *Record {
file, err := os.Open(path)
if err != nil {
panic(err)
}

defer file.Close()

var records []*Record
err = gocsv.UnmarshalFile(file, &records)
if err != nil {
panic(err)
}

for _, rec := range records {
if rec.Country == country {
return rec
}
}
return nil
}

Rust

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
/// 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,
}

fn main() {
let r = search(FILEPATH, "美国").unwrap();
println!("{:?}", r);
}

fn search<P: AsRef<Path>>(filepath: P, country: &str) -> Option<Record> {
let input= fs::File::open(filepath).unwrap();
let mut rdr = csv::Reader::from_reader(input);
for r in rdr.deserialize() {
let record: Record = r.unwrap();
if record.country == country {
return Some(record);
}
}

None
}

完善错误处理

想让我们的程序更加健壮,就必须要处理可能出现的异常或者错误。

Python

在上面的 Python 的实现中,在 open 文件时,就可能出现相关异常。我们在实践中,可以在调用的地方使用 try...except 捕获异常即可。修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def main():
try:
print(search(csv_filename, '美国'))
except FileNotFoundError as e:
print(e)


def search(path, country):
reader = csv.DictReader(open(path))
for row in reader:
if row['country'] == country:
return row

return None

Go

而对于 Go 的示例,则有两处可能出现报错,即打开文件和反序列化 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
34
35
func main() {
r, err := search(filename, "美国")
if err != nil {
if err == ErrNotFound {
fmt.Println("not found error")
}
fmt.Println(err)
} else {
fmt.Println(r)
}
}

var ErrNotFound = errors.New("record not found")

func search(path, country string) (*Record, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}

defer file.Close()

var records []*Record
err = gocsv.UnmarshalFile(file, &records)
if err != nil {
return nil, err
}

for _, rec := range records {
if rec.Country == country {
return rec, nil
}
}
return nil, ErrNotFound
}

显然,对于 Go 而言,只能做到这一步了。但依然有一些问题:

  1. 有很多样板代码:if err != nil,这在一定程度上影响了代码的可读性;
  2. 容易丢失底层错误类型,且排查错误时,很难得到错误链,需要借助第三方包实现;
  3. 错误类型判断不够优雅。

Rust

我们先看看用 Rust 模拟下 Go 的错误处理方式。

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
fn main() {
match search(FILEPATH, "美国") {
Ok(r) => {
println!("{:?}", r);
},
Err(e) => {
eprintln!("{}", e);
}
};
}

fn search<P: AsRef<Path>>(filepath: P, country: &str) -> Result<Record, String> {
let input= match fs::File::open(filepath) {
Ok(f) => f,
Err(e) => return Err(format!("{}", e)),
};

let mut rdr = csv::Reader::from_reader(input);
for r in rdr.deserialize() {
let record: Record = match r {
Ok(r) => r,
Err(e) => return Err(format!("{}", e))
};
if record.country == country {
return Ok(record);
}
}

Err("record not found".to_owned())
}

显然,还是不够优雅,且会丢失底层的错误类型,导致我们在 main 函数中只能拿到错误消息。因此,有必要自定义错误类型,示例如下:

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
fn search<P: AsRef<Path>>(filepath: P, country: &str) -> Result<Record, CliError> {
let input= fs::File::open(filepath)?;
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)
}

怎么样,看起来是不是简洁了不少,并且 main 函数中可以根据 CliError 的枚举项判断具体的错误类型,从而执行不同的操作。不过这里的 ? 操作符是什么鬼?可以把它看成 Rust 错误处理的语法糖,它的实现原理类似这样:

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. 自动调用 From::from 完成错误类型的转换。

完整的项目参见:covid

0%