Rust錯誤處理最佳實踐
錯誤處理是Rust編碼過程中重要的一環; )
序言
現實世界處處凶險,充滿了未知和異常,錯誤處理是保持程式碼健壯性必不可少的環節,處理錯誤的方式各有千秋,本文是對筆者在學習與實踐過程中摸索得來的錯誤處理之道的梳理和總結.
為什麼要進行錯誤處理
以如下Demo為例:
fn main(){ let path="abc.txt"; println!("{}",try_read_file(path)); } fn try_read_file(path: &str)-> String { std::fs::read_to_string(path).unwrap() }
try_read_file
函式嘗試對path指示的檔案進行讀取,但 unwrap()
在該檔案不存在時將導致程式崩潰,這顯然不夠健壯,在快速實現思路的階段或者在出於演示目的的demo程式碼中可以使用 unwrap()
快速開啟 Option
和 Result
的包裝,但是這個操作很危險,實際開發中應儘量避免這種暴力的方式.
如何在Rust中進行正確且優雅的錯誤處理
前導知識
1:實現"自定義錯誤型別"
閱讀標準庫中 std::error::Error
的程式碼,剔除無關程式碼之後基本框架如下:
pub trait Error: Debug+Display{ // snip fn source(&self)-> Option<&(dynError+'static)>{ None } }
如果採用自定義錯誤型別的方式來完成這項任務,我們需要為我們的自定義錯誤型別完成以下任務:
impl std::fmt::Display
Trait,並實現fmt
方法- 一般情況下可通過
#[derive(Debug)]
實現std::fmt::Debug
Trait - 實現
std::error::Error
的Trait,並根據error的級別決定是否覆蓋source()
方法.如果當前錯誤型別是低級別錯誤,沒有子錯誤,則返回None
,所以此時可以不覆蓋soucre()
; 如果當前錯誤有子錯誤,則需要覆蓋該方法
動手實現一個Demo如下:
usestd::error::Error; // 自定義Error,通過#[derive(Debug)]註解實現std::fmt::Debug的trait #[derive(Debug)] struct CustomError{ err: ChildError, } // 實現Display的trait impl std::fmt::Display for CustomError{ // 一般情況下是固定寫法 fn fmt(&self,f: &mutstd::fmt::Formatter<'_>)-> std::fmt::Result{ write!(f,"父型別錯誤~") } } // 實現std::error::Error Trait,因為有子Error:ChildError,覆蓋source()方法,返回Some(err) impl std::error::Error for CustomError{ fn source(&self)-> Option<&(dynstd::error::Error+'static)>{ Some(&self.err) } } // 子Error #[derive(Debug)] struct ChildError; // 實現Display impl std::fmt::Display for ChildError{ fn fmt(&self,f: &mutstd::fmt::Formatter<'_>)-> std::fmt::Result{ write!(f,"子型別錯誤~") } } // 實現Error的trait,因為沒有子Error,不需要覆蓋source()方法 impl std::error::Error for ChildError{} // 構建一個Result的結果,返回自定義的error:CustomError fn get_super_error()-> Result<(),CustomError>{ Err(CustomError{err: ChildError}) } fn main(){ match get_super_error(){ Err(e)=>{ println!("{}",e); println!("Caused by: {}",e.source().unwrap()); } _=>println!("No error"), }
執行結果:
父型別錯誤~
Caused by:子型別錯誤~
2:如何避免複雜的型別定義
複雜的型別定義其實可以視作程式碼噪音,例如型別 std::result::Result<someType, CustomError>
,簡化它分為兩個步驟個,首先使用 use std::result::Result
,然後再使用 pub type Result<I> = std::result::Result<I, CustomError>
設定別名,最終Rust在編譯 Result<u32>
的時候就會替換成 std::result::Result<u32,CustomError>
3:如何避免大量使用 match
匹配Option和Result
對於函式返回的 Option
型別和 Result
型別,Rust提供 match
對其進行匹配,但是如果巢狀層次比較深,可能會出現非常醜陋的層疊 match
,雖然能夠覆蓋所有情況,但顯然這樣的程式碼不太美觀,此時應考慮使用Rust提供的 ?
操作符進行簡化.
分析sled的錯誤處理哲學
強烈推薦首先閱讀sled作者寫的文章:
Error Handling in a Correctness-Critical Rust Projectsled.rs/errors.html
接下來的程式碼來自sled專案,將錯誤處理模組從專案中提取出來,重要部分添加註釋.
Error
是一個全域性的致命錯誤列舉型別,將其列成 enum
的原因是,在程式碼執行過程中出現的致命錯誤都將被向上傳遞給呼叫者,所以需要一個外部的錯誤型別.這裡其實和Rust社群中"將任何錯誤都包裹到一個全域性的錯誤enum"的風氣恰好相反.,因為這種方式無法區分致命錯誤和非致命錯誤,有些時候向上傳播了本應該在函式內處理的錯誤.
result.rs
// 不要把邏輯上在函式內就能夠處理的小錯誤傳遞到上層去,即不要把這種型別的錯誤包裹到全域性Error enum中
// 而需要包裹到全域性Error enum中的是那些可能會引起系統癱瘓的致命錯誤,這些錯誤也必須要人工干預
// 因此必須要傳遞到上層
pub enum Error{
/// The underlying collection no longer exists.
CollectionNotFound(IVec),
/// The system has been used in an unsupported way.
Unsupported(String),
/// An unexpected bug has happened. Please open an issue on github!
ReportableBug(String),// 比如這個錯誤就很嚴重,嚴重到要通知作者,所以就被包裹進全域性的Error enum中了
/// A read or write error has happened when interacting with the file
/// system.
Io(io::Error),
/// Corruption has been detected in the storage file.
Corruption{
/// The file location that corrupted data was found at.
at: DiskPtr,
},
// a failpoint has been triggered for testing purposes
#[doc(hidden)]
#[cfg(feature = "failpoints")]
FailPoint,
}
而在設計之初就知道很有可能甚至是一定會發生的錯誤 應該直接在發生錯誤的函式內處理掉 ,無需向上傳播給呼叫者. 這種型別的錯誤需要有完全單獨的錯誤型別,比如 CompareAndSwap
錯誤,可能會引起這個錯誤的操作是: 猜測Map某個鍵的值,如果猜對了就將其更新為新值,如果猜錯了,就引發 CompareAndSwap
錯誤,這個錯誤實際上出現非常頻繁,它不是一個致命錯誤,而是一個在設計的時候就知道會發生的錯誤,它的型別定義為:
pub struct CompareAndSwapError{
/// The current value which caused your CAS to fail.
pub current: Option<IVec>,
/// Returned value that was proposed unsuccessfully.
pub proposed: Option<IVec>,
}
能夠引起該錯誤的 compare_and_swap
函式如下(剔除無關程式碼):
pub fn compare_and_swap<K,OV,NV>(
&self,
key: K,
old: Option<OV>,
new: Option<NV>,
)-> CompareAndSwapResult {
/*snip*/
if self.context.read_only{
// 如果滿足錯誤路徑,則在這裡返回一個致命錯誤
return Err(Error::Unsupported(
"can not perform a cas on a read-only Tree".into(),
));
}
/*snip*/
return Ok(Err(CompareAndSwapError{
current: current_value,
proposed: new,
}));
/*snip*/
returnOk(Ok(()));// 在完全無錯的狀態下返回Ok(Ok())
}
CompareAndSwapResult
型別的完整定義是:Result<Result<(), CompareAndSwapError>, sled::Error>
,看起來很笨重,一點都不美,但它實際上非常精妙:
return Err(Error::Unsupported(
返回的是CompareAndSwapResult
的外層Result
,此時函式內出現了致命錯誤,Err內包裹的是自定義的Error::Unsupported
型別
2和3這兩個是巢狀進外層 Result
裡面的 Ok
,說明即使有錯但問題也不大,不用傳播至最開始的呼叫者(main函式)那裡,直接在函式內處理即可.
return Ok(Err(CompareAndSwapError{
// 包裹了我們定義的CompareAndSwapError
錯誤return Ok(Ok(()))
// 沒有任何錯誤發生
這個看似笨重實則精妙的型別是如何起作用的呢?來觀察一下 compare_and_swap
的呼叫者 basic
函式:
// basic的函式體裡面有對compare_and_swap返回值很清晰的使用方式
fn basic()-> Result<(),sled::Error>{
/*Snap*/
match db.compare_and_swap(k.clone(),Some(&v1.clone()),Some(v2.clone()))?{
Ok(())=>println!("it worked!"),
Err(sled::CompareAndSwapError{current: cur,proposed: _})=>{
println!("the actual current value is {:?}",cur)
}
}
/*Snap*/
let(k1,v1)=iter.next().unwrap().unwrap();// 是不是意味著還是允許有崩潰情況發生的?
/*Snap*/
Ok(())
}
注意 match
處有一個通過 ?
進行的解包操作,如果 compare_and_swap()
的返回值是不是 Err(sled::Error)
,則它一定是 Ok(())
或者 Ok(sled::CompareAndSwapError)
,這說明產生的都是小問題,?
解包操作就直接把返回值給拿出來了,然後通過 match
進行匹配,但是如果 compare_and_swap()
的返回值是 sled::Error
,這問題比較大,因為按照這種錯誤處理的思路,這個列舉的項們都是比較嚴重的錯誤,就必須傳播給 basic()
的 caller
了,此處使用 ?
直接提前返回是正確的操作.經過 basic
函式這麼一處理,設計時就意料到的錯誤就被正確"吞"掉了,而致命錯誤接著向上層轉發.
basic
函式的呼叫者是 main
函式(Rust1.26支援了 main
返回 Result
):
fn main()-> Result<(),sled::Error>{
// 如果basic返回的是Ok(()),這說明basic內部一切正常,沒有任何致命錯誤,皆大歡喜,繼續執行
// 如果basic返回的是sled::Error,這說明basic遇到了致命錯誤,main負責通知程式設計師,所以用?直接提前返回了.
basic()?;
// snip
// 這裡的程式碼們依然有可能返回Err(sled::Error)
// snip
Ok(())// 只要執行到這裡就說明完全沒有錯誤了
}
總結
這就是使用巢狀 Result
的哲學,它看上去不優雅,不美,但是好用,它把錯誤分成兩種,能夠處理的 ( 包括無錯誤的( ),以及設計中預料到的輕微錯誤),將它們包在外層 Result
的 Result::Ok
裡面,而必須人工干預的嚴重錯誤包裹在外層Result的Err裡面. 這構成了 compare_and_swap
的返回型別,然後在呼叫 compare_and_swap
的 basic
函式中使用 match + ?
來解析這個返回值,?
使致命錯誤繼續上傳,其他情況就在basic中消化掉了.
實戰Demo
use std::collections::HashMap;
use std::result::Result;
use std::fmt::Display;
use std::io::Read;
fn main()->Result<(),UnExpectedError>{
let mut m = HashMap::new();
for i in 0..4{
m.insert(i,i);
}
use_wrapper(&mutm,1,1,100,"abc.txt")?;
Ok(())
}
#[derive(Debug)]
enum UnExpectedError{
Io(std::io::Error)
}
struct DesignError{
ov: i32,
nv: i32,
}
// 實現Error trait表明UnExpectedError是個錯誤型別
// 而錯誤型別通過Debug和Display來描述它們自己,所以UnExpectedError又實現了這兩個trait。
impl std::error::Error for UnExpectedError{}
impl Display for UnExpectedError{
fn fmt(&self,f:&mutstd::fmt::Formatter<'_>)->std::fmt::Result{// ???
use self::UnExpectedError::*;
match &self{
Io(refe)=>write!(f,"IO error: {}",e)// ???
}
}
}
// sled作者不是說不讓進行錯誤型別轉換嗎,為什麼這裡還要提供轉換能力呢
// 這是因為sled作者不提倡的行為是:
// 把處理手段不同的錯誤型別們(比如意料內的輕微錯誤和需要人工干預的嚴重錯誤)轉換到一種型別的錯誤上
// 作者在文中把這種單一型別的錯誤稱之為big-ball-of-mud single error enum
// 但是我們這裡的轉換屬於只把那些致命的錯誤(比如std::io::Error)轉換到外部的,全域性的UnExpectedError型別上,
// 這不屬於上述不推薦的行為
// 根據業務邏輯,如有必要,那些致命錯誤型別當然可以不止std::io::Error一種,那麼我們就
// 可以反覆為這個全域性的UnExpectedError實現From trait. impl From<FatalErrorType> for UnExpectedError.
impl From<std::io::Error> for UnExpectedError{
fn from(io_error:std::io::Error)->Self{
UnExpectedError::Io(io_error)
}
}
// 這個From trait是?操作符要求的。比如在use_wrapper的程式碼中我們有 maybe_err(m,k,ov,nv,path)? 的操作
// 而為了讓嚴重錯誤型別繼續向上傳播,且傳播這些嚴重錯誤型別時使用單一型別進行傳播,
// use_wrapper的返回值是Result<(),UnExpectedError>。此處這個單一型別錯誤就是UnExpectedError。
// 當?執行時,得到的是一個std::io::Error, From trait就把它轉成一個UnExpectedError。
// 如果k的v等於使用者猜測的ov,那麼將其替換成nv.如果不是,則這是DesignError; 無論上述哪種情況發生,都列印相關資訊
// 本函式內還會由路徑讀取一個檔案,如果有則列印讀取到的內容,如果沒有這是嚴重的io錯誤
fn maybe_err(m: &mutHashMap<i32,i32>,k: i32,ov: i32,nv: i32,path: &str)-> Result<Result<(),DesignError>,UnExpectedError>
{
use std::fs::File;
// 先整致命錯誤
let mut s = String::new();
File::open(path)?.read_to_string(&muts)?;
println!("{}",s);
// 再整DesignError
if *m.get(&k).unwrap()==ov{
m.insert(k,nv);
}else{
returnOk(Err(DesignError{ov,nv}))
};
Ok(Ok(()))
}
fn use_wrapper(m: &mut HashMap<i32,i32>,k: i32,ov: i32,nv: i32,path: &str)
-> Result <(),UnExpectedError>{
match maybe_err(m,k,ov,nv,path)?{
Ok(()) => println!("{}","It worked!"),
// 如果寫成ov:ref cur, 那麼cur繫結的就是傳進來的那個DesignError的ov值的引用
// 當然也可以寫成ov:ref ov, Rust會提示直接寫成 ref ov更好
// 這裡只能用ref,而不能用&,因為ref就是用來幹這個事的:進行模式匹配時,無論是let還是match,ref關鍵字可以用來建立 結構體/元組 的欄位的引用
Err(DesignError{ ref ov,nv:_ }) => {
println!("The actual current value is {}",ov)
}
}
Ok(())
}
還有一段更簡單清晰的程式碼:
use rand::Rng;
use std::result::Result;
use std::fmt::{Display, Formatter};
use std::io::{Error, Read};
type CompareAndSwapResult=Result<Result<(),DesignedError>,UnExpectedError>;
fn main() {
match use_wapper(){
Ok(())=>{
println!("Ok,everythings done!")
},
Err(e)=>{
println!("We got something wrong! {}",e)
}
}
}
#[derive(Debug)]
enum UnExpectedError{
Io(std::io::Error)
}
struct DesignedError;
impl std::error::Error for UnExpectedError{}
impl Display for UnExpectedError{
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self{
UnExpectedError::Io(e)=>{
write!(f,"IO error: {}",e)
}
}
}
}
impl From<std::io::Error> for UnExpectedError{
fn from(e: Error) -> Self {
UnExpectedError::Io(e)
}
}
fn maybe_err<P:AsRef<std::path::Path>>(path:P)->CompareAndSwapResult{
use std::fs::File;
let mut s = String::new();
File::open(path)?.read_to_string(&mut s)?;
println!("File contents is:{}", s);
let mut rng = rand::thread_rng();
let num = rng.gen_range(0..=1);
if num==0 {
return Ok(Err(DesignedError))
}
Ok(Ok(()))
}
fn use_wapper() -> Result<(), UnExpectedError> {
let result = maybe_err(std::path::Path::new("abc.txt"))?;
if let Err(DesignedError)=result{
println!("DesignedError occurs!")
}
Ok(())
}