转载

简谈 Rust 中的错误处理

在学习 Rust 的过程中,错误处理是一个必需要迈过的坎。主要原因是所有的标准库都以统一的方式处理错误,我们就来谈一谈 Rust 中是如何处理错误的吧。

Rust Book 对 rust 中的错误处理有详细的讲解,本文对其中一些选择背后的原因进行了思考和总结。强烈建议先看原文。

返回错误与异常处理

名正则言顺,我们先说说什么是“错误”,什么是“异常”:

错误:程序运行时发生的不寻常的、 超出预期 的行为,这些问题只能通过修改程序来解决。例如内存不足。

异常:运行时发生的不规则的、 意料之内 的行为。例如尝试读取“读保护”的文件。

可以看到,“错误”与“异常”的区别是“意料之内”还是“之外”。因此,我们这里所说的“错误”其实都指的是异常(这也是 Java 中既存在异常 Exception 又存在 Error 的原因了)。

在 C 语言中,错误处理的机制是十分简陋的,例如 Linux 的系统调用如果出错,会将错误记录在一个全局变量 errno 中, errno 是一个整型值,操作系统事先约定好不同值代表不同含义。

到了 C++/Java/Python 语言则采用了异常处理机制,当函数错误时,可以抛出预定义或自定义的异常,语言本身提供了捕获这个异常/错误的语法(即 try ... catch ...

异常处理相比于返回错误的好处是分离了接收和处理错误的代码。如果只用 C 语言的方式,则函数的返回值需要有一部分用于表示错误。例如 read 函数 在出错时返回 -1 ;正确时返回 0 或以上,而函数的调用者必须自己区分正确也错误的情形。还有一些更坏的情况,例如一个除法函数,它返回的任何值理论上都应该是“正确值”。那么当发和除 0 错误时,它应该返回什么值来表示错误呢?

在写本文时,我也倍受困扰,“返回错误”的方式明明一无是处,为什么 Rust 还要选择这种方式呢? 这篇文章 中提出的观点是:Rust 是一门相对底层的语言,因此在某些情况下,异常处理所需要的额外性能开销是不可接受的。或许这就是 Rust 不包含异常的原因吧。

Option

首先要注意到 Rust 中是没有 null 的概念的,我们无法像其它语言(如 C++/java)一样创建一个变量,并赋值为 null 来代表变量当前没有内容。在 Rust 中,做不到!

于是 Rust 自定义了一个结构体来表示可能为空的情形,这应该是向 Haskell 的 Maybe 借鉴的吧。结构体长这样:

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

这样,当你想表示 null 时就可以用 None 代替。而其它的赋值则可以用 Some(...) 完成。带来的问题是:如何访问 Some(...) 里的内容呢?Rust 的答案是 pattern matching:

match opt {
 Some(value) => println!("value = {}", value),
 None => println!("Got None"),
}

而由于 match 会保证我们列出了所有可能的 pattern ,即不允许只处理 Some 而不处理 None ,因此保证了程序员必定处理了值为 null 的情形。就说机不机智。

不过事实是程序员都懒啊,如果我明确知道不可能出现为 null 的情况,还需要写一堆的 match ,着实闹心,于是 rust 又为我们开了小灶,提供了 unwrap 函数:

impl<T> Option<T> {
 fn unwrap(self) -> T {
 match self {
 Option::Some(val) => val,
 Option::None =>
 panic!("called `Option::unwrap()` on a `None` value"),
 }
 }
}

注意这里的 panic! ,它的作用是输出错误的信息并退出程序(严格地说并不一定退出程序,rust 1.9 添加了 catch_unwind 支持)。所以可以通过调用 option.unwrap() 来获取 option 中包裹的值。言下之意就是:你说不可能出现 null 是吧,我且相信你,但如果出了问题我就不管了。

当然,使用 Option 的过程中还有其它一些问题,例如,程序员知道可能出现 None 的情况,当出现时使用一个默认的值。这种情况 rust 提供了函数 unwrap_or(default) 来方便书写。再例如两个函数都返回 Option ,我们想将一个函数的输出作为另一个函数的输入,此时可以使用 and_then 来减少手写 match 的次数。

还有一些其它的情况可以参考 官方文档

Result: Option 加强版

Option 可以用来表示 null 的情形,这解决了前文提到的一个问题,如果除法函数发生了除 0 操作,返回什么值来表示发生错误了?有了 Option 我们可以返回 None

但如果可能发生多个错误呢?这时, Option 可以认为只能表示发生一个错误的情形。于是 Rust 提出了另一个结构,用于包裹真正的结果:

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

其实就是表示了两种可能,如果没有错误,则返回 Ok(..) ,反之返回 Err(..) 。而由于 Err 可以带参数,所以即使发生了多个错误也能正常表示。甚至,我们可以将 Option 定义为:

type Option<T> = Result<T, ()>;

它和上节中的 Option 在作用上从各个角度都是等价的。另一方面,我们也看到,其实 rust 处理错误就是返回不同的结构体,某些表示正确,某些表示错误,我们甚至可以抛开这些结构,直接用 tuple 来表示:

type Result<T, E> = (T, E);

这样的话,是不是和 Go 语言又很相似了呢?所以这里要强调的是,返回错误的重点在于“返回”,也就是说,错误也是“正常值”的一种。

我们马上又要回到了 Option 的老路了,但这之前,我们发现 Err(E) 中, E 可以是任意类型,也就是说我们可以将错误指定为任意类型。我们先指定为 i32 来模仿 C 中的 errno

fn read(...) -> Result<usize, i32> {
 if size >= 0 {
 return Ok(size);
 } else {
 return Err(errno);
 }
}

而如果调用者对发生的错误感兴趣,则可以继续用 pattern matching 来解构:

match read(...) {
 Ok(size) => ...
 Err(1) => ... file not found ...
 Err(2) => ... is directory ...
 ...
}

当然,像 Option 一样,如果程序员对发生的错误不感兴趣,rust 也提供了 unwrap 方法来避免手写 match

要注意的是,无论是 Option 还是 Result ,它们更像是一种约定,而不是机制。假设你是 API 的提供者,你当然也可以按你自己喜欢的方式返回错误。而关于 OptionResult ,重要的是标准库的所有函数都遵守这样的约定,也因此对它们的支持相比你自定义的类型要丰富,这也是我们最好遵守这种约定的主要原因。

错误传递

上面说了半天,其实依旧没有提及如何表示“错误”本身。无论是 Option 还是 Result 其实都只是“包裹”错误的容器罢了。那么什么才是“错误”呢?

上节其实提到了,在 Result 中,“错误”其实可以是任意类型。但下文我们会提到, rust 定义了一个 trait: Error 。而之所以需要这个定义,是因为我们在错误传递上遇到了问题。

想像一下,当你调用某个函数时,你不在乎它们会产生什么错误,无论错误是什么,你只想把它们往外丢,就像异常处理里的 throw 一样。考虑 下面例子 ,

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(); // error 1
 let mut contents = String::new();
 file.read_to_string(&mut contents).unwrap(); // error 2
 let n: i32 = contents.trim().parse().unwrap(); // error 3
 2 * n
}

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

