你可以把把本文作为:
对标准库某一部分的研究
一份高级错误管理指南
一个美观的 API 设计案例
阅读本文需要对 Rust 的错误处理有基本的了解。
当使用Result
错误被代码处理。 用户来检查错误,所以其内部结构应该需要合理的暴露出来。
错误被传播并且展示给用户。用户不会通过超出fmt::Display之外的方式检查错误;所以其内部结构可以被封装。
注意,暴露实现细节和将其封装之间互相牵扯。对于实现第一种情况,一个常见的反模式(译注:即不好的编程实践,详见anti-pattern[2])是定义一个 kitchen-sink[3] 枚举(译注:即把想到的一切错误类型塞到一个枚举中):
pub enum Error {
Tokio(tokio::io::Error),
ConnectionDiscovery {
path: PathBuf,
reason: String,
stderr: String,
},
Deserialize {
source: serde_json::Error,
data: String,
},
...,
Generic(String),
}
但是这种方式存在很多问题。
首先 ,从底层库暴露出的错误会其成为公开 API 的一部分。如果你的依赖库出现重大变更,那么你也需要进行大量修改。
其次,它规定了所有的实现细节。 例如,如果你留意到ConnectionDiscovery很大,对其进行 boxing 将会是一个破坏性的改变。
第三, 它通常隐含着更大的设计问题。Kitchen sink 错误将不同的 failure 模式打包进一种类型。但是,如果 failure 模式区别很大,可能处理起来就不太合理。这看起来更像第二种情况。
对于 kitchen-sink 问题的一个比较奏效的方法是,将错误推送给调用者。 考虑下面的例子:
fn my_function() -> Result
let thing = dep_function()?;
...
Ok(92)
}
my_function 调用 dep_function,所以MyError应该是可以从DepError转换得来的。下面可能是一种更好的方式
fn my_function(thing: DepThing) -> Result
...
Ok(92)
}
在这个版本中,调用者可以专注于执行dep_function并处理它的错误。这是用更多的打字(typing)换取更多的类型安全。MyError和DepError现在是不同的类型,调用者可以分别处理他们。如果DepError是MyError的一个变体(variant),那么可能会需要一个运行时的 match。
这种想法的一个极致版本是san-io[4]编程。对于很多来自 I/O 的错误,如果你把所有的 I/O 错误都推给调用者,你就可以略过大多数的错误处理。
尽管使用枚举这种方式很糟糕,但是它确实实现了在第一种情况下将可检查性最大化。
以传播为核心的第二种错误管理,通常使用 boxed trait 对象来处理。一个像Box
std::io::Error的这种情况比较有趣,是因为它想同时做到以上两点甚至更多。
这是std,所以封装和面向未来是最重要的。
来自操作系统的 I/O 错误通常可以被处理(例如,EWOULDBLOCK)
对于一门系统编程语言,切实地暴露底层的系统错误是重要的。
io::Error可以作为一个 vocabulary 类型,并且应该能够表示一些非系统错误。例如,Rust 的Path内部可以是 0 字节,对这样的Path在进行打开操作时,应该在进行系统调用之前就返回一个io::Error。
下面是std::io::Error的样子:
pub struct Error {
repr: Repr,
}
enum Repr {
Os(i32),
Simple(ErrorKind),
Custom(Box
}
struct Custom {
kind: ErrorKind,
error: Box
}
首先需要注意的是,它是一个内部的枚举,但这是一个隐藏得很好的实现细节。为了能够检查和处理各种错误情况,这里有一个单独的公开的无字段的 kind 枚举。
#[derive(Clone, Copy)]
#[non_exhaustive]
pub enum ErrorKind {
NotFound,
PermissionDenied,
Interrupted,
...
Other,
}
impl Error {
pub fn kind(&self) -> ErrorKind {
match &self.repr {
Repr::Os(code) => sys::decode_error_kind(*code),
Repr::Custom(c) => c.kind,
Repr::Simple(kind) => *kind,
}
}
}
尽管ErrorKind和Repr都是枚举,公开暴露的ErrorKind就那么恐怖了。 另一点需要注意的是#[non_exhaustive]的可拷贝的无字段枚举的设计——-没有合理的替代方案或兼容性问题。
一些io::Errors只是原生的 OS 错误代码:
impl Error {
pub fn from_raw_os_error(code: i32) -> Error {
Error { repr: Repr::Os(code) }
}
pub fn raw_os_error(&self) -> Option
match self.repr {
Repr::Os(i) => Some(i),
Repr::Custom(..) => None,
Repr::Simple(..) => None,
}
}
}
特定平台的sys::decode_error_kind函数负责把错误代码映射到ErrorKind枚举。所有的这些都意味着代码可以通过检查.kind()以跨平台方式来对错误类别进行处理。并且,如果要以一种依赖于操作系统的方式处理一个非常特殊的错误代码,这也是可能的。这些 API 提供了方便的抽象,但是没有忽略重要的底层细节。
一个std::io::Error还可以从一个ErrorKind构建:
impl From
fn from(kind: ErrorKind) -> Error {
Error { repr: Repr::Simple(kind) }
}
}
这提供了一种跨平台访问错误码风格的错误处理。如果你需要最快的错误处理,这很方便。
最后,还有第三种,完全自定义的表示:
impl Error {
pub fn new
where
E: Into
{
Self::_new(kind, error.into())
}
fn _new(
kind: ErrorKind,
error: Box
) -> Error {
Error {
repr: Repr::Custom(Box::new(Custom { kind, error })),
}
}
pub fn get_ref(
&self,
) -> Option<&(dyn error::Error + Send + Sync + 'static)> {
match &self.repr {
Repr::Os(..) => None,
Repr::Simple(..) => None,
Repr::Custom(c) => Some(&*c.error),
}
}
pub fn into_inner(
self,
) -> Option
match self.repr {
Repr::Os(..) => None,
Repr::Simple(..) => None,
Repr::Custom(c) => Some(c.error),
}
}
}
需要注意的是:
通用的new函数委托给单态的_new函数,这改善了编译时间,因为在单态化的过程中需要重复的代码更少了。我认为这对运行时效率也有改善:_new函数没有标记为内联(inline),所以函数调用会在调用点生成。这是好事,因为错误构造比较冷门,节省指令缓存更受欢迎。
Custom变量是 boxed——这样是为了保持整体的size_of更小。错误的栈上大小是重要的:即使没有错误也要承担开销。
这两种类型都指向一个'static'错误:
type A = &(dyn error::Error + Send + Sync + 'static);
type B = Box
在一个 dyn Trait + '_ 中,'_ 是'static 的省略, 除非 trait 对象藏于一个引用背后,这种情况下,会被缩写为 &'a dyn Trait + 'a。
get_ref, get_mut 以及into_inner提供了对底层错误的完整访问。与os_error相似,抽象模糊了细节,但也提供了钩子获取原本的底层数据。
类似的,Display的实现也揭示了关于内部表示的最重要的细节。
impl fmt::Display for Error {
fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.repr {
Repr::Os(code) => {
let detail = sys::os::error_string(*code);
write!(fmt, "{} (os error {})", detail, code)
}
Repr::Simple(kind) => write!(fmt, "{}", kind.as_str()),
Repr::Custom(c) => c.error.fmt(fmt),
}
}
}
对std::io::Error总结一下:
封装其内部表示,并通过对较大的枚举变量进行 boxing 来优化,
通过ErrorKind模式提供一种便利的方式来基于类别处理错误,
如果有的话,可以完全暴露底层操作系统的错误。
可以透明地包装(wrap)任意其他的错误类型。
最后一点意味着,io::Error可以被用于ad-hoc[6]错误,因为&str和 String 可以转为Box
io::Error::new(io::ErrorKind::Other, "something went wrong")
它还可以被用于anyhow[7]的简单替换。我认为一些库可能会通过下面这种方式简化其错误处理:
io::Error::new(io::ErrorKind::InvalidData, my_specific_error)
例如,serde_json[8]提供下面的方式:
fn from_reader
where
R: Read,
T: DeserializeOwned,
Read会 fail,并带有io::Error,所以serde_json::Error需要能够表示io::Error。我认为这是倒退(但是我不了解完整的背景,如果我被证明是错的,那我会很高兴),并且签名应该是下面这样:
fn from_reader
where
R: Read,
T: DeserializeOwned,
然后,serde_json::Error没有Io变量,并且会被藏进InvalidData类型的io::Error。
我认为std::io::Error是一个真正了不起的类型,它能够在没有太多妥协的情况下,为许多不同的用例服务。但是我们能否做得更好?
std::io::Error的首要问题是,当一个文件系统操作失败时,你不知道它失败的路径。这是可以理解的——Rust 是一门系统语言,所以它不应该比 OS 原生提供的东西增加多少内容。OS 返回的是一个整数返回代码,而将其与一个分配在堆上的 PathBuf 耦合在一起可能是一个不可接受的开销。
我很惊讶地发现,事实上,std 在每一个与路径相关的系统调用中都会进行分配。
它需要以某种形式存在。OS API 需要在字符串的结尾有一个零字节。但我想知道对短路径使用栈分配的缓冲区是否有意义。可能不会_路径通常不会那么短,而且现代分配器能有效地处理瞬时分配。
我不知道有什么好的解决方案。一个选择是在编译时(一旦我们得到能觉察std的 cargo)或运行时(像 RUST_BACKTRACE 那样)添加开关,所有路径相关的 IO 错误都在堆上分配。一个类似的问题是 io::Error 不支持 backtrace。
另一个问题是,std::io::Error的效率不高。
它的大小相当大:
assert_eq!(size_of::
对于自定义情况,它会产生二次的间接性和分配:
enum Repr {
Os(i32),
Simple(ErrorKind),
// First Box :|
Custom(Box
}
struct Custom {
kind: ErrorKind,
// Second Box :(
error: Box
}
我认为现在我们可以修正这个问题!
首先, 我们可以通过使用一个比较轻的 trait 对象来避免二次间接性,按照failure[9]或者anyhow[10]的方式。现在,有了GlobalAlloc[11], 它是个相对直观的实现。
其次,我们可以根据指针是对齐的这一事实,将OS和Simple变量都藏进具有最低有效位的usize。我认为我们甚至可以发挥想象,使用第二个最低有效位,把第一个有效位留作他用。这样一来,即使是像 io::Result这样的东西也可以是指针大小的!
本篇文章到此结束。下一次你要为你的库设计一个错误类型的时候,花点时间看看 std::io::Error 的源码[12],你可能会发现一些值得借鉴的东西。
益智问题
看看这个实现中的这一行:Repr::Custom(c) => c.error.fmt(fmt)
impl fmt::Display for Error {
fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.repr {
Repr::Os(code) => {
let detail = sys::os::error_string(*code);
write!(fmt, "{} (os error {})", detail, code)
}
Repr::Simple(kind) => write!(fmt, "{}", kind.as_str()),
Repr::Custom(c) => c.error.fmt(fmt),
}
}
}
为什么这行代码竟然可以工作?
亚马逊测评 www.yisuping.cn