要 panic! 還是不要 panic!

所以你該如何決定何時要呼叫 panic! 還是要回傳 Result 呢?當程式碼恐慌時,就沒有任何恢復的方式。你可以在任何錯誤場合呼叫 panic!,無論是可能或不可能復原的情況。不過這樣你就等於替呼叫你的程式碼的呼叫者做出決定,讓情況變成無法復原的錯誤了。當你選擇回傳 Result 數值,你將決定權交給呼叫者的程式碼。呼叫者可能會選擇符合當下場合的方式嘗試復原錯誤,或者它可以選擇 Err 內的數值是不可恢復的,所以它就呼叫 panic! 讓你原本可恢復的錯誤轉成不可恢復。因此,當你定義可能失敗的函式時預設回傳 Result 是不錯的選擇。

在像是範例、草稿與測試的情況下,程式碼恐慌會比回傳 Result 來得恰當。讓我們來探討為何比較好。然後我們再來討論編譯器無法辨別出不可能失敗,但身為人類的你卻可以的情況。本章節會總結一些通用指導原則來決定何時在函式庫程式碼中恐慌。

範例、程式碼原型與測試

當你在寫解釋一些概念的範例時,寫出完善錯誤處理的範例,反而會讓範例變得較不清楚。在範例中,使用像是 unwrap 這樣會恐慌的方法可以被視為是一種要求使用者自行決定如何處理錯誤的表現,因為他們可以依據程式碼執行的方式來修改此方法。

同樣地 unwrapexpect 方法也很適用在試做原型,你可以在決定準備開始處理錯誤前使用它們。它們會留下清楚的痕跡,當你準備好要讓程式碼更穩固時,你就能回來修改。

如果有方法在測試內失敗時,你會希望整個測試都失敗,就算該方法不是要測試的功能。因為 panic! 會將測試標記為失敗,所以在此呼叫 unwrapexpect 是很正確的。

當你知道的比編譯器還多的時候

如果你知道一些編譯器不知道的邏輯的話,直接在 Result 呼叫 unwrapexpect 來直接取得 Ok 的數值是很有用的。你還是會有個 Result 數值需要做處理,你呼叫的程式碼還是有機會失敗的,就算在你的特定場合中邏輯上是不可能的。如果你能保證在親自審閱程式碼後,你絕對不可能會有 Err 變體的話,那麼呼叫 unwrap 是完全可以接受的。而更好的話,用 expect 說明為何你一定不會遇到 Err 變體。以下範例就是如此:

fn main() {
    use std::net::IpAddr;

    let home: IpAddr = "127.0.0.1"
        .parse()
        .expect("寫死的 IP 位址應該要有效");
}

我們傳遞寫死的字串來建立 IpAddr 的實例。我們可以看出 127.0.0.1 是完全合理的 IP 位址,所以這邊我們可以直接 expect。不過使用寫死的合理字串並不會改變 parse 方法的回傳型別,我們還是會取得 Result 數值,編譯器仍然會要我們處理 Result 並認為 Err 變體是有可能發生的。因為編譯器並沒有聰明到可以看出此字串是個有效的 IP 位址。如果 IP 位址的字串是來自使用者輸入而非我們寫死進程式的話,它的確有可能會失敗,這時我們就得要認真處理 Result 了。註明該 IP 位址是寫死的能在未來我們想拿掉 expect 並改善錯誤處理時,幫助我們理解需要如何從其他來源處理 IP 位址。

錯誤處理的指導原則

當你的程式碼可能會導致嚴重狀態的話,就建議讓你的程式恐慌。這裡的嚴重狀態是指一些假設、保證、協議或不可變性被打破時的狀態,像是當你的程式碼有無效的數值、互相矛盾的數值或缺少數值。另外還加上以下情形:

  • 該嚴重狀態並非預期會發生的,而不是像使用者輸入了錯誤格式這種偶而可能會發生的。
  • 你的程式在此時需要避免這種嚴重狀態,而不是在每一步都處理此問題。
  • 你所使用的型別沒有適合的方式能夠處理此嚴重狀態。我們會在第十七章的「定義狀態與行為成型別」段落用範例解釋我們指的是什麼。

如果有人呼叫了你的程式碼卻傳遞了不合理的數值,如果可以的話最好的辦法是回傳個錯誤,這樣函式庫的使用者可以決定在該情況下該如何處理。不過要是繼續執行下去可能會造成危險或不安全的話,最好的辦法是呼叫 panic! 並警告使用函式庫的人他們程式碼錯誤發生的位置,好讓他們在開發時就能修正。同樣地,panic! 也適合用於如果你呼叫了你無法掌控的外部程式碼,然後它回傳了你無法修正的無效狀態。

不過如果失敗是可預期的,回傳 Result 就會比呼叫 panic! 來得好。類似的例子有,語法分析器(parser)收到格式錯誤的資訊,或是 HTTP 請求回傳了一個狀態,告訴你已經達到請求上限了。在這樣的案例,回傳 Result 代表失敗是預期有時會發生的,而且呼叫者必須決定如何處理。

當你的程式碼可能會因為進行運算時輸入無效數值,而造成使用者安危的話,你的程式需要先驗證該數值,如果數值無效的話就要恐慌。這是基於安全原則,嘗試對無效資料做運算的話可能會導致你的程式碼產生漏洞。這也是標準函式庫在你嘗試取得超出界限的記憶體存取會呼叫 panic! 的主要原因。嘗試取得不屬於當前資料結構的記憶體是常見的安全問題。函式通常都會訂下一些合約(contracts),它們的行為只有在輸入資料符合特定要求時才帶有保障。當違反合約時恐慌是十分合理的,因為違反合約就代表這是呼叫者的錯誤,這不是你的程式碼該主動處理的錯誤。事實上,呼叫者也沒有任何合理的理由來復原這樣的錯誤。函式的合約應該要寫在函式的技術文件中解釋,尤其是違反時會恐慌的情況。

然而要在你的函式寫一大堆錯誤檢查有時是很冗長且麻煩的。幸運的是你可以利用 Rust 的型別系統(以及編譯器的型別檢查)來幫你完成檢驗。如果你的函式用特定型別作為參數的話,你就可以認定你的程式邏輯是編輯器已經幫你確保你拿到的數值是有效的。舉例來說,如果你有一個型別而非 Option 的話,你的程式就會預期取得某個值而不是沒拿到值。你的程式就不必處理 SomeNone 這兩個變體情形,它只會有一種情況並絕對會拿到數值。要是有人沒有傳遞任何值給你的函式會根本無法編譯,所以你的函式就不需要在執行時做檢查。另一個例子是使用非帶號整數像是 u32 來確保參數不會是負數。

建立自訂型別來驗證

讓我們來試著使用 Rust 的型別系統來進一步確保我們擁有有效數值,並建立自訂型別來驗證。回想一下第二章的猜謎遊戲,我們的程式碼要使用者從 1 到 100 之間猜一個數字。在開始與祕密數字做比較之前,我們從未驗證使用者輸入的值,我們只驗證了它是否為正的。在這種情況帶來的後果還不算嚴重:我們還是會顯示「太大」或「太小」。但是我們可以改善這段來引導使用者輸入有效數值,並在使用者輸入時猜了超出範圍的數字或字母時呈現不同行為。

我們可以將輸入的猜測分析改成 i32 而非 u32 來允許負數,並檢查數字是否在範圍內,如以下所示:

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("請猜測一個數字!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        // --省略--

        println!("請輸入你的猜測數字。");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("讀取行數失敗");

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

        if guess < 1 || guess > 100 {
            println!("祕密數字介於 1 到 100 之間。");
            continue;
        }

        match guess.cmp(&secret_number) {
            // --省略--
            Ordering::Less => println!("太小了!"),
            Ordering::Greater => println!("太大了!"),
            Ordering::Equal => {
                println!("獲勝!");
                break;
            }
        }
    }
}

if 表達式檢查我們的數值是否超出範圍,如果是的話就告訴使用者問題原因,並呼叫 continue 來進行下一次的猜測循環,要求再猜一次。在 if 表達式之後我們就能用已經知道範圍是在 1 到 100 的 guess 與祕密數字做比較。

不過這並非理想解決方案:如果程式必定要求數值一定要是 1 到 100,而且我們有很多函式都有此需求的話,在每個函式都檢查就太囉唆了(而且可能會影響效能)。

對此我們可以建立一個新的型別,並且建立一個驗證產生實例的函式,這樣我們就不必在每個地方都做驗證。這樣一來函式就可以安全地以這個新型別作為簽名,並放心地使用收到的數值。範例 9-13 顯示了定義 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!("猜測數字必須介於 1 到 100 之間,你輸入的是 {}。", value);
        }

        Guess { value }
    }

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

範例 9-13:只會擁有 1 到 100 的 Guess 型別

首先我們定義了一個結構體叫做 Guess,其欄位叫做 value 並會持有 i32。這就是數字會被儲存的地方。

接著我們實作一個 Guess 的關聯函式叫做 new 來建立 Guess 的值。new 函式定義的參數叫做 value 並擁有型別 i32,且最後會回傳 Guess。函式 new 本體中的程式碼會驗證 value 確保它位於 1 到 100 之間。如果 value 沒有通過驗證,我們呼叫 panic! 來警告呼叫此程式碼的開發者,他們可能有需要修正的程式錯誤,因為使用超出範圍的 value 來建立 Guess 違反了 Guess::new 的合約。Guess::new 會恐慌的情況需要在公開的 API 技術文件中提及。我們會在第十四章討論如何寫出技術文件並在 API 技術文件中指出可能發生 panic! 的情形。如果 value 通過驗證的話,我們就建立一個新的 Guess 並將參數 value 賦值給 value 欄位,最後回傳 Guess

接著我們實作了個方法叫做 value,它會借用 self 且沒有任何參數,並會回傳 i32。這種方法有時會被稱為 getter,因為它的目的是從它的欄位中取得一些資料並回傳它。此公開方法是必要的,因為 Guess 結構體中的 value 欄位是私有的。將 Guess 結構體的 value 欄位設為私有是很重要的,這樣就無法直接設置 value ,模組外的程式碼必須使用 Guess::new 函式來建立 Guess 的實例,因而確保 Guess 不可能會有沒有經過 Guess::new 函式驗證的 value

這樣當函式的參數或回傳值只能是數字 1 到 100 的話,它的簽名就能使用或回傳 Guess 而不是 i32,因此就不必在它的本體內做任何額外檢查。

總結

Rust 的錯誤檢查功能的設計旨在協助你寫出可靠的程式碼。panic! 巨集告訴你的程式遇到了它無法處理的狀態,並讓你告訴程序停止,而不是繼續嘗試使用無效或不正確的數值。Result 列舉使用 Rust 的型別系統來指出可能會失敗的運算,並讓你的程式碼有辦法恢復。你可以使用 Result 來告訴使用你的程式碼的呼叫者,他們需要處理可能成功與失敗的情形。在適當的場合使用 panic!Result 能讓你的程式碼在不可避免的問題中更加可靠。

現在你已經看過標準函式庫中 OptionResult 使用泛型的優勢了,就讓我們來討論泛型如何運作的,以及你如何在程式碼中使用它們。