Appearance
33.3 问号运算符
Result 类型的组合调用功能很强大,但是它有一个缺点,就是经常会发生嵌套层次太多的情况,不利于可读性。比如下面这个示例:
rust
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),
}
}
这说明我们还有继续改进的空间。为了方便用户,Rust 设计组在前面这套系统的基础上,又加入了一个问号运算符 ?
,用来简化源代码。这个问号运算符完全是建立在前面这套错误处理机制上的语法糖。
问号运算符意思是,如果结果是 Err
,则提前返回,否则继续执行。
使用问号运算符,我们可以把file_double
函数简化成这样:
rust
fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {
let mut file = File::open(file_path).map_err(|e| e.to_string())?;
let mut contents = String::new();
file.read_to_string(&mut contents)
.map_err(|err| err.to_string())?;
let n = contents.trim().parse::<i32>()
.map_err(|err| err.to_string())?;
Ok(2 * n)
}
这里依然有不少的map_err
调用,主要原因是返回类型限制成了Result<i32,String>
。如果改一下返回类型,代码还能继续精简。
因为这段代码总共有两种错误:一种是 io 错误,用std::io::Error
表示;另外一种是字符串转整数错误,用std::num::ParseIntError
表示。我们要把这两种类型统一起来,所以使用了一个自定义的 enum 类型,这样map_err
方法调用就可以省略了。我们再补充这两种错误类型到自定义错误类型之间的类型转换,问题就解决了。 完整源码如下所示:
rust
use std::fs::File;
use std::io::Read;
use std::path::Path;
#[derive(Debug)]
enum MyError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
}
impl From<std::io::Error> for MyError {
fn from(error: std::io::Error) -> Self {
MyError::Io(error)
}
}
impl From<std::num::ParseIntError> for MyError {
fn from(error: std::num::ParseIntError) -> Self {
MyError::Parse(error)
}
}
fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, MyError> {
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)
}
fn main() {
match file_double("foobar") {
Ok(n) => println!("{}", n),
Err(err) => println!("Error: {:?}", err),
}
}
这样一来,这个 file_double
函数就精简太多了。它只需管理正常逻辑,对于可能出错的分支,直接一个问号操作符提前返回,错误处理和正常逻辑互不干扰,清晰易读。
下面继续讲解一下问号运算符背后做了什么事情。跟其他很多运算符一样,问号运算符也对应着标准库中的一个 trait std::ops::Try
。它的定义如下:
rust
trait Try {
type Ok;
type Error;
fn into_result(self) -> Result<Self::Ok, Self::Error>;
fn from_error(v: Self::Error) -> Self;
fn from_ok(v: Self::Ok) -> Self;
}
编译器会把 expr?
这个表达式自动转换为以下语义(不在 catch 块内的情况):
rust
match Try::into_result(expr) {
Ok(v) => v,
// here, the `return` presumes that there is
// no `catch` in scope:
Err(e) => return Try::from_error(From::from(e)),
}
哪些类型支持这个问号表达式呢?标准库中已经为 Option
、Result
两个类型 impl 了这个 trait:
rust
impl<T> ops::Try for Option<T> {
type Ok = T;
type Error = NoneError;
fn into_result(self) -> Result<T, NoneError> {
self.ok_or(NoneError)
}
fn from_ok(v: T) -> Self {
Some(v)
}
fn from_error(_: NoneError) -> Self {
None
}
}
impl<T,E> ops::Try for Result<T, E> {
type Ok = T;
type Error = E;
fn into_result(self) -> Self {
self
}
fn from_ok(v: T) -> Self {
Ok(v)
}
fn from_error(v: E) -> Self {
Err(v)
}
}
把这些综合起来,我们就能理解对于 Result 类型,执行问号运算符做了什么了。其实就是碰到 Err 的话,调用 From trait 做个类型转换,然后中断当前逻辑提前返回。
和 Try trait 一起设计的,还有一个临时性的 do catch
语法。在使用 do catch
的情况下,问号运算符就不是直接退出函数,而是退出 do catch
块。 示例如下:
rust
fn file_double<P: AsRef<Path>>(file_path: P) {
let r : Result<i32, MyError> = do catch {
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)
};
match r {
Ok(n) => println!("{}", n),
Err(err) => println!("Error: {:?}", err),
}
}
目前使用这个语法需要打开 #![feature(catch_expr)]
这个 feature gate。而且语法也是do catch { }
这样的写法。这么做是为了避免导致代码不兼容的问题。 以前的版本中,catch 这个单词不是关键字,可能就有用户使用了 catch 这个名字作为标识符名字。 如果我们直接把这个单词升级为关键字,必然导致某些现存代码编译错误。所以引入关键字这种事情,一定要通过 edition 的版本更迭来完成。 在 Rust 2018 edition 中,所有使用catch
作为标识符的代码都会生成一个警告,但依然编译通过。再下一个 edition 的时候,catch
就可以提升为正式关键字了,到那时,就不需要do catch{}
语法,而是直接使用 catch{}
了。(以后也可能选择 try
作为关键字,目前还没有定论。)
大家可能又发现,如果使用问号运算符,主要逻辑那部分确实已经简化得非常干净,但是其他部分的代码量又有了增长。 我们需要定义新的错误类型,实现一些 trait,才能让它工作起来。这部分能不能更简化一点呢?答案是肯定的。
其中一个方案是,使用 trait object 来替换 enum。实际上 trait object 和 enum 有很大的相似性,它们都可以把一系列的类型统一成一个类型。恰好标准库内部给我们提供了一个 trait,来统一抽象所有的错误类型,它就是 std::error::Error
:
rust
pub trait Error: Debug + Display {
fn description(&self) -> &str;
fn cause(&self) -> Option<&dyn Error> { ... }
}
所有标准库里面定义的错误类型都已经实现了这个 trait。所以,我们可以想象,错误类型其实可以表示成 Box<dyn Error>
。下面用这种方式来精简一下 file_double
这个例子:
rust
use std::fs::File;
use std::io::Read;
use std::path::Path;
fn file_double<P: AsRef<Path>>(file_path: P) ->
Result<i32, Box<dyn std::error::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)
}
fn main() {
match file_double("foobar") {
Ok(n) => println!("{}", n),
Err(err) => println!("Error: {:?}", err),
}
}
上面这段代码也可以编译通过。因为std::io::Error
和std::num::Parse-Int-Error
类型都实现了std::error::Error
trait,都可以转换为Box<dyn std::error::Error>
这个 trait object。
统一用 trait object 来接收所有错误是最简单的写法,但它也有缺点。最大的问题是,它不方便向下转型。如果外面的调用者希望针对某些类型做特殊的错误处理,就很难办。除非你不需要对任何错误类型做任何有区分的处理。这种写法适合一些简单的小工具。
使用 enum 表达错误类型,可以最精确地表达错误信息。当然带来的一个后果是,被调用者的 enum 错误类型发生变化的时候(比如给 enum 增加一个成员),会导致调用者那边编译失败,这是由类型系统保证的。很多情况下,这其实是设计者愿意看到的结果,改变错误类型本质上就是改变了 API,此事不该在调用者完全不知情的条件下默默进行。当然,有些情况下设计者的本意如果就是希望新增加一种错误类型但不影响下游用户的兼容性。这也是有办法的,那就是最开始的版本就给这个 enum 类型加上#[non_exhaustive]
标签。这样调用者那边的代码在做模式匹配的时候,无论如何都要写一条默认分支。以后给 enum 新加一个成员,就不会造成编译错误,调用者那边的流程会执行最开始的那条默认分支。具体要不要使用这个标签,就取决于设计者的意图了。