第一个遇到的问题就是:调用的函数会返回不同类型的错误,如果我们要抛出错误,要将它们定义成什么类型?眉头一皱,计上心头。定义成 String 不就行了?于是我们将代码改写成:

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

可以看到,我们手工地将各种错误通过 err.to_string() 转成 String 类型并返回。回想一下我们的初衷,就是在 file_double 中我们不想处理调用子函数时产生的任何错误,我们认为应该让调用者处理,可由于返回值要统一,因此我们把它转换成 String 类型后再返回。

第二个问题是:我们手写了许多的 match 语句来解构返回值,浪费时间,降低代码的可读性,这个问题可以通过写一个宏来解决。

try! 宏

为了解决上节的第二个问题,我们定义了一个宏,命名为 try! ,如下:

macro_rules! try {
 ($e:expr) => (match $e {
 Ok(val) => val,
 Err(err) => return Err(err),
 });
}

有了它,上节的代码就可以写成:

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

其中的 .map_err(|e| e.to_string()) 做的是将 err 转成 String 类型。可以看到,代码一下简短了许多。然而我们写了许多 .map_err(..) 来转换类型也着实丑陋,下面就来解决这个问题。

Error Trait

把错误转换成 String 返回有一个不足,就是我们失去了错误原本的类型信息,不利于函数的调用者再针对错误的类型做不同的处理。于是 Rust 为我们定了一个统一的类型来表示错误:

