praying

原文标题: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]

你可以把把本文作为:

  1. 对标准库某一部分的研究

  2. 一份高级错误管理指南

  3. 一个美观的 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)换取更多的类型安全。MyErrorDepError现在是不同的类型,调用者可以分别处理他们。如果DepErrorMyError的一个变体(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,
    }
  }
}

尽管ErrorKindRepr都是枚举,公开暴露的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], 它是个相对直观的实现。

其次,我们可以根据指针是对齐的这一事实,将OSSimple变量都藏进具有最低有效位的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),
    }
  }
}
  1. 为什么这行代码竟然可以工作?

  2. 它是怎样工作的?

参考资料

[1]

library/std/src/io/error.rs: https://github.com/rust-lang/rust/blob/5565241f65cf402c3dbcb55dd492f172c473d4ce/library/std/src/io/error.rs

[2]

anti-pattern: https://*.com/questions/980601/what-is-an-anti-pattern

[3]

kitchen-sink: https://*.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

分类:

技术点:

相关文章: