【譯】對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