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


    当使用Result<T, E>设计Error 类型时,主要问题是“错误将会被如何使用?”。通常,会符合下面的情况之一。

    注意,暴露实现细节和将其封装之间互相牵扯。对于实现第一种情况,一个常见的反模式(译注:即不好的编程实践,详见anti-pattern)是定义一个 kitchen-sink 枚举(译注:即把想到的一切错误类型塞到一个枚举中):

    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编程。对于很多来自 I/O 的错误,如果你把所有的 I/O 错误都推给调用者,你就可以略过大多数的错误处理。

    尽管使用枚举这种方式很糟糕,但是它确实实现了在第一种情况下将可检查性最大化。

    以传播为核心的第二种错误管理,通常使用 boxed trait 对象来处理。一个像Box<dyn std::error::Error>的类型可以构建于任意的特定具体错误,可以通过Display打印输出,并且可以通过动态地向下转换进行可选的暴露。anyhow就是这种风格的最佳示例。

    std::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),
        }
      }
    }

    需要注意的是:

    type A =   &(dyn error::Error + Send + Sync + 'static);
    type B = Box<dyn error::Error + Send + Sync>

    在一个 dyn Trait + '_ 中,'_ 是'static 的省略, 除非 trait 对象藏于一个引用背后,这种情况下,会被缩写为 &'a dyn Trait + 'a。

    类似的,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总结一下:

    最后一点意味着,io::Error可以被用于ad-hoc错误,因为&str和 String 可以转为Box<dyn std::error::Error>:

    io::Error::new(io::ErrorKind::Other, "something went wrong")

    它还可以被用于anyhow的简单替换。我认为一些库可能会通过下面这种方式简化其错误处理:

    io::Error::new(io::ErrorKind::InvalidData, my_specific_error)

    例如,serde_json提供下面的方式:

    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的 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或者anyhow的方式。现在,有了GlobalAlloc, 它是个相对直观的实现。

    其次,我们可以根据指针是对齐的这一事实,将OSSimple变量都藏进具有最低有效位的usize。我认为我们甚至可以发挥想象,使用第二个最低有效位,把第一个有效位留作他用。这样一来,即使是像 io::Result

    本篇文章到此结束。下一次你要为你的库设计一个错误类型的时候,花点时间看看 std::io::Error 的源码,你可能会发现一些值得借鉴的东西。


    益智问题

    看看这个实现中的这一行: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),
        }
      }
    }

    参考资料

    12-31 04:48