Rust-2.错误处理

程序运行中,在执行一些操作时会出现问题,我们需要对这些问题进行相应的处理,这就是错误处理,错误处理可以有很多不同的方式和风格,下面来介绍一下Rust的错误处理。

Rust的错误处理,严格来说算不上是一个专门独立的特性,而是某些特性带来的一种用法,但是其实用价值我觉得足以单独拿出来讲一讲。原计划这一篇会在这个系列比较靠后的地方出现,等前置的知识讲完,可以把这里的原理讲清楚之后。但是出于某些原因,我打算提前讲一讲。

我们熟悉的错误处理

首先我们来看一些C里面的错误处理。

很多时候,我们使用返回值来判断一个函数的执行是否成功

比如

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdlib.h>
int main() {
void *ptr = malloc(1024);
if (ptr == NULL) {
/* code */
}
else {
/* code */
free(ptr);
}
return 0;
}

或者使用Linux系统调用的时候

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <unistd.h>
#include <errno.h>
int main() {
char data[128];
if (read(0, data, 128) < 0) {
write(2, "Error when read from standard input: %s\n", stderr(errno));
// or perror("Error when read from standard input");
}
else {
/* code */
}
return 0;
}

代码冗长

有个很明显的问题,为了可靠地处理错误,必须在每个可能出错的操作后面加上错误的判断和处理,这就会造成代码中混入大量的错误处理。带来很多可读性上的问题:比如在阅读时,大量的无关逻辑会打断我们对主要逻辑的理解。

不能保证被处理

同时,正是因为代码的冗长,实际编程中我们经常在一些出错概率极小的操作后面省略错误处理,假设每次都能成功,然而这就埋下了很多隐患。虽然绝大多数情况没有问题,但是一但出现,那问题就是问题,不会因为其概率低而减轻影响。

我们没有完全处理每一个错误,可以分两种情况讨论。

一是使用同一个变量来同时代表错误和正常的返回值。这是一种非常容易混淆的代码,非常容易导致我们忘记检查,直接把表示错误的值当作正常的值处理。比如第一段代码,如果我们不经检查直接把NULL拿来使用的话,我们就能见到大名鼎鼎的段错误了;第二段代码,假如我们需要获取读取的长度,并且没有检查它十分合法,那就是把一个负数当长度使用,最后造成的问题也是非常诡异且难排查的。

一是有些时候,真正要使用的值和错误是分开的两个变量,这种情况要好些,但仍然存在问题。比如第二段代码,如果我们需要使用的只有读取到的data,不用管返回的长度,那没有检查是否成功,data中的数据也不一定是我们所期望的,可能是未初始化的乱码,或者别的什么,直接拿来使用也会造成问题。

风格不统一

函数的编写者可以“自由”地决定使用返回值的风格,如何表示错误。使用的时候我们必要对这个接口有充分的了解,适应不同的风格。比如上面的malloc返回申请到的内存地址,NULL表示错误;read返回读取到数据的实际长度,使用-1表示错误。碰到一个不熟悉的函数,我们为了写出可靠的代码,都需要去仔细了解其接口。比如Linux的man page,不仔细阅读,里面有很多容易忽视的细节。

同时不统一的风格也阻碍我们作出抽象,减少冗余的代码。

“全局”的errno

另外还有Linux的errno,大概是因为返回值太自由了可以各搞各的。所以他们设计一个类似与全局变量的errno(严格来说是每个线程各有一个,相互独立,但是在同线程内是全局的),来统一错误的处理,执行系统调用失败后,除了返回值告诉你失败了之外,还会设置一个errno变量指出具体的错误类型,然后可以通过stderr拿到解释对应错误的字符串,或者通过perror直接打印出来。

但是errno只能表示最近一次的错误,如果有一个错误导致另一个错误的这种错误链,errno是无法表达的。而且任何库和用户代码都能自由修改errno,除了直接调用系统调用可以勉强相信有代码质量和规范的保障之外,你无法保证拿到的errno的值一定是有意义的。而且可读性上来讲,可以用返回值,局部变量解决的事情就尽量避免全局变量,它不能在小范围的上下文中迅速看出来龙去脉,是突然冒出来的东西,会给不了解的人带来很多困惑。

回到正题

看了上面之后,我们希望的错误处理是什么样的呢?

  • 风格统一,不易错误使用
  • 写起来方便简洁

还有一些细节的注意点:

  • 不要把错误储存在全局变量里
  • 不用用一种类型的不同区间表示两种不同的东西
  • 最好也不要把返回值和错误分成两个独立的变量

Union Type

细节的后两点看起来是矛盾的,我们既不能用一种类型同时表示正常返回值和错误,又不能把它们分开成两个变量返回,那要怎么做?

这就可以说到一种在程序语言学里面被成为Union Type的特性。这就是我们要讲到的Rust的错误处理的核心。Rust里面可能出错的操作的返回值一般为如下的Result类型。

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

