【译】对Rust中的std::io::Error的研究
原文标题:Study of std::io::Error
原文链接:https://matklad.github.io/2020/10/15/study-of-std-io-error.html
公众号: Rust 碎碎念
翻译 by: Praying
在本文中,我们将剖析 Rust 标准库中的std::io::Error
类型的实现。对应的代码在:library/std/src/io/error.rs[1]。
你可以把把本文作为:
-
对标准库某一部分的研究
-
一份高级错误管理指南
-
一个美观的 API 设计案例
阅读本文需要对 Rust 的错误处理有基本的了解。
当使用Result<T, E>
设计Error
类型时,主要问题是“错误将会被如何使用?”。通常,会符合下面的情况之一。
-
错误被代码处理。 用户来检查错误,所以其内部结构应该需要合理的暴露出来。
-
错误被传播并且展示给用户。用户不会通过超出
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<i32, MyError> {
let thing = dep_function()?;
...
Ok(92)
}
my_function
调用 dep_function
,所以MyError
应该是可以从DepError
转换得来的。下面可能是一种更好的方式
fn my_function(thing: DepThing) -> Result<i32, MyError> {
...
Ok(92)
}
在这个版本中,调用者可以专注于执行dep_function
并处理它的错误。这是用更多的打字(typing)换取更多的类型安全。MyError
和DepError
现在是不同的类型,调用者可以分别处理他们。如果DepError
是MyError
的一个变体(variant),那么可能会需要一个运行时的 match。
这种想法的一个极致版本是san-io[4]编程。对于很多来自 I/O 的错误,如果你把所有的 I/O 错误都推给调用者,你就可以略过大多数的错误处理。
尽管使用枚举这种方式很糟糕,但是它确实实现了在第一种情况下将可检查性最大化。
以传播为核心的第二种错误管理,通常使用 boxed trait 对象来处理。一个像Box<dyn std::error::Error>
的类型可以构建于任意的特定具体错误,可以通过Display
打印输出,并且可以通过动态地向下转换进行可选的暴露。anyhow[5]就是这种风格的最佳示例。
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<Custom>),
}
struct Custom {
kind: ErrorKind,
error: Box<dyn error::Error + Send + Sync>,
}
首先需要注意的是,它是一个内部的枚举,但这是一个隐藏得很好的实现细节。为了能够检查和处理各种错误情况,这里有一个单独的公开的无字段的 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<i32> {
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<ErrorKind> for Error {
fn from(kind: ErrorKind) -> Error {
Error { repr: Repr::Simple(kind) }
}
}
这提供了一种跨平台访问错误码风格的错误处理。如果你需要最快的错误处理,这很方便。
最后,还有第三种,完全自定义的表示:
impl Error {
pub fn new<E>(kind: ErrorKind, error: E) -> Error
where
E: Into<Box<dyn error::Error + Send + Sync>>,
{
Self::_new(kind, error.into())
}
fn _new(
kind: ErrorKind,
error: Box<dyn error::Error + Send + Sync>,
) -> 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<Box<dyn error::Error + Send + Sync>> {
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 error::Error + Send + Sync>
在一个 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<dyn std::error::Error>
:
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<R, T>(rdr: R) -> Result<T, serde_json::Error>
where
R: Read,
T: DeserializeOwned,
Read
会 fail,并带有io::Error
,所以serde_json::Error
需要能够表示io::Error
。我认为这是倒退(但是我不了解完整的背景,如果我被证明是错的,那我会很高兴),并且签名应该是下面这样:
fn from_reader<R, T>(rdr: R) -> Result<T, io::Error>
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::<io::Error>(), 2 * size_of::<usize>());
-
对于自定义情况,它会产生二次的间接性和分配:
enum Repr {
Os(i32),
Simple(ErrorKind),
// First Box :|
Custom(Box<Custom>),
}
struct Custom {
kind: ErrorKind,
// Second Box :(
error: Box<dyn error::Error + Send + Sync>,
}
我认为现在我们可以修正这个问题!
首先, 我们可以通过使用一个比较轻的 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),
}
}
}
-
为什么这行代码竟然可以工作?
-
它是怎样工作的?
参考资料
library/std/src/io/error.rs: https://github.com/rust-lang/rust/blob/5565241f65cf402c3dbcb55dd492f172c473d4ce/library/std/src/io/error.rs
[2]
anti-pattern: https://stackoverflow.com/questions/980601/what-is-an-anti-pattern
[3]
kitchen-sink: https://stackoverflow.com/questions/33779296/what-is-exact-meaning-of-kitchen-sink-in-programming
[4]
san-io: https://sans-io.readthedocs.io/
[5]
anyhow: https://lib.rs/crates/anyhow
[6]
ad-hoc: https://zh.wikipedia.org/wiki/Ad_hoc
[7]
anyhow: https://lib.rs/crates/anyhow
[8]
serde_json: https://docs.rs/serde_json/1.0.59/serde_json/fn.from_reader.html
[9]
failure: https://github.com/rust-lang-nursery/failure/blob/135e2a3b9af422d9a9dc37ce7c69354c9b36e94b/src/error/error_impl_small.rs#L9-L18
[10]
anyhow: https://github.com/dtolnay/anyhow/blob/840afd84e9dd91ac5340c05afadeecbe45d0b810/src/error.rs#L671-L679
[11]
GlobalAlloc: https://doc.rust-lang.org/stable/std/alloc/trait.GlobalAlloc.html
[12]
源码: https://github.com/rust-lang/rust/blob/5565241f65cf402c3dbcb55dd492f172c473d4ce/library/std/src/io/error.rs