use std::fmt::{Debug, Display};

trait Error: Debug + Display {
 /// A short description of the error.
 fn description(&self) -> &str;

 /// The lower level cause of this error, if any.
 fn cause(&self) -> Option<&Error> { None }
}

如果所有的错误全都实现了 Error trait,则我们很容易就能创建自己的错误类型,目的则是统一函数里会发生的错误,继续上节的例子,我们首先定义自己的类型:

use std::io;
use std::num;

// We derive `Debug` because all types should probably derive `Debug`.
// This gives us a reasonable human readable description of `CliError` values.
#[derive(Debug)]
enum CliError {
 Io(io::Error),
 Parse(num::ParseIntError),
}
  • File::open(file_path) 会返回 io::Error 类型,通过 CliError::Io 可以转换成 CliError
  • file.read_to_stringFile::open 类似,也返回 io::Error 的错误。
  • String::parse 则返回的是 num::ParseIntError 类型,能通过 CliError::Parse 转换成 CliError 类型。

当然,为了保证与其它类型的兼容性,我们也需要为 CliError 实现 Error triat:

use std::error;
use std::fmt;

impl fmt::Display for CliError {
 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
 match *self {
 // Both underlying errors already impl `Display`, so we defer to
 // their implementations.
 CliError::Io(ref err) => write!(f, "IO error: {}", err),
 CliError::Parse(ref err) => write!(f, "Parse error: {}", err),
 }
 }
}

impl error::Error for CliError {
 fn description(&self) -> &str {
 // Both underlying errors already impl `Error`, so we defer to their
 // implementations.
 match *self {
 CliError::Io(ref err) => err.description(),
 CliError::Parse(ref err) => err.description(),
 }
 }

 fn cause(&self) -> Option<&error::Error> {
 match *self {
 // N.B. Both of these implicitly cast `err` from their concrete
 // types (either `&io::Error` or `&num::ParseIntError`)
 // to a trait object `&Error`. This works because both error types
 // implement `Error`.
 CliError::Io(ref err) => Some(err),
 CliError::Parse(ref err) => Some(err),
 }
 }
}

可见,只要每个错误类型都实现了 Error trait,则很容易通过建立新的自定义类型来统一错误类型。

From trait

Error trait 虽然统一了错误类型,但我们依旧要写一堆 .map_err(...) 来转换类型,有没有什么更好的方法呢?rust 定义了一个通用的 triat 用于转换类型:

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

再次重申,有点类型于 Java 中的 interfacetrait 只是一种“约定”,而约定之所以有用,是因为 rust 的标准库都遵守了这个约定。如 From 要求类型实现从其它类型的转换函数,例如你可以做下面的操作:

let string: String = From::from("foo");
let bytes: Vec<u8> = From::from("foo");
let cow: ::std::borrow::Cow<str> = From::from("foo");

这是因为标准库中的 String 类型已经实现了 From<&str> ,另外几个也类似。

那么为什么上节中我们自定义的错误类型要实现 Error trait 呢?其中一个重要原因是标准库已经为 Box<Error> 实现了 From trait:

impl<'a, E: Error + 'a> From<E> for Box<Error + 'a>

也因此我们可以用 From::from 来进行错误类型间的转换如下:

// We have to jump through some hoops to actually get error values.
let io_err: io::Error = io::Error::last_os_error();
let parse_err: num::ParseIntError = "not a number".parse::<i32>().unwrap_err();

// OK, here are the conversions.
let err1: Box<Error> = From::from(io_err);
let err2: Box<Error> = From::from(parse_err);