以上的类型声明表示,Result类型下分两个子类型,Ok代表正常的返回值,Err表示返回错误。

同时这是一个泛型结构,虽然我们还没来得及讲到Rust的泛型,但是这种常见的特性大家应该都是了解的。这里是一个统一的接口,TE可以被替换成各种类型,比如File::open函数的返回值类型就是Result<std::fs::File, std::io::Error>,可能返回一个打开的文件对象,或者一个IO错误。

这种在学术界被叫做Union Type,在Rust里面叫enum的东西,其实和C里面的unionenum都不同。

C里面的union表示多个类型共享一块内存,可以对这块内存使用多种解读方式,具体按哪种类型解析还是需要程序员自己理解上下文来正确使用。

如果用C来模拟Rust里面的enum,大概长是这样的

1
2
3
4
5
6
7
8
9
10
11
12
enum type_result {
OK,
ERR,
}
struct result_open {
enum type_result type;
union {
File *ok;
int err;
};
}

实际使用的时候,我们需要做如下判断

1
2
3
4
5
6
7
8
9
switch (result.type) {
case OK:
/* do something with result.ok */
/* but result.err can be used when it shouldn't be */
break;
case ERR:
/* do something with result.err */
/* but result.type can be used when it shouldn't be */
}

而在Rust里面,写起来更自然一点。而且语法上限制了,如果它是错误,你就不能把它当正常的返回值解释,反之亦然。

1
2
3
4
5
6
7
8
match result {
Ok(file) => {
/* do something with file */
},
Err(e) => {
/* do something with e */
},
}

更深入本质一点讲,这种接口把判断操作成功和取得返回值两个操作合为一体,杜绝了没有判断操作是否成功就直接把返回值拿来用的情况。

如果想看偏原理一点的,到这里就结束了,我们已经保证了错误一定会被处理。然而,如果关心错误处理的实际操作,想看如何简洁方便地处理错误,目前为止都还是铺垫,下面才是真正的好戏。

if let语法糖

上面的match语句在大多数时候写起来还是太长了。所以Rust从Swfit那边学过来一种语法,作为match的语法糖。

1
2
3
if let Ok(file) = result {
/* do something with file */
}

等价于

1
2
3
4
5
6
match result {
Ok(file) => {
/* do something with file */
},
Err(_) => {},
}

或者

1
2
3
4
5
6
if let Ok(file) = result {
/* do something with file */
}
else {
/* do something without e */
}

等价于

1
2
3
4
5
6
7
8
match result {
Ok(file) => {
/* do something with file */
},
Err(_) => {
/* do something without e */
},
}

前面一种如果操作失败,则什么都不用做。后面一种拿不到具体代表错误的那个值,只能对所有的错误情况做统一处理,但是写法比原先简洁了些。不过还是不要急,后面有更好的。

?操作符

终于来到了本篇的关键。Rust中基于Result,又搞出了一个?操作符,。用于一种非常常见的场景,我们不马上对错误进行处理,而是直接把错误返回上一层。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use std::fs::File;
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
fn copy_file<P: AsRef<Path>>(path_src: P, path_dst: P) -> std::io::Result<()> {
let file_src = File::open(path_src)?;
let mut reader = BufReader::new(file_src);
let mut file_dst = File::create(path_dst)?;
loop {
let content = reader.fill_buf()?;
if content.is_empty() {
break Ok(());
}
file_dst.write(&content)?;
}
}
fn main() {
copy_file("/tmp/src", "/tmp/dst")
.unwrap_or_else(|e| eprintln!("{}", e));
}

这里举例用Rust实现了一个复制文件的函数。main函数尝试调用这个函数,失败则打印错误信息。

我们主要关注copy_file函数,可以看到其中有4个?,这表示代码中有四个位置可能失败,打开输入文件,创建输出文件,读文件,写文件。这四个操作的返回类型都是Result<T, E>,可能为子类型Ok(T)或者Err(E),如果其中任何一个操作失败,即返回类型为Err(E),函数会立刻将Err(E)返回上一层;否则Ok(T)中的T会被取出,函数继续往下运行。

这里如果某个操作出现错误,则直接将错误返回上一级这个操作,直接被简化为一个?。我们可以说,没有任何无关代码,来干扰我对主要逻辑的理解,这一目标已经几乎完美地实现了。

大家可以想象一下,这一段如果用C来写,并且要求可靠地将所有错误都处理了,需要往里面插入多少额外代码。

总结

先讲到这里,其实还有不少可以讲,比如

  • Result还可以使用函数式风格的Monad处理错误
  • 即使这样的接口仍然需要注意的问题
  • 定义自己的错误类型以及?操作符的进一步扩展

这些内容可能会更新在本篇或者另开几篇继续说明。