转自:https://zhuanlan.zhihu.com/p/25506762,修改排版等内容

人孰无过,过而能改,善莫大焉。程序运行过程中总是会出现各种各样的问题,导致程序出现异常或错误,这些异常和错误本身不是 bug,但是如果不处理好的话就会成为 bug。不同编程语言提供了不同的机制来处理错误和异常,一般分为两大类:把错误当作值来处理;抛出异常。

Rust 提供以下基础设施做错误处理:

Option, 
Result,
unwrap, 
expect,
combinators,
try! macro,
Error trait,
From trait,
Carrier trait/Try trait,

Rust 并没有提供基于 exception 的错误处理机制,虽然 panic! 宏在让进程挂掉时也抛出堆栈,同时也可以用 std::panic::catch_unwind 捕捉 panic,但是极其不推荐用来处理常规错误。

catch_unwind 一般是用来在多线程程序里面在将挂掉的线程 catch 住,防止一个线程挂掉导致整个进程崩掉,或者是通过外部函数接口(FFI)与 C 交互时将堆栈信息兜住防止 C 程序看到堆栈不知道如何处理,直接把堆栈信息丢给 C 程序的话属于 C 里的未定义行为(Undefined Behavior)。

另外 catch_unwind 并不保证能 catch 所有 panic,而只对通过 unwind 实现的 panic 有用。因为 unwind 需要额外记录堆栈信息,对程序性能和二进制程序大小有影响,所以在一些嵌入式平台上面的 panic 并没有通过 unwind 实现,而是直接 abort 的,所以 catch_unwind 并不保证能捕捉到所有panic。

use std::panic;
fn main() {
    let result = panic::catch_unwind(|| {
        println!("hello!");
    });
    assert!(result.is_ok());

    let result = panic::catch_unwind(|| {
        panic!("oh no!");
    });
    assert!(result.is_err());
}

Rust 错误处理本质上还是基于返回值的,很多基于返回值做错误处理的语言是将错误直接硬编码到正确值上,或者返回两个值,前者例如 C 在很多时候都是直接把正常情况永远不会出现的值作为错误值,后者例如 Go 同时返回两个值来进行错误处理。而 Rust 则将两个可能的值用 enum 类型表示,enum 在函数式语言里面叫做代数数据类型(algebraic data type),而且是和类型(sum type),表示两个可能的值一次只能取一个。

enum Option<T> {
    None,
    Some(T),
}

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

用 Option 表示错误时一般不关心错误原因,出错时直接返回空值 None 而 Result<T, E> 则将错误的不同原因包括进来了,Option 相当于 Result<T, ()>,如果错误只可能是一种原因造成的,一般就直接用 Option 例如从 HashMap 里面取值或者对 Vector 进行 pop 操作,前者出错了只可能是对应的 key 不存在,后者出错只可能是 Vector 已经是空的了。而如果错误可能是多种原因造成的则用 Result<T, E> 来表示,例如 IO 错误,原因可能是 NotFound, PermissionDenied, AlreadyExists, InvalidData…

新手在看各种文档或读别人的代码时发现 Result 的错误类型时可能会有点小疑惑。这里简单提一下,Result 是用到了类型别名,例如 io::Result 定义如下,因为 io 模块里的错误都是 io::Error,用到 Result<T, Error> 的地方如果都换成 Result 会少敲很多次键盘,同时又不会产生歧义(因为 Result 里面的 E 已经被固定成 io::Error 了)

type Result<T> = Result<T, Error>;

Option 和 Result 作为代数数据类型,可以利用函数式语言里面的组合子(combinator)抽象简化错误处理。Option 和 Result 可以看成是一个包装类型,要对其进行处理的话,一般需要将里面的东西—正确值和错误值取出来,然后根据取出来的值分情况处理,即对 enum 类型常见的操作 match。这种方式是最拿衣服的处理方式,分情况分析,不做任何抽象,与另外两种基于返回值的错误处理方式基本一样。

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

