Rust 要不要 panic!

2023-03-22 15:10 更新
ch09-03-to-panic-or-not-to-panic.md
commit 199ca99926f232ee7f581a917eada4b65ff21754

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

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

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

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

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

如果方法調(diào)用在測試中失敗了,我們希望這個測試都失敗,即便這個方法并不是需要測試的功能。因為 panic! 會將測試標記為失敗,此時調(diào)用 unwrap 或 expect 是恰當?shù)摹?br>

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

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

    use std::net::IpAddr;

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

我們通過解析一個硬編碼的字符來創(chuàng)建一個 IpAddr 實例??梢钥闯?nbsp;127.0.0.1 是一個有效的 IP 地址,所以這里使用 unwrap 是可以接受的。然而,擁有一個硬編碼的有效的字符串也不能改變 parse 方法的返回值類型:它仍然是一個 Result 值,而編譯器仍然會要求我們處理這個 Result,好像還是有可能出現(xiàn) Err 成員那樣。這是因為編譯器還沒有智能到可以識別出這個字符串總是一個有效的 IP 地址。如果 IP 地址字符串來源于用戶而不是硬編碼進程序中的話,那么就 確實 有失敗的可能性,這時就絕對需要我們以一種更健壯的方式處理 Result 了。

錯誤處理指導原則

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

  • 有害狀態(tài)是非預期的行為,與偶爾會發(fā)生的行為相對,比如用戶輸入了錯誤格式的數(shù)據(jù)。
  • 在此之后代碼的運行依賴于不處于這種有害狀態(tài),而不是在每一步都檢查是否有問題。
  • 沒有可行的手段來將有害狀態(tài)信息編碼進所使用的類型中的情況。我們會在第十七章 “將狀態(tài)和行為編碼為類型” 部分通過一個例子來說明我們的意思。

如果別人調(diào)用你的代碼并傳遞了一個沒有意義的值,最好的情況也許就是 panic! 并警告使用你的庫的人他的代碼中有 bug 以便他能在開發(fā)時就修復它。類似的,如果你正在調(diào)用不受你控制的外部代碼,并且它返回了一個你無法修復的無效狀態(tài),那么 panic! 往往是合適的。

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

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

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

創(chuàng)建自定義類型進行有效性驗證

讓我們使用 Rust 類型系統(tǒng)的思想來進一步確保值的有效性,并嘗試創(chuàng)建一個自定義類型以進行驗證?;貞浺幌碌诙碌牟虏驴从螒颍覀兊拇a要求用戶猜測一個 1 到 100 之間的數(shù)字,在將其與秘密數(shù)字做比較之前我們從未驗證用戶的猜測是位于這兩個數(shù)字之間的,我們只驗證它是否為正。在這種情況下,其影響并不是很嚴重:“Too high” 或 “Too low” 的輸出仍然是正確的。但是這是一個很好的引導用戶得出有效猜測的輔助,例如當用戶猜測一個超出范圍的數(shù)字或者輸入字母時采取不同的行為。

一種實現(xiàn)方式是將猜測解析成 i32 而不僅僅是 u32,來默許輸入負數(shù),接著檢查數(shù)字是否在范圍內(nèi):

    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 表達式檢查了值是否超出范圍,告訴用戶出了什么問題,并調(diào)用 continue 開始下一次循環(huán),請求另一個猜測。if 表達式之后,就可以在知道 guess 在 1 到 100 之間的情況下與秘密數(shù)字作比較了。

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

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

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-13:一個 Guess 類型,它只在值位于 1 和 100 之間時才繼續(xù)

首先,我們定義了一個包含 i32 類型字段 value 的結(jié)構(gòu)體 Guess。這里是儲存猜測值的地方。

接著在 Guess 上實現(xiàn)了一個叫做 new 的關聯(lián)函數(shù)來創(chuàng)建 Guess 的實例。new 定義為接收一個 i32 類型的參數(shù) value 并返回一個 Guess。new 函數(shù)中代碼的測試確保了其值是在 1 到 100 之間的。如果 value 沒有通過測試則調(diào)用 panic!,這會警告調(diào)用這個函數(shù)的程序員有一個需要修改的 bug,因為創(chuàng)建一個 value 超出范圍的 Guess 將會違反 Guess::new 所遵循的契約。Guess::new 會出現(xiàn) panic 的條件應該在其公有 API 文檔中被提及;第十四章會涉及到在 API 文檔中表明 panic! 可能性的相關規(guī)則。如果 value 通過了測試,我們新建一個 Guess,其字段 value 將被設置為參數(shù) value 的值,接著返回這個 Guess。

接著,我們實現(xiàn)了一個借用了 self 的方法 value,它沒有任何其他參數(shù)并返回一個 i32。這類方法有時被稱為 getter,因為它的目的就是返回對應字段的數(shù)據(jù)。這樣的公有方法是必要的,因為 Guess 結(jié)構(gòu)體的 value 字段是私有的。私有的字段 value 是很重要的,這樣使用 Guess 結(jié)構(gòu)體的代碼將不允許直接設置 value 的值:調(diào)用者 必須 使用 Guess::new 方法來創(chuàng)建一個 Guess 的實例,這就確保了不會存在一個 value 沒有通過 Guess::new 函數(shù)的條件檢查的 Guess。

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

總結(jié)

Rust 的錯誤處理功能被設計為幫助你編寫更加健壯的代碼。panic! 宏代表一個程序無法處理的狀態(tài),并停止執(zhí)行而不是使用無效或不正確的值繼續(xù)處理。Rust 類型系統(tǒng)的 Result 枚舉代表操作可能會在一種可以恢復的情況下失敗??梢允褂?nbsp;Result 來告訴代碼調(diào)用者他需要處理潛在的成功或失敗。在適當?shù)膱鼍笆褂?nbsp;panic! 和 Result 將會使你的代碼在面對不可避免的錯誤時顯得更加可靠。

現(xiàn)在我們已經(jīng)見識過了標準庫中 Option 和 Result 泛型枚舉的能力了,在下一章讓我們聊聊泛型是如何工作的,以及如何在你的代碼中使用他們。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號