Rust 中的错误处理

13 min read

异常/错误处理 1 被称作是**“第四流程控制语句”**,在现代程序设计中发挥着越来越重要的作用。一般的语言喜欢使用 try...catch... 语句捕捉异常,这样做看似很符合逻辑,却很难将代码写得优雅:如果在异常抛出处处理,容易破坏业务逻辑的完整性,不优雅;如果将所有的异常放在一起统一处理,又容易出现忘记处理的情况。所以如何处理异常,何时处理异常是一件非常考验程序员经验的事情。但 Rust 就不同了。

Rust 中的错误处理方式是我见过最优雅的。Rust 中并没有异常,而主要是使用一种名为 Result (实际上是一种枚举) 的概念处理错误。它的好处就在于不会影响业务逻辑,因为它本身就是业务逻辑的一部分!

Rust 中,有两种错误:

  • 可恢复错误 (recoverable)
  • 不可恢复错误 (unrecoverable)

大部分语言不区分这两类错误,并使用相似的逻辑处理他们。Rust 使用 Resultpanic! 处理可恢复错误,在遇到不可恢复错误时直接使用 panic! 停止程序执行。

Rust 中的错误类型

先来解释一下 panic!

1 异常错误是有区别的,这里只比较他们的相似性,不多做区分。

panic!

panic! 处理错误时会采用展开 (unwinding) 和终止 (abort) 两种策略处理栈上的数据。

展开的意思就是说 Rust 会回溯栈并一层一层的清理它遇到的每一个函数的数据,就像这样:

    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', /checkout/src/liballoc/vec.rs:1555:10
stack backtrace:
   0: std::sys::imp::backtrace::tracing::imp::unwind_backtrace
             at /checkout/src/libstd/sys/unix/backtrace/tracing/gcc_s.rs:49
   1: std::sys_common::backtrace::_print
             at /checkout/src/libstd/sys_common/backtrace.rs:71
   2: std::panicking::default_hook::{{closure}}
             at /checkout/src/libstd/sys_common/backtrace.rs:60
             at /checkout/src/libstd/panicking.rs:381
   3: std::panicking::default_hook
             at /checkout/src/libstd/panicking.rs:397
   4: std::panicking::rust_panic_with_hook
             at /checkout/src/libstd/panicking.rs:611
   5: std::panicking::begin_panic
             at /checkout/src/libstd/panicking.rs:572
   6: std::panicking::begin_panic_fmt
             at /checkout/src/libstd/panicking.rs:522
   7: rust_begin_unwind
             at /checkout/src/libstd/panicking.rs:498
   8: core::panicking::panic_fmt
             at /checkout/src/libcore/panicking.rs:71
   9: core::panicking::panic_bounds_check
             at /checkout/src/libcore/panicking.rs:58
  10: <alloc::vec::Vec<T> as core::ops::index::Index<usize>>::index
             at /checkout/src/liballoc/vec.rs:1555
  11: panic::main
             at src/main.rs:4
  12: __rust_maybe_catch_panic
             at /checkout/src/libpanic_unwind/lib.rs:99
  13: std::rt::lang_start
             at /checkout/src/libstd/panicking.rs:459
             at /checkout/src/libstd/panic.rs:361
             at /checkout/src/libstd/rt.rs:61
  14: main
  15: __libc_start_main
  16: <unknown>

是不是很像异常?Rust 默认使用展开的策略清理内存,不过展开堆栈需要额外记录堆栈信息,这会对二进制程序的大小以及执行效率 (微乎其微) 产生影响。

另一种选择是直接终止,这会不清理数据就直接退出。剩下的一堆烂摊子扔给操作系统来处理。

展开堆栈时可以用 std::panic::catch_unwind 捕获 panic 抛出的堆栈,但是极不推荐用这种方法来处理错误。catch_unwind 一般是用来在多线程程序里面在将挂掉的线程 catch 住,防止一个线程挂掉导致整个进程崩掉,或者是通过外部函数接口 (FFI) 与 C 交互时将堆栈信息兜住防止 C 程序看到堆栈不知道如何处理。另外并不是所有程序都能用 catch_unwind 捕捉,有的嵌入式平台上的程序受限于二进制文件大小的限制,panic 没有使用展开,而是使用终止的方式退出程序,这就没法兜得住了。

Result

就下来介绍一种更常用也是我最喜欢的一种错误处理方式:Result 类型。

Result 的本质是一个枚举类型。首先我们需要了解一下什么是枚举。

Result 与枚举

枚举是 Rust 特有的一种类型,与 F#、OCaml 和 Haskell 这样的函数式编程语言中的代数数据类型 (algebraic data types) 最为相似。

Result 是 Rust 自带的一个已经定义好了的枚举。Result 中定义了两个成员:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

OK 表示成功的情况,Err 表示出错的情况 (TE 分别表示 OKErr 中数据的类型)。如果你调用了一个可能会出错的函数,那么函数将会返回一个 Result 枚举。一个枚举实例表示枚举类型定义的成员中的任意一项,反映到 Result 上就是 OKErr。也就是说函数返回的 Result 既可能是一个包含 OKResult,也可能是一个包含 ErrResult,这有点类似于“薛定谔的猫”——处于生与死的叠加态——可以说 Result 处于 OKErr 的“叠加态”。

那么这又有什么好处呢?

Result 的优越性就在于:无论程序出错与否,**函数返回的值的类型始相同的!**函数的调用者可以采用相同的方式行云流水般的处理 Result。也就是说,无论成功与否,函数的调用者都要处理 Result,错误处理成为了业务逻辑的一部分。

处理错误

我们已经成功的获得了从调用的函数手里返回的 Result,我们并不知道执行到底有没有成功。这时我们就应该开始处理错误了。

unwrap 与 expect

我们先看这么一个例子:

use std::fs::File;

fn main() {
    let f1 = File::open("hello.txt").unwrap();
    let f2 = File::open("hello.txt").expect("Failed to open hello.txt");
}

代码中,我们使用 File::open 来读打开一个名为 hello.txt 的文件。函数 File::open 将会返回一个 io::Result。Rust 标准库中有很多叫做 Result 的类型,io::Result 是其中之一,并且 io::Result 的实例拥有 unwrap 方法和 expect 方法。 如果程序执行成功 unwrapexpect 方法会将正确的值取出来,如果出错就直接让程序挂掉。其中 unwrap 方法在挂掉时会打印出标准库内置的错误信息,而 exprct 则让我们可以自己定义一个字符串在程序挂掉时显示。

unwrap /expect 并不是一个好的错误处理方式。 **unwrapexpect 只会捕捉错误,然后终止程序,并没有真正的处理错误。**所以 unwrapexpect 一般只是用于原型设计。

match

使用 match 来处理错误是最常用,也是最推崇的做法。还是刚刚的那个例子,如果用 match 来写的话:

use std::fs::File;

fn main() {
    let f = File::open("hello.txt");

    let f = match f {
        Ok(file) => file,
        Err(error) => {
            panic!("There was a problem opening the file: {:?}", error)
        },
    };
}

可以把 match 表达式想象成某种硬币分类器:硬币滑入有着不同大小孔洞的轨道,每一个硬币都会掉入符合它大小的孔洞。同样地,值也会通过 match 的每一个模式,并且在遇到第一个 “符合” 的模式时,值会进入相关联的代码块并在执行中被使用。

在本例中,f 就是那个硬币,OKErr 就是那个孔;match 会把 f (io::Result) 实例中值的类型与 OKErr 做匹配,如果匹配到了合适的类型就执行 => 后的表达式。当结果是 Ok 时,返回 Ok 成员中的 file 值,然后将这个文件句柄赋值给变量 f。当结果是 Err 时,我们选择调用 panic!宏。

匹配不同的错误

上文的代码不管 File::open 是因为什么原因失败都会 panic!。我们真正希望的是对不同的错误原因采取不同的行为:如果 File::open因为文件不存在而失败,我们希望创建这个文件并返回新文件的句柄。如果 File::open 因为任何其他原因失败,例如没有打开文件的权限,我们仍然希望 panic!

只需要增加一个 match 就可以了:

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!("Tried to create file but there was a problem: {:?}", e),
            },
            other_error => panic!("There was a problem opening the file: {:?}", other_error),
        },
    };
}

如果觉得太麻烦了,还可以使用闭包:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let f = File::open("hello.txt").map_err(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Tried to create file but there was a problem: {:?}", error);
            })
        } else {
            panic!("There was a problem opening the file: {:?}", error);
        }
    });
}

传播错误

Rust 允许程序像别的语言处理“异常”一样的将错误扔给更上一层的调用者,这被称为传播 (propagating)错误。

如果文件不存在或不能读取,这个函数会将这些错误返回给调用它的代码:

use std::io;
use std::io::Read;
use std::fs::File;

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

在上面的代码中,程序会尝试打开一个名为 hello.txt 的文件,如果失败将会提前返回一个包含 ErrResult;如果成功,则将会尝试读取 hello.txt 中的信息,如果成功则会返回包含储存了被读取信息的 StringResult,否则返回包含 ErrResult

也可以使用 ? 简写:

use std::io;
use std::io::Read;
use std::fs::File;

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 调用结尾的 ? 将会返回 Ok 中的值,也就是文件 hello.txt 的文件句柄。如果出现了错误,? 会提早返回整个函数并将一些 Err 值传播给调用者。同理也适用于 read_to_string 调用结尾的 ?

match 表达式与问号运算符所做的有一点不同:? 所使用的错误值被传递给了 from 函数,它定义于标准库的 From trait 中,其用来将错误从一种类型转换为另一种类型。当 ? 调用 from 函数时,收到的错误类型被转换为定义为当前函数返回的错误类型。这在当一个函数返回一个错误类型来代表所有可能失败的方式时很有用,即使其可能会因很多种原因失败。只要每一个错误类型都实现了 from 函数来定义如将其转换为返回的错误类型,? 会自动处理这些转换。

? 只能被用于返回 Result 的函数

Previous article
Next article