当然也可以选择不处理错误,用 unwrap 或 expect 直接将正确值取出来,如果出错了就直接让程序挂掉,其中 unwrap 在挂掉时是标准库内置的错误提示,而 expect 则让我们可以自己定义一个字符串在程序挂掉时显示。这两种是最粗暴的处理方式了,程序看起来很简洁,除了每个可能出错的地方有个难看的 unwrap 外(Rust 设计就是让这些不规范的代码看起来丑陋好让你改变一些不规范的写法)。很多动态语言写起来也类似,但是不用写 unwrap,其实可以看成是默认包含了 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();
    let mut contents = String::new();
    file.read_to_string(&mut contents).unwrap();
    let n: i32 = contents.trim().parse().unwrap();
    2 * n
}

fn main() {
    let doubled = file_double("foobar");
    println!("{}", doubled);
}

其实很多时候用户并不需要将错误立即返回给上层调用者,而只是希望整个函数给出最后一个计算结果即可,这种情况下可以将出错的结果当作一个普通值继续参与后面的计算,这样从上一步可能出错的结果中取出两种可能的值继续送入下一个可能出错的计算里,一直到最后,计算过程得到的都是可能会出错的结果,即 Option 或 Result,与其从包装类型里面取出值进行计算然后得到一个包装类型再取出来,是否可以把这个过程抽象出来呢。组合子就可以实现这个功能,不用取出计算结果然后参与计算然后再取出。。。标准库里为 Option 和 Result 提供了大量的组合子,map, map_err, and, and_then, or, or_else 等这些组合子将 compute -> unwrap -> case analyze -> compute -> unwrap 的过程抽象出来,从代码上看就使计算本身显得更紧凑,而不是被各种错误处理打断。

map 顾名思义就是把值全部取出来做一个操作,不过 map 只对正确值做处理,而对 None 和 Err 不做任何处理,所以 map 的用处就是不用取出 Option, Result 里面的值然后分情况计算,直接将计算作为闭包放在 map 参数里。map_err 正好相反,只对 Err 进行操作,而不处理 Ok 值。

and 和 or 两个则跟操作符 && 和 || 一样是短路运算,其中 and 连接的两个 Result(或 Option) 如果第一个就是错误的话就短路直接取第一个 Result(或 Option) 的 Err(或 None) 值,否则就取第二个值,而 or 连接的两个 Result(或 Option) 如果第一个是正确的话就直接短路取第一个正确的值 Ok(或 Some),否则就取第二个值。还有一点需要注意的是 and 连接的两个 Result 的 Err 类型是一样的,而 or 连接的两个 Result 的 Ok 类型是一样的,具体可以看看下面的几个小例子就能理解了,这里之所以没有用 Option 作例子,是因为前面提到的 Option 其实相当于特殊的 Result 即 Result<T, ()>

// and
let x: Result<u32, &str> = Ok(2);
let y: Result<&str, &str> = Err("late error");
assert_eq!(x.and(y), Err("late error"));

let x: Result<u32, &str> = Err("early error");
let y: Result<&str, &str> = Ok("foo");
assert_eq!(x.and(y), Err("early error"));

let x: Result<u32, &str> = Err("not a 2");
let y: Result<&str, &str> = Err("late error");
assert_eq!(x.and(y), Err("not a 2"));

let x: Result<u32, &str> = Ok(2);
let y: Result<&str, &str> = Ok("different result type");
assert_eq!(x.and(y), Ok("different result type"));

// or
let x: Result<u32, &str> = Ok(2);
let y: Result<u32, &str> = Err("late error");
assert_eq!(x.or(y), Ok(2));

let x: Result<u32, &str> = Err("early error");
let y: Result<u32, &str> = Ok(2);
assert_eq!(x.or(y), Ok(2));

let x: Result<u32, &str> = Err("not a 2");
let y: Result<u32, &str> = Err("late error");
assert_eq!(x.or(y), Err("late error"));

let x: Result<u32, &str> = Ok(2);
let y: Result<u32, &str> = Ok(100);
assert_eq!(x.or(y), Ok(2));

在遇到 Result<Result<T, E1>, E2>> 或 Option<Option> 这种高阶错误类型时就得用 and_then 或 or_else 组合子,高阶错误类型用 match 进行模式匹配的话需要嵌套两层 match,分情况分析的话就有四种可能,而有了 and_then 和 or_else 这两个组合子后,则只用调用这两者中的一个就可以得到普通的一阶错误类型,所以在有些语言里称之为 flatmap。还是跟上面短路运算类似,and_then 如果第一个就是错误的话,就直接取第一个错误值,否则就把 and_then 里面的闭包应用到第一个的正确值上;而 or_else 如果第一个是正确值的话就直接取第一个正确值,否则就把 or_else 里面的闭包应用到第一个错误值上面。可以结合下面的例子辅助理解一下:

