Rust 是怎麼處理錯誤的?

異常的演進

程序在運行的過程中,總是會不可避免地產生錯誤,而如何優雅地解決錯誤,也是語言的設計哲學之一。那麼現有的主流語言是怎麼處理錯誤的呢?比如調用一個函數,如果函數執行的時候出錯了,那麼該怎麼處理呢。

C 語言

C 是一門古老的語言,通常會以指針作爲參數,在函數內部進行解引用,修改指針指向的值。然後用 1 和 0 代表返回值,如果返回 1,則表示修改成功;返回 0,表示修改失敗。

但這種做法有一個缺陷,就是修改失敗時,無法將原因記錄下來。

C++ 和 Python

引入了 Exception,通過 try catch 可以將異常捕獲,相比 C 進步了一些。但它的缺陷是我們不知道被調用方會拋出什麼異常。

Java

引入了 checked exception,方法的所有者可以聲明自己會拋出什麼異常,然後調用者對異常進行處理。在 Java 程序啓動時,拋出大量異常都是司空見慣的事情,並在相應的調用堆棧中將信息完整地記錄下來。至此,Java 的異常不再是異常,而是一種很普遍的結構,從良性到災難性都有所使用,異常的嚴重性由調用者來決定。

而像 Go、Rust 這樣的新興語言,則採用了與之不同的方式。它們沒有像傳統的高級語言一樣引入 try cache,因爲設計者認爲這會把控制流搞得非常亂。在 Go 和 Rust 裏面,錯誤是通過返回值體現的。

比如打開一個文件,如果文件不存在,像 Python 程序就會直接報錯。但 Go 不一樣,Go 在打開文件的時候會同時返回一個文件句柄和 error,如果文件成功打開,那麼 error 就是空;如果文件打開失敗,那麼 error 就是錯誤原因。

所以對於 Go 而言,在可能出錯的時候,程序會同時返回 value 和 error。如果你要使用 value,那麼必須先對 error 進行判斷。

錯誤和異常

我們上面提到了錯誤 (Error) 和異常(Exception),有很多人分不清這兩者的區別,我們來解釋一下。

在 Python 裏面很少會對錯誤和異常進行區分,甚至將它們視做同一種概念。但在 Go 和 Rust 裏面,錯誤和異常是完全不同的,異常要比錯誤嚴重得多。

當出現錯誤時,開發者是有能力解決的,比如文件不存在。這時候程序並不會有異常產生,而是正常執行,只是作爲返回值的 error 不爲空,開發者要基於 error 進行下一步處理。

但如果出現了異常,那麼一定是代碼寫錯了,開發者無法處理了。比如索引越界,程序會直接 panic 掉,所以在 Rust 裏面異常又叫做不可恢復的錯誤。

不可恢復的錯誤

如果在 Rust 裏面出現了異常,也就是不可恢復的錯誤,那麼就表示開發者希望程序立刻中止掉,不要再執行下去了。

而不可恢復的錯誤,除了程序在運行過程中因爲某些原因自然產生之外,也可以手動引發。

fn main() {
    println!("程序開始執行");
    // 在 Go 裏面引發異常通過 panic 函數
    // Rust 則是通過 panic! 宏,還是挺相似的
    panic!("發生了不可恢復的錯誤");
    println!("程序不會執行到這裏");
}

注意 panic! 和 println! 的參數一致的,都支持字符串格式化輸出。下面看一下輸出結果:

如果將環境變量 RUST_BACKTRACE 設置爲 1,還可以顯示調用棧。

然後除了 panic! 之外,assert 系列的宏也可以生成不可恢復的錯誤。

fn main() {
    // 如果 assert! 裏面的布爾值爲真,無事發生
    // 如果爲假,那麼程序會 panic 掉
    assert!(1 == 2);

    // assert!(1 == 2) 還可以寫成
    assert_eq!(1, 2);

    // 除了 assert_eq! 外,還有 assert_ne!
    assert_ne!(1, 2);

    // 不過最常用的還是 assert!
}

還有一個宏叫 unimplemented!,當我們的代碼還沒有開發完畢時,爲了在別人調用的時候能夠提示調用者,便可以使用這個宏。