因此,有了 ErrorFrom 两个 trait 及标准库对两个 trait 的实现, try! 宏的真正实现方式就进化了:

macro_rules! try {
 ($e:expr) => (match $e {
 Ok(val) => val,
 Err(err) => return Err(::std::convert::From::from(err)),
 });
}

有了这两个工具,我们就可以:

  1. 不定义自己的类型,而直接使用 Box<Error> 来统一错误类型。
  2. try! 宏来传递错误。
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)
}

完美!并且,在 rust 1.13 中加入了 ? 操作符,用来替代 try! 因此可以这么写:

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

统一自定义错误类型

最后一个大问题是自定义错误类型。有了 From trait 之后,我们可以轻易地将任意实现了 Error trait 的错误转换成 Box<Error> ,但如果我们要返回的不是 Box<Error> 而是自定义错误,那要怎么办呢?答案也很简单,为可能出现的错误实现 From trait。

上几节的例子中,可能出现的错误为 io::Errornum::ParseIntError ,因此我们需要为 CliError 实现 From<io::Error>From<num::ParseIntError> 。如下:

use std::io;
use std::num;

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

终于搞定了!

如何处理错误?

综上,在 rust 语言中,处理错误有几种方式:

对于函数的作者而言,返回值可以是:

  1. 正常的值,即 i32 , String 等等,表明该函数不可能发生错误。
  2. 返回 Option 表示函数可能会失败。
  3. 不自定义错误。返回 Result<..., Box<Error>>
  4. 返回自定义错误,如上例中的 Result<i32, CliError>

而当函数 A 调用的子函数 B 返回错误时,有几种处理的方式:

  1. 不处理错误。即调用 unwrap 来获取返回数据。
  2. 在函数 A 内部处理。即通过 match 语句或 unwrap_or 等函数来处理返回值可能包含错误的情况。
  3. 当函数 A 返回值为 ResultB 的返回值也为 Result 时,可以通过 try!(B()) 来获得 B 的返回值。而若返回值为 Err 时, try! 会自动退出函数 A 并将错误进行处理后返回。

最后,当函数的作用决定自定义错误类型(如 CliError )时,需要做几项操作:

  1. 实现 Error trait。即实现 descriptioncause 函数,来提供错误的内容。
  2. 为可能发生的错误实现 From trait。如上文中 CliError 实现了 From<io::Error>From<num::ParseIntError>

上述两项工作完成后就可以放心地使用 try! 来获取子函数返回值的内容了。

小结

本文首先区别介绍了“返回错误”和“异常处理”的区别。Rust 选择了“返回错误”的道路,本文也因此介绍了它面临了几个问题:

  1. 如何表示返回值有错误?Rust 提供了 OptionResult 这两个“容器”来满足不同需求。
  2. 调用不同子函数可能返回不同错误类型,于是使用 Error trait 来统一类型。
  3. 解构返回值需要写大量 match 语句,Rust 引入宏 try! 来减少工作量。
  4. 不同错误类型间的转换需要写很多代码,Rust 引入 From trait 来减少程序员的输入。

最后,若用户需要自定义错误类型,它需要同时实现 ErrorFrom 两个 trait.

与其它语言对比,rust 的错误处理是相当地复杂。其中的重要原因是它更像是一种高层的约定,而非语言层面的机制,换句话说,你用其它的语言也能实现类似的功能。

由于我写过的 rust 程序都不大,并且没有写过库,因此对这套错误处理方式的优点并不是特别“感同深受”,也许它更适合大型程序的开发吧。

Reference

  • http://blog.honeypot.io/errors-and-exceptions-in-rust/ : Rust 处理错误异常的方式,介绍了不同语言处理异常的方式。
  • https://news.ycombinator.com/item?id=9545647 : 关于 rust 为何不采用“异常处理”的讨论。
  • http://www.infoq.com/cn/news/2012/11/go-error-handle : Go语言的错误处理机制引发争议。
原文  http://lotabout.me/2017/rust-error-handling/
正文到此结束
Loading...