fn sq(x: u32) -> Result<u32, u32> { Ok(x * x) }
fn err(x: u32) -> Result<u32, u32> { Err(x) }

assert_eq!(Ok(2).and_then(sq).and_then(sq), Ok(16));
assert_eq!(Ok(2).and_then(sq).and_then(err), Err(4));
assert_eq!(Ok(2).and_then(err).and_then(sq), Err(2));
assert_eq!(Err(3).and_then(sq).and_then(sq), Err(3));

assert_eq!(Ok(2).or_else(sq).or_else(sq), Ok(2));
assert_eq!(Ok(2).or_else(err).or_else(sq), Ok(2));
assert_eq!(Err(3).or_else(sq).or_else(err), Ok(9));
assert_eq!(Err(3).or_else(err).or_else(err), Err(3));

这里只简单介绍下上面这几个 combinator,而标准库里面 Option 和 Result 还有大量的 combinator,建议把标准库里 Option 和 Result 的方法都扫一遍,这样在进行错误处理时可以知道用哪些组合子把错误处理串起来。

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

由于返回值只有一个类型,在上面示例中可能会出现不同的错误 io::Error, num::ParseIntError,为了让程序通过编译,必须将不同的错误转换成同一个类型。这里采用的是最简单的方式,统一转成字符串,对于程序使用者来说,错误就是所能看到的 error message,所以这种处理也是比较自然的。如果同时出现 Option 和 Result<T, E>,则一般是直接通过 ok_or 或 ok_or_else 将 Option 转换成 Result<T, E> 进行处理的。

但是这里在每个地方都写 map_err 会显得很烦琐,后面会介绍如何进行抽象将重复的 map_err 消除掉。除了这个之外,这里还有一个问题,上层调用者拿到 string 类型的错误后,很难再基于这个错误做进一步处理,基本只能将错误打印出来,因为不同的错误都是字符串,上层调用者没法基于字符串对错误进行区分。

要让 Rust 编译器认为本质上不同的类型在编译器层面是相同的,可以利用 trait object 将真正的类型隐藏在指针后面,只要不同类型实现了某个公共 trait,就可以将所有类型都转换成相同的 trait object。trait object 可以通过运行时动态反射得到指针后的类型信息。

Rust 标准库里 Error 就是被定义成 trait 的,而标准库里所有的错误类型都实现了这个 trait,所以都可以转换成 Box 类型。

trait Error: Display {
    fn description(&self) -> &str;

    fn cause(&self) -> Option<&Error> { None }
}

前面的例子中 io::Error 和 num::ParseIntError 都被转换成了 String 类型,利用 trait object 可以用 Box 替换 String 来表示错误。

use std::fs::File;
use std::io::Read;
use std::path::Path;
use std::error::Error;
use std::convert::From;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, Box<Error>> {
    File::open(file_path)
        .map_err(|err| From::from(err))
        .and_then(|mut file| {
            let mut contents = String::new();
            file.read_to_string(&mut contents)
                .map_err(|err| From::from(err))
                .map(|_| contents)
        })
        .and_then(|contents| {
            contents.trim()
                .parse::<i32>()
                .map_err(|err| From::from(err))
        })
        .map(|n| 2 * n)
}

fn main() {
    match file_double("foobar") {
        Ok(n) => println!("{}", n),
        Err(err) => println!("Error: {}", err),
    }
}

链式调用有个缺点是没法在出错时提前返回,要提前返回的话必须从组合子跳出然后再跳出外层函数,这是普通函数没法做到的。不过 Rust 还提供了比普通函数更原始的抽象—宏,直接操作语法单元作为模板在编译期展开,使得我们可以将 return 塞到宏里面,而宏本身不是函数,所以可以直接提前返回。

macro_rules! try {
    ($expr:expr) => (match $expr {
        $crate::result::Result::Ok(val) => val,
        $crate::result::Result::Err(err) => {
            return $crate::result::Result::Err($crate::convert::From::from(err))
        }
    })
}