fn get_data() {
    unimplemented!("還沒開發完畢,by {}", "古明地覺");
}

fn main() {
    get_data()
}

它和 Python 裏的 raise NotImplementedError 是比較相似的。

最後在 Rust 裏面還有一個常用的宏,用於表示程序不可能執行到某個地方。

fn divide_by_3(nu32) -> u32 {
    // 找到可以滿足 3 * i 大於 n 的最小整數 i
    for i in 0 .. {
        if 3 * i > n {
            return i;
        }
    }
    // 顯然程序不可能執行到這裏
    // 因爲 for 循環是無限進行的,最終一定會 return
    // 但 Rust 在編譯時,從語法上是判斷不出來的
    // 它只知道這個函數目前不完整,因爲如果 for 循環結束,
    // 那麼返回值就不符合 u32 類型了,儘管我們知道 for 循環不可能結束

    // 爲此我們可以隨便 return 一個 u32,並寫上註釋
    // "此處是爲了保證函數簽名合法,但程序不會執行到這裏"
    // 而更專業的做法是使用一個宏
    unreachable!("程序不可能執行到這裏");
}

如果程序真的執行到了該宏所在的地方,那麼同樣會觸發一個不可恢復的錯誤。

以上就是 Rust 裏面的幾個用於創建不可恢復的錯誤的幾個宏。

可恢復的錯誤

說完了不可恢復的錯誤,再來看看可恢復的錯誤,一般稱之爲錯誤。在 Go 裏面錯誤是通過多返回值實現的,如果程序可能出現錯誤,那麼會多返回一個 error,然後根據 error 是否爲空來判斷究竟有沒有產生錯誤。所以開發者必須先對 error 進行處理,然後纔可以執行下一步,不應該對 error 進行假設。

而 Rust 的錯誤機制和 Go 類似,只不過是通過枚舉實現的,該枚舉叫 Result,我們看一下它的定義。

pub enum Result<T, E> {
    Ok(T),
    Err(E),
}

如果將定義簡化一下,那麼就是這個樣子。可以看到它就是一個簡單的枚舉,並且帶有兩個泛型。我們之前也介紹過一個枚舉叫 Option,用來處理空值的,內部有兩個成員,分別是 Some 和 None。

然後枚舉 Result 和 Option 一樣,它和內部的成員都是可以直接拿來用的,我們實際舉個例子演示一下吧。

// 計算兩個 i32 的商
fn divide(ai32, bi32) -> Result<i32, &'static str> {
    let retResult<i32, &'static str>;
    // 如果 b != 0,返回 Ok(a / b)
    if b != 0 {
        ret = Ok(a / b);
    } else {
        // 否則返回除零錯誤
        ret = Err("ZeroDivisionError: division by zero")
    }
    return ret;
}

fn main() {
    let a = divide(100, 20);
    println!("a = {:?}", a);

    let b = divide(100, 0);
    println!("b = {:?}", b);
    /*
    a = Ok(5)
    b = Err("ZeroDivisionError: division by zero")
    */
}

打印結果如我們所料,但 Rust 和 Go 一樣,都要求我們提前對 error 進行處理,並且 Rust 比 Go 更加嚴格。對於 Go 而言,在沒有發生錯誤的時候,即使我們不對 error 做處理(不推薦),也是沒問題的。而 Rust 不管會不會發生錯誤,都要求對 error 進行處理。

因爲 Rust 返回的是枚舉,比如上面代碼中的 a 是一個 Ok(i32),即便沒有發生錯誤,這個 a 也不能直接用,必須使用 match 表達式處理一下。

fn main() {
    // 將返回值和 5 相加,由於 a 是 Ok(i32)
    // 顯然它不能直接和 i32 相加
    let a = divide(100, 20);
    match a {
        Ok(i) => println!("a + 5 = {}", i + 5),
        Err(error) => println!("出錯啦: {}", error),
    }

    let b = divide(100, 0);
    match b {
        Ok(i) => println!("b + 5 = {}", i + 5),
        Err(error) => println!("出錯啦: {}", error),
    }
    /*
    a + 5 = 10
    出錯啦: ZeroDivisionError: division by zero
    */
}

