panic! 還是不 panic!

ch09-03-to-panic-or-not-to-panic.md
commit 76df60bccead5f3de96db23d97b69597cd8a2b82

那麼,該如何決定何時應該 panic! 以及何時應該返回 Result 呢?如果代碼 panic,就沒有恢復的可能。你可以選擇對任何錯誤場景都調用 panic!,不管是否有可能恢復,不過這樣就是你代替調用者決定了這是不可恢復的。選擇返回 Result 值的話,就將選擇權交給了調用者,而不是代替他們做出決定。調用者可能會選擇以符合他們場景的方式嘗試恢復,或者也可能乾脆就認為 Err 是不可恢復的,所以他們也可能會調用 panic! 並將可恢復的錯誤變成了不可恢復的錯誤。因此返回 Result 是定義可能會失敗的函數的一個好的預設選擇。

有一些情況 panic 比返回 Result 更為合適,不過他們並不常見。讓我們討論一下為何在範例、代碼原型和測試中,以及那些人們認為不會失敗而編譯器不這麼看的情況下, panic 是合適的。章節最後會總結一些在庫代碼中如何決定是否要 panic 的通用指導原則。

範例、代碼原型和測試都非常適合 panic

當你編寫一個範例來展示一些概念時,在擁有健壯的錯誤處理代碼的同時也會使得例子不那麼明確。例如,調用一個類似 unwrap 這樣可能 panic! 的方法可以被理解為一個你實際希望程序處理錯誤方式的占位符,它根據其餘代碼運行方式可能會各不相同。

類似地,在我們準備好決定如何處理錯誤之前,unwrapexpect方法在原型設計時非常方便。當我們準備好讓程序更加健壯時,它們會在代碼中留下清晰的標記。

如果方法調用在測試中失敗了,我們希望這個測試都失敗,即便這個方法並不是需要測試的功能。因為 panic! 會將測試標記為失敗,此時調用 unwrapexpect 是恰當的。

當我們比編譯器知道更多的情況

當你有一些其他的邏輯來確保 Result 會是 Ok 值時,調用 unwrap 也是合適的,雖然編譯器無法理解這種邏輯。你仍然需要處理一個 Result 值:即使在你的特定情況下邏輯上是不可能的,你所調用的任何操作仍然有可能失敗。如果通過人工檢查代碼來確保永遠也不會出現 Err 值,那麼調用 unwrap 也是完全可以接受的,這裡是一個例子:


#![allow(unused)]
fn main() {
use std::net::IpAddr;

let home: IpAddr = "127.0.0.1".parse().unwrap();
}

我們透過解析一個寫死的字元來創建一個 IpAddr 實例。可以看出 127.0.0.1 是一個有效的 IP 地址,所以這裡使用 unwrap 是可以接受的。然而,擁有一個寫死的有效的字串也不能改變 parse 方法的返回值類型:它仍然是一個 Result 值,而編譯器仍然會要求我們處理這個 Result,好像還是有可能出現 Err 成員那樣。這是因為編譯器還沒有智慧到可以識別出這個字串總是一個有效的 IP 地址。如果 IP 地址字串來源於用戶而不是寫死進程序中的話,那麼就 確實 有失敗的可能性,這時就絕對需要我們以一種更健壯的方式處理 Result 了。

錯誤處理指導原則

在當有可能會導致有害狀態的情況下建議使用 panic! —— 在這裡,有害狀態是指當一些假設、保證、協議或不可變性被打破的狀態,例如無效的值、自相矛盾的值或者被傳遞了不存在的值 —— 外加如下幾種情況:

  • 有害狀態並不包含 預期 會偶爾發生的錯誤
  • 在此之後代碼的運行依賴於不處於這種有害狀態
  • 當沒有可行的手段來將有害狀態訊息編碼進所使用的類型中的情況

如果別人調用你的代碼並傳遞了一個沒有意義的值,最好的情況也許就是 panic! 並警告使用你的庫的人他的代碼中有 bug 以便他能在開發時就修復它。類似的,panic! 通常適合調用不能夠控制的外部代碼時,這時無法修復其返回的無效狀態。

然而當錯誤預期會出現時,返回 Result 仍要比調用 panic! 更為合適。這樣的例子包括解析器接收到格式錯誤的數據,或者 HTTP 請求返回了一個表明觸發了限流的狀態。在這些例子中,應該通過返回 Result 來表明失敗預期是可能的,這樣將有害狀態向上傳播,調用者就可以決定該如何處理這個問題。使用 panic! 來處理這些情況就不是最好的選擇。

當代碼對值進行操作時,應該首先驗證值是有效的,並在其無效時 panic!。這主要是出於安全的原因:嘗試操作無效數據會暴露代碼漏洞,這就是標準庫在嘗試越界訪問數組時會 panic! 的主要原因:嘗試訪問不屬於當前數據結構的記憶體是一個常見的安全隱患。函數通常都遵循 契約contracts):他們的行為只有在輸入滿足特定條件時才能得到保證。當違反契約時 panic 是有道理的,因為這通常代表調用方的 bug,而且這也不是那種你希望所調用的代碼必須處理的錯誤。事實上所調用的代碼也沒有合理的方式來恢復,而是需要調用方的 程式設計師 修復其代碼。函數的契約,尤其是當違反它會造成 panic 的契約,應該在函數的 API 文件中得到解釋。

雖然在所有函數中都擁有許多錯誤檢查是冗長而煩人的。幸運的是,可以利用 Rust 的類型系統(以及編譯器的類型檢查)為你進行很多檢查。如果函數有一個特定類型的參數,可以在知曉編譯器已經確保其擁有一個有效值的前提下進行你的代碼邏輯。例如,如果你使用了一個並不是 Option 的類型,則程序期望它是 有值 的並且不是 空值。你的代碼無需處理 SomeNone 這兩種情況,它只會有一種情況就是絕對會有一個值。嘗試向函數傳遞空值的代碼甚至根本不能編譯,所以你的函數在運行時沒有必要判空。另外一個例子是使用像 u32 這樣的無符號整型,也會確保它永遠不為負。

創建自訂類型進行有效性驗證

讓我們使用 Rust 類型系統的思想來進一步確保值的有效性,並嘗試創建一個自訂類型以進行驗證。回憶一下第二章的猜猜看遊戲,我們的代碼要求用戶猜測一個 1 到 100 之間的數字,在將其與秘密數字做比較之前我們從未驗證用戶的猜測是位於這兩個數字之間的,我們只驗證它是否為正。在這種情況下,其影響並不是很嚴重:“Too high” 或 “Too low” 的輸出仍然是正確的。但是這是一個很好的引導用戶得出有效猜測的輔助,例如當用戶猜測一個超出範圍的數字或者輸入字母時採取不同的行為。

一種實現方式是將猜測解析成 i32 而不僅僅是 u32,來默許輸入負數,接著檢查數字是否在範圍內:

loop {
    // --snip--

    let guess: i32 = match guess.trim().parse() {
        Ok(num) => num,
        Err(_) => continue,
    };

    if guess < 1 || guess > 100 {
        println!("The secret number will be between 1 and 100.");
        continue;
    }

    match guess.cmp(&secret_number) {
    // --snip--
}

if 表達式檢查了值是否超出範圍,告訴用戶出了什麼問題,並調用 continue 開始下一次循環,請求另一個猜測。if 表達式之後,就可以在知道 guess 在 1 到 100 之間的情況下與秘密數字作比較了。

然而,這並不是一個理想的解決方案:如果讓程序僅僅處理 1 到 100 之間的值是一個絕對需要滿足的要求,而且程序中的很多函數都有這樣的要求,在每個函數中都有這樣的檢查將是非常冗餘的(並可能潛在的影響性能)。

相反我們可以創建一個新類型來將驗證放入創建其實例的函數中,而不是到處重複這些檢查。這樣就可以安全的在函數簽名中使用新類型並相信他們接收到的值。範例 9-10 中展示了一個定義 Guess 類型的方法,只有在 new 函數接收到 1 到 100 之間的值時才會創建 Guess 的實例:


#![allow(unused)]
fn main() {
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }

        Guess {
            value
        }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}

範例 9-10:一個 Guess 類型,它只在值位於 1 和 100 之間時才繼續

首先,我們定義了一個包含 i32 類型欄位 value 的結構體 Guess。這裡是儲存猜測值的地方。

接著在 Guess 上實現了一個叫做 new 的關聯函數來創建 Guess 的實例。new 定義為接收一個 i32 類型的參數 value 並返回一個 Guessnew 函數中代碼的測試確保了其值是在 1 到 100 之間的。如果 value 沒有通過測試則調用 panic!,這會警告調用這個函數的程式設計師有一個需要修改的 bug,因為創建一個 value 超出範圍的 Guess 將會違反 Guess::new 所遵循的契約。Guess::new 會出現 panic 的條件應該在其公有 API 文件中被提及;第十四章會涉及到在 API 文件中表明 panic! 可能性的相關規則。如果 value 通過了測試,我們新建一個 Guess,其欄位 value 將被設置為參數 value 的值,接著返回這個 Guess

接著,我們實現了一個借用了 self 的方法 value,它沒有任何其他參數並返回一個 i32。這類方法有時被稱為 getter,因為它的目的就是返回對應欄位的數據。這樣的公有方法是必要的,因為 Guess 結構體的 value 欄位是私有的。私有的欄位 value 是很重要的,這樣使用 Guess 結構體的代碼將不允許直接設置 value 的值:調用者 必須 使用 Guess::new 方法來創建一個 Guess 的實例,這就確保了不會存在一個 value 沒有通過 Guess::new 函數的條件檢查的 Guess

於是,一個接收(或返回) 1 到 100 之間數字的函數就可以聲明為接收(或返回) Guess的實例,而不是 i32,同時其函數體中也無需進行任何額外的檢查。

總結

Rust 的錯誤處理功能被設計為幫助你編寫更加健壯的代碼。panic! 宏代表一個程序無法處理的狀態,並停止執行而不是使用無效或不正確的值繼續處理。Rust 類型系統的 Result 枚舉代表操作可能會在一種可以恢復的情況下失敗。可以使用 Result 來告訴代碼調用者他需要處理潛在的成功或失敗。在適當的場景使用 panic!Result 將會使你的代碼在面對不可避免的錯誤時顯得更加可靠。

現在我們已經見識過了標準庫中 OptionResult 泛型枚舉的能力了,在下一章讓我們聊聊泛型是如何工作的,以及如何在你的代碼中使用他們。