可以看到 try! 宏里面包括了 convert::From::from 函数调用,所以在 try! 宏调用时只要实现了 From 就可以自动进行类型转换,而不用显式地通过 map_err 进行错误类型的转换。下面的例子就是利用了宏可以提前返回,同时可以自动转换类型的特点,前面提到过 io::Error 和 num::ParseIntError 都实现了 Error trait,而标准库里又有 impl<'a, E: Error + 'a> From for Box<Error + 'a> 所以最终实现了 io::Error 和 num::ParseIntError 到 Box 的自动转换。

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<Error>> {
    let mut file = try!(File::open(file_path));
    let mut contents = String::new();
    try!(file.read_to_string(&mut contents));
    let n = try!(contents.trim().parse::<i32>());
    Ok(2 * n)
}

但是运行时反射要得到错误详细信息是需要一次间接指针访问的,动态分发(dynamic dispatch)访问 Error 的两个方法。这是不太符合 Rust 零开销抽象原则的,所以 Rust 还提供更好的抽象机制处理错误。

这里需要要利用前面提到的 From trait,首先我们看一下这个 trait 的定义,非常简单,只有一个函数:

pub trait From<T> {
    fn from(T) -> Self;
}

前面提到宏里面包括了调用 convert::From::from 所以如果用户自己定义一个错误,然后实现 From trait 将其它错误转成自定义的错误,就可以利用 try! 实现自动调用,上层调用者得到的就是一个用户自定义的错误类型,不用再通过 trait object 间接得到错误类型。

use std::fs::File;
use std::io::{self, Read};
use std::num;
use std::path::Path;

#[derive(Debug)]
enum CliError {
    Io(io::Error),
    Parse(num::ParseIntError),
}

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

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, CliError> {
    let mut file = try!(File::open(file_path));
    let mut contents = String::new();
    try!(file.read_to_string(&mut contents));
    let n: i32 = try!(contents.trim().parse());
    Ok(2 * n)
}

这里我们自定义了错误类型 CliError,并实现了 From trait 从 io::Error 和 ParseIntError 转换到 CliError。既利用了 try! 宏里面的 from 函数自动转换类型省掉了手写烦琐的 map_err,又可以在遇到错误时提前返回,同时上层调用者又可以不用反射就得到具体错误类型方便分情况做进一步处理。

所以算是 Rust 里面比较完美的错误处理方案了,尤其是对库的作者来说,最终发布出来的代码都应该包括自己定义的错误类型,以方便库的使用者拿到错误后能在错误基础上继续分情况进行处理。另外由于库的作者不知道上层应用如何处理错误,所以除非真的需要否则不要在库里面直接 panic。

不过如果需要多次调用 try! 宏的话,代码写出来可能就不大好看了。比如:

let event : EventInfo = try!(try!(try!(try!(try!(self.request(Method::Post, &dsn.get_submit_url()))
    .with_header("X-Sentry-Auth", &dsn.get_auth_header(event.timestamp)))
    .with_json_body(&event))
    .send()).convert());

这里看一串 try! 宏最里面的,构造了一个 Post 请求,这个可能会失败,所以需要一个 try! 处理一次错误,

取出里面的正确值,接着用成功的 request 加上 header 信息,这里又有可能出错,所以得再用一个 try!,

再然后用加了 header 的 Post 请求加上 json 数据,又是可能失败的操作,所以再来一个 try!,等总算构造出一个 Post 请求后,真正发送出去又是一个可能失败的操作,接着上 try!,请求发出去后,得到 response convert 成 EventInfo 类型,又是一个可能失败的操作,又得来一个 try! 了,

不过经历这么多步可能失败的操作,最终终于完成了一个 Post 请求并把结果转换成了用户要的 EventInfo,简直跟西天取经一样要经历这么多磨难才能拿到想要的信息,这在 IO 操作和网络编程时是非常常见的,与外界打交道总是不能保证一定拿到预期的正确值。

这里不仔细数还真不知道有几个 try!,看起来当然就有点恶心了。

官方为响应社区的需求引入了问号语法糖改善这一点,可以将缩在一坨的 try! 分散开成多个问号。一个返回 Result 的函数或方法后面加一个问号,如果是 Err 的话则提前返回,否则就取出 Ok 里面的值,所以借助这个语法糖,上面那串 try! 就可以改写成:

let event : EventInfo = self.request(Method::Post, &dsn.get_submit_url())?
    .with_header("X-Sentry-Auth", &dsn.get_auth_header(event.timestamp))?
    .with_json_body(&event)?
    .send()?.convert()?;

是不是看起来清爽一些了,错误处理本来是在返回错误之后进行的,所以用问号放在函数或方法调用后面表示处理错误比 try! 放在前面更合符合直觉一点,同时问号也有表示询问前面的调用返回的是正确的还是错误的意味,另外就是问号也被分散到每一个方法调用后面而不是集中到一起,所以增加了代码的可读性。其实问号语法糖在其它语言里面也比较常见的。

当然 Rust 里面问号语法糖并不是编译器的黑魔法,而是通过 Carrier trait 来实现的:

pub trait Carrier {
    type Success;
    type Error;
    fn from_success(Self::Success) -> Self;
    fn from_error(Self::Error) -> Self;
    fn translate<T>(self) -> T
where T: Carrier<Success=Self::Success, Error=Self::Error>
;
}

只要实现了这个 trait 就可以吃上这口糖,虽然问号语法糖已经在 stable 版里可用了,但是用户要想让自定义的类型使用这个语法糖的话还是需要 nightly 版的。而且标准库里只对 Result 类型实现了这个 trait,根据 Rust 语法规定(类型和 trait 至少有一个是用户定义的才能给类型实现 trait)用户是没法在 Option 类型上使用问号语法糖的。这个语法糖从加到 nightly 到进入 stable 只用了一个多月时间,这在 Rust 惯例里面还是很少见的,说明这个语法糖还是很受欢迎的。

借助这个语法糖,前面 file_double 的错误处理代码可以进一步简化成:

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, CliError> {
    let mut contents = String::new();
    File::open(file_path)?.read_to_string(&mut contents)?;
    let n: i32 = contents.trim().parse()?;
    Ok(2 * n)
}

其实官方还准备引入 try/throw//catch 语法来处理错误的,不过跟一般语言的 try/throw/catch 机制不同,而仍然是基于 Option/Result 的,要求 Result 在穿越多层时能够 cast 成相同类型的 Result,目前 nightly 里有 try_catch 的特性开关,具体细节这里就不展开讲了,感兴趣的读者可以参考RFC说明

所以总结一下 Rust 错误处理有以下几种情况:

原型设计和一些 quick & dirty work 里面可以用 unwrap 或 expect 忽略错误在不需要提前返回错误时可以链式调用各种 combinator,一气呵成处理错误需要提前返回错误并且不是写库的话直接用 try! 宏作为库的作者,应该自己定义错误类型并实现 From trait,然后借助 try! 宏和各种 combinator 综合灵活处理至于用 try! 宏还是问号语法糖则就看个人和团队喜好了

​问号语法糖这部分官方已经决定要把底层的 Carrier trait 换成 Try trait 了,相应的 try/throw 也不准备引入了。

由于问号语法糖背后的 Carrier trait 还是 nightly 特性,虽然 RFC 0243 已经合并了,但是 Carrier 实现允许任何实现 Carrier trait 之间的类型相互转换,给类型推断增加了很大的难度,如果要限制语义上同类的类型转换则可能需要借助高阶类型(HKT),而目前 Rust 尚不支持高阶类型,所以 Carrier 的实现和使用被 block 住了,Niko 提出的 Try trait 就不存在这个问题,

trait Try<E> {
    type Success;
    fn try(self) -> Result<Self::Success, E>;
}

Try trait 只允许语义上相同的类型通过问号语法糖进行转换,比如 Result<T, E> 和 Result<U, F>,而不允许 Result<T, E> 和 Option 之间转,所以类型推断实现起来相对容易些。

由于这个 RFC 尚未合并,这里就不展开讲了,感兴趣的读者可以自己去读读 try-trait RFC 和相关的讨论

另外 try/throw 的错误处理机制虽然在 RFC 243 里面提到了,不过官方已经放弃引入了。而上面提到的 catch 实际上只是用来将把问号的返回值在函数内部处理掉,如果外层函数的返回值不是 Result 类型,而函数内部又用到了问号语法糖,就会出现类型不匹配的错误, catch 的作用就是把含有问号的表达式或语句框住并处理掉,从而不让 Result 蔓延。