雖然這種編碼方式會讓人感到有點麻煩,但它杜絕了出現運行時錯誤的可能。相比運行時報錯,我們寧可在編譯階段多費些功夫。

自定義錯誤和問號表達式

我們說 Rust 爲了避免控制流混亂,並沒有引入 try cache 語句。但 try cache 也有它的好處,就是可以完整地記錄堆棧信息,從錯誤的根因到出錯的地方,都能完整地記錄下來,舉個 Python 的例子:

程序報錯了,根因是調用了函數 f,而出錯的地方是在第 10 行,我們手動 raise 了一個異常。可以看到程序將整個錯誤的鏈路全部記錄下來了,只要從根因開始一層層往下定位,就能找到錯誤原因。

而對於 Go 和 Rust 來說就不方便了,特別是 Go,如果每返回一個 error,就打印一次,那麼會將 error 打的亂七八糟的。所以我們更傾向於錯誤能夠在上下文當中傳遞,對於 Rust 而言,我們可以通過問號表達式來實現這一點。

fn external_some_func() -> Result<u32, &'static str> {
    // 外部的某個函數
    Ok(666)
}

fn call1() -> Result<f64, &'static str> {
    // 我們要調用 external_some_func
    match external_some_func() {
        // 類型轉化在 Rust 裏面通過 as 關鍵字
        Ok(i) => Ok((i + 1) as f64),
        Err(error) => Err(error)
    }
}

// 但是上面這種調用方式有點繁瑣
// 我們還可以使用問號表達式
fn call2() -> Result<f64, &'static str> {
    // 注:使用問號表達式有一個前提
    // 調用方和被調用方的返回值都要是 Result 枚舉類型
    // 並且它們的錯誤類型要相同,比如這裏都是 &'static str
    let ret = external_some_func()?;
    Ok((ret + 1) as f64)
}

fn main() {
    println!("{:?}", call1());  // Ok(667.0)
    println!("{:?}", call2());  // Ok(667.0)
}

裏面的 call1 和 call2 是等價的,如果在 call2 裏面函數調用出錯了,那麼會自動將錯誤返回。並且注意 call2 裏面的 ret,它是 u32,不是 Ok(u32)。因爲函數調用出錯會直接返回,不出錯則會將 Ok 裏面的 u32 取出來賦值給 ret。

然後我們說如果 external_some_func 函數執行出錯了,那麼 call2 就直接將錯誤返回了,程序不會再往下執行。所以這也側面要求,call2 和 external_some_func 的返回值類型都是 Result,並且裏面的錯誤類型也要一樣,否則函數簽名是不合法的。

fn external_some_func() -> Result<u32, &'static str> {
    // 外部的某個函數
    Err("函數執行出錯")
}

fn call1() -> Result<f64, &'static str> {
    match external_some_func() {
        Ok(i) => Ok((i + 1) as f64),
        Err(error) => Err(error)
    }
}

fn call2() -> Result<f64, &'static str> {
    let ret = external_some_func()?;
    Ok((ret + 1) as f64)
}

fn main() {
    println!("{:?}", call1());  // Err("函數執行出錯")
    println!("{:?}", call2());  // Err("函數執行出錯")
}

此時錯誤就自動地在上下文當中傳遞了,並且還更簡潔,只需要在函數調用後面加一個問號即可。

再來考慮一種更復雜的情況,我們在調用函數的時候可能會調用多個函數,而這多個函數的錯誤類型不一樣該怎麼辦呢?

struct FileNotFoundError {
    errString,
    filenameString,
}

struct IndexError {
    err&'static str,
    indexu32,
}

fn external_some_func1() -> Result<u32, FileNotFoundError> {
    Err(FileNotFoundError {
        errString::from("文件不存在"),
        filenameString::from("main.py"),
    })
}

fn external_some_func2() -> Result<i32, IndexError> {
    Err(IndexError {
        err"索引越界了",
        index9,
    })
}

很多時候,錯誤並不是一個簡單的字符串,因爲那樣能攜帶的信息太少。基本上都是一個結構體,文字格式的錯誤信息只是裏面的字段之一,而其它字段則負責描述更加詳細的上下文信息。

我們上面有兩個函數,是一會兒我們要調用的,但問題是它們返回的錯誤類型不同,也就是 Result<T, E> 裏面的 E 不同。而如果是這種情況的話,問號表達式就會失效,那麼我們應該怎麼做呢?

// 其它代碼不變
#[derive(Debug)]
enum MyError {
    Error1(FileNotFoundError),
    Error2(IndexError)
}

// 爲 MyError 實現 From trait
// 分別是 From<FileNotFoundError> 和 From<IndexError>
impl From<FileNotFoundError> for MyError {
    fn from(errorFileNotFoundError) -> MyError {
        MyError::Error1(error)
    }
}

impl From<IndexError> for MyError {
    fn from(errorIndexError) -> MyError {
        MyError::Error2(error)
    }
}

fn call1() -> Result<i32, MyError>{
    // 調用的兩個函數、和當前函數返回的錯誤類型都不相同
    // 但是當前函數是合法的,因爲 MyError 實現了 From trait
    // 當錯誤類型是 FileNotFoundError 或 IndexError 時
    // 它們會調用 MyError 實現的 from 方法
    // 然後將錯誤統一轉換爲 MyError 類型
    let x = external_some_func1()?;
    let y = external_some_func2()?;
    Ok(x as i32 + y)
}

fn call2() -> Result<i32, MyError>{
    let y = external_some_func2()?;
    let x = external_some_func1()?;
    Ok(x as i32 + y)
}

fn main() {
    println!("{:?}", call1());
    /*
    Err(Error1(FileNotFoundError { err: "文件不存在", filename: "main.py" }))
    */
    println!("{:?}", call2());
    /*
    Err(Error2(IndexError { err: "索引越界了", index: 9 }))
    */
}

如果調用的多個函數返回的錯誤類型相同,那麼只需要保證調用方也返回相同的錯誤類型,即可使用問號表達式。但如果調用的多個函數返回的錯誤類型不同,那麼這個時候調用方就必須使用一個新的錯誤類型,其數據結構通常爲枚舉。

而枚舉裏的成員要包含所有可能發生的錯誤類型,比如這裏的 FileNotFoundError 和 IndexError。然後爲枚舉實現 From trait,該 trait 帶了一個泛型,並且內部定義了一個 from 方法。

我們在實現之後,當出現 FileNotFoundError 和 IndexError 的時候,就會調用 from 方法,轉成調用方的 MyError 類型,然後返回。

因此這就是 Rust 處理錯誤的方式,可能有一些難理解,需要私下多琢磨琢磨。最後再補充一點,我們知道 main 函數應該返回一個空元組,但除了空元組之外,它也可以返回一個 Result。

fn main() -> Result<(), MyError> {
    // 如果 call1() 的後面沒有加問號
    // 那麼在調用沒有出錯的時候,返回的就是 Ok(...)
    // 調用出錯的時候,返回的就是 Err(...)
    // 但不管哪一種,都是 Result<T, E> 類型
    println!("{:?}", call1());

    // 如果加了 ? 那麼就不一樣了
    // 在調用沒出錯的時候,會直接將 Ok(...) 裏面的值取出來
    // 調用出錯的時候,當前函數會中止運行,
    // 並將被調用方(這裏是 call2)的錯誤作爲調用方(這裏是 main)的返回值返回
    // 此時通過問號表達式,就實現了錯誤在上下文當中傳遞
    // 所以這也要求被調用方返回的錯誤類型要和調用方相同
    println!("{:?}", call2()?);

    // 爲了使函數簽名合法,這裏要返回一個值,直接返回 Ok(()) 即可
    // 但上面的 call2()? 是會報錯的,所以它下面的代碼都不會執行
    Ok(())
}

我們執行一下看看輸出:

由於 main 函數已經是最頂層的調用方了,所以出錯的時候,直接將錯誤拋出來了。

小結

以上就是 Rust 的錯誤處理,相比其它語言來說,確實難理解了一些。另外從該系列的開始到現在,我們介紹的都屬於基礎內容,而且有些地方介紹的還不夠詳細,後續我們會將這些內容以更深入的方式做一個補充。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/6xueuoGM8Qgce9qeQNjnVA