对Rust中的std::io::Error的研究

你可以把把本文作为:

对标准库某一部分的研究

一份高级错误管理指南

一个美观的 API 设计案例

阅读本文需要对 Rust 的错误处理有基本的了解。

当使用Result设计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 {

  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的类型可以构建于任意的特定具体错误,可以通过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),

}

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 for Error {

  fn from(kind: ErrorKind) -> Error {

    Error { repr: Repr::Simple(kind) }

  }

}

这提供了一种跨平台访问错误码风格的错误处理。如果你需要最快的错误处理,这很方便。

最后,还有第三种,完全自定义的表示:

impl Error {

  pub fn new(kind: ErrorKind, error: E) -> Error

  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(rdr: R) -> Result

where

  R: Read,

  T: DeserializeOwned,

Read会 fail,并带有io::Error,所以serde_json::Error需要能够表示io::Error。我认为这是倒退(但是我不了解完整的背景,如果我被证明是错的,那我会很高兴),并且签名应该是下面这样:

fn from_reader(rdr: R) -> Result

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::(), 2 * 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

你可能感兴趣的:(对Rust中的std::io::Error的研究)