Rust 代碼啓發之錯誤處理

編寫程序,錯誤處理是不可避免的。

但程序員總是偏向正常的情況,而容易忽略有錯誤的情況。

返回值錯誤處理


最開始,錯誤是通過返回值來表示,比如非零表示錯誤,0 表示成功。而處理錯誤的代碼類似

hr = step1();


if hr != 0 {
  handle error;
}
hr = step2();
if hr != 0 {
  handle error;
}
hr = step3();
if hr != 0 {
  handle error;
}

這樣的代碼,錯誤處理代碼和業務邏輯交織在一起,也容易忽略處理錯誤。以及把返回值只用於錯誤返回,有點浪費的感覺。因爲很多時候把計算結果作爲返回值,更符合思考的邏輯。

異常錯誤處理


後面出現了異常的方式,在出錯的時候,拋出異常。異常一層一層往上拋,如果沒有處理異常,那麼程序就會被 terminate. 比如 C++ 和 Java 採用這種方式。

使用異常的代碼類似

try {
  step1();
  step2();
  step3();
} catch(...) {
  handle error.
}

看起來錯誤處理代碼與業務邏輯分開,比較清晰。但有如下的不足,

注: python 把異常還用於程序控制流改變,如 StopInteractionException 用於跳出循環。

Java 裏異常還分 checked exception 和 unchecked exception。checked exception 是必須要處理的異常,從而可以避免被忽略。但 checked exception 有其侷限性,比如添加新的 checked exception,會改變接口簽名,變得不能向前兼容。

綜上,我們需要一種錯誤處理

其中返回值和異常都可能會被無意識忽略。可讀性,異常好於返回值,且避免佔用了返回值。而不可忽略的 Java checked exception 有它自己的問題。

就沒有其他更好的方式了嗎?Rust 給出了它的答案,使用 Result 類型。

什麼是 Result 和類型?


Result 的完整形態是 Result<T, E>,其中 T 和 E 是泛型參數。不懂泛型不重要,這裏跟泛型沒有關係。我們要知道的是 Result 是兩個類型的集合:

第一點,我們可以看到,現在返回值可以用於返回函數計算的結果了,沒有被錯誤佔領。

第二點,因爲返回的值又不是計算結果,所以程序員不能直接使用返回值,需要先檢查具體的類型,沒有出錯時,才能使用計算結果。這樣又避免了無意識的忽略錯誤。

我們可以簡陋地認爲 Result 類型,是 C++ 裏面的 tag union,即包含一個 tag 的 union。其中 tag 是錯誤標記,如果是 0 表示成功,非零表示錯誤,而 union 則存放着具體的錯誤或者具體的計算結果。(很多時候 Result,稱作是和類型 sum type)

可以避免無意識地忽略錯誤,那麼可讀性呢?

因爲返回值不是計算結果,需要檢查一下才能繼續下一步,這不就跟錯誤返回值一樣了嗎?

注:先把話說明,沒有錯誤處理的代碼是可讀性最好的。因爲只有 happy path,第一步,第二步等等。但我們討論在可能出錯的時候的可讀性。

Result 和類型的代碼可以是

match step1() {
  Ok(o) => { 
       match step2() { }
  }
  Err(e) => { handle erro}}

哇咔咔,這看上去可讀性很差那。實話說,這麼寫的代碼的確沒有什麼可讀性。

但 Rust 提供了另外一個寫法,如下

let res = step1()?;let res= step2()?;let res = step3()?;

這個寫法看起來很像異常的情況。業務邏輯和錯誤處理沒有交織在一起。

眼尖的讀者會發現每個函數都有個問號?。而錯誤處理就藏在?後面。

問號的存在,讓 Rust 自動幫你檢查返回值,在出錯的時候直接返回錯誤,不再繼續往下走了。問號可以展開爲如下的形式 (簡化版本,方便理解,實際版本請看官方文檔),

let res = match step1() {
   Ok(o) => o,
   Err(e) => return e,}

到這裏,我們可以看到 Rust 的創新點在於將錯誤與計算結果放在了返回值,而不是單純地返回錯誤,或者返回計算結果和從第三個路徑返回異常。並且提供了問號和組合子來簡寫錯誤處理。所以同時提供了避免無意識忽略錯誤和提供可讀性。

但錯誤處理遠遠不止這點內容。在我寫了 GitHub 的 webhook 微服務 https://github.com/Celthi/github-webhook-gateway 以後,我發現寫了一大坨下面的代碼

  let res = client
                .post(...
                ))
                .header("Authorization", &config_env::get__api_token())
                .header("Accept", "application/json")
                .json(&task)
                .send()
                .await;
            match res {
                Ok(body) => {
                    println!("Succeed posting task {:?}", body);
                    if body.status() == reqwest::StatusCode::OK {
                        if let Ok(result) = body.json::<serde_json::Value>().await {
                            if let Some(code) = result.get("code") {
                                if let Some(code) = code.as_u64() {
                                    if code != 200 {
                                        
                                        if let Err(e) = github::issue::post_issue_comment(...
                                        ).await
                                        {
                                            eprintln!("{}", e);
                                        }
                                    }
                                }
                           }
                         }
                       }
               }}

寫成這樣,說明我對 Rust 的錯誤處理仍然沒有理解到位,於是我試着重構這段代碼,並提了個問題 How reduce the nested if and indents?

經過重構以後,我發現瞭如下的一些情況

有時候只想處理成功的情況,我稱之爲 “最大努力做事”。所以代碼邏輯是這樣

if let Ok() = step1() {
   if let Ok() = step2() {
      if let Ok() = step3 () {
       ...
      }
   }}

這也是我自己代碼那麼多縮進的原因。它可以通過如下方式來改善,

方式一、首先先把代碼段提到一個單獨的函數 post_sending_task(),然後將返回值改成 Result,所以調用的地方代碼是

let _ = best_delivery(); //這裏使用使用_,說明我們不關心失敗的情況

在這個 best_delivery() 裏面,我們就可以使用問號表達式了。

方式二、使用組合子,如將 Option 轉換成 Result,從而可以使用問號,如

let res = get_something().ok_or_else(|| err)?;

這裏 ok_or_else 是 option 上的組合子。什麼是組合子,簡單理解是將東西組合在一起的函數。至於”子 “,一種稱謂罷了,要說相似的話,第一反應類似套接字裏面的” 字“的功能。

方式三、提前返回。通過反轉 if 的條件,提前返回,比如,

if condtion_not_care {
   return ;}

提前返回沒有問號那麼可讀性強,但是減少了縮進的層數。

方式四、如果獲取結果的同時必須處理錯誤的情況,那麼使用下面的形式,

let res = match step1() {
     Ok(o)=> o,
     Err(e) => { handle error }}

注意,問號表達式是適合於獲取結果且不處理錯誤,直接往上拋。

經過這四個個方式的改善,我的代碼可讀性提高了,變成了

錯誤處理與日誌、錯誤報告


錯誤處理的時候,通常要寫日誌。但是錯誤處理和日誌是兩碼事。不是所有的錯誤處理都要寫日誌,而且不同的錯誤,寫到的日誌級別是不一樣的,如調試,信息,錯誤,嚴重等等級別。

錯誤處理是處理出錯的情況,而日誌是記錄感興趣的信息。它們有重合,但是關注點不一樣。以後再寫文章。

錯誤報告 (error report) 跟錯誤處理也是兩碼事,雖然經常關聯在一起,也留作以後再寫文章。

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