天天看點

對比程式設計語言的四種錯誤處理方法,哪種才是最優方案?

作者:Python貓

作者:Andrea Bergia

譯者:豌豆花下貓@Python貓

英文:Error handling patterns

轉載請保留作者及譯者資訊!

錯誤處理是程式設計的一個基本要素。除非你寫的是“hello world”,否則就必須處理代碼中的錯誤。在本文中,我将讨論各種程式設計語言在處理錯誤時使用的最常見的四種方法,并分析它們的優缺點。

關注不同設計方案的文法、代碼可讀性、演變過程、運作效率,将有助于我們寫出更為優雅和健壯的代碼。

傳回錯誤代碼

這是最古老的政策之一——如果一個函數可能會出錯,它可以簡單地傳回一個錯誤代碼——通常是負數或者null。例如,C 語言中經常使用:

FILE* fp = fopen("file.txt" , "w");
if (!fp) {
  // 發生了錯誤
}
           

這種方法非常簡單,既易于實作,也易于了解。它的執行效率也非常高,因為它隻需要進行标準的函數調用,并傳回一個值,不需要有運作時支援或配置設定記憶體。但是,它也有一些缺點:

  • 使用者很容易忘記處理函數的錯誤。例如,在 C 中,printf 可能會出錯,但我幾乎沒有見過程式檢查它的傳回值!
  • 如果代碼必須處理多個不同的錯誤(打開檔案,寫入檔案,從另一個檔案讀取等),那麼傳遞錯誤到調用堆棧會很麻煩。
  • 除非你的程式設計語言支援多個傳回值,否則如果必須傳回一個有效值或一個錯誤,就很麻煩。這導緻 C 和 C++ 中的許多函數必須通過指針來傳遞存儲了“成功”傳回值的位址空間,再由函數填充,類似于:
my_struct *success_result;
int error_code = my_function(&success_result);
if (!error_code) {
  // can use success_result
}
           

衆所周知,Go 選擇了這種方法來處理錯誤,而且,由于它允許一個函數傳回多個值,是以這種模式變得更加人性化,并且非常常見:

user, err = FindUser(username)
if err != nil {
    return err
}
           

Go 采用的方式簡單而有效,會将錯誤傳遞到調用方。但是,我覺得它會造成很多重複,而且影響到了實際的業務邏輯。不過,我寫的 Go 還不夠多,不知道這種印象以後會不會改觀!

異常

異常可能是最常用的錯誤處理模式。try/catch/finally 方法相當有效,而且使用簡單。異常在上世紀 90 年代到 2000 年間非常流行,被許多語言所采用(例如 Java、C# 和 Python)。

與錯誤處理相比,異常具有以下優點:

  • 它們自然地區分了“快樂路徑”和錯誤處理路徑
  • 它們會自動從調用堆棧中冒泡出來
  • 你不會忘記處理錯誤!

然而,它們也有一些缺點:需要一些特定的運作時支援,通常會帶來相當大的性能開銷。

此外,更重要的是,它們具有“深遠”的影響——某些代碼可能會抛出異常,但被調用堆棧中非常遠的異常處理程式捕獲,這會影響代碼的可讀性。

此外,僅憑檢視函數的簽名,無法确定它是否會抛出異常。

C++ 試圖通過throws 關鍵字來解決這個問題,但它很少被使用,是以在 C++ 17 中已被棄用 ,并在 C++ 20 中被删除。此後,它一直試圖引入noexcept 關鍵字,但我較少寫現代 C++,不知道它的流行程度。

(譯者注:throws 關鍵字很少使用,因為使用過于繁瑣,需要在函數簽名中指定抛出的異常類型,并且這種方法不能處理運作時發生的異常,有因為“未知異常”而導緻程式退出的風險)

Java 曾試圖使用“受檢的異常(checked exceptions)”,即你必須将異常聲明為函數簽名的一部分——但是這種方法被認為是失敗的,是以像 Spring 這種現代架構隻使用“運作時異常”,而有些 JVM 語言(如 Kotlin)則完全抛棄了這個概念。這造成的結果是,你根本無法确定一個函數是否會抛出什麼異常,最終隻得到了一片混亂。

(譯者注:Spring 不使用“受檢的異常”,因為這需要在函數簽名及調用函數中顯式處理,會使得代碼過于冗長而且造成不必要的耦合。使用“運作時異常”,代碼間的依賴性降低了,也便于重構,但也造成了“異常源頭”的混亂)

回調函數

另一種方法是在 JavaScript 領域非常常見的方法——使用回調,回調函數會在一個函數成功或失敗時調用。這通常會與異步程式設計結合使用,其中 I/O 操作在背景進行,不會阻塞執行流。

例如,Node.JS 的 I/O 函數通常加上一個回調函數,後者使用兩個參數(error,result),例如:

const fs = require('fs');
fs.readFile('some_file.txt', (err, result) => {
  if (err) {
    console.error(err);
    return;
  }

  console.log(result);
});
           

但是,這種方法經常會導緻所謂的“回調地獄”問題,因為一個回調可能需要調用其它的異步 I/O,這可能又需要更多的回調,最終導緻混亂且難以跟蹤的代碼。

現代的 JavaScript 版本試圖通過引入promise 來提升代碼的可讀性:

fetch("https://example.com/profile", {
      method: "POST", // or 'PUT'
})
  .then(response => response.json())
  .then(data => data['some_key'])
  .catch(error => console.error("Error:", error));
           

promise 模式并不是最終方案,JavaScript 最後采用了由 C#推廣開的 async/await 模式,它使異步 I/O 看起來非常像帶有經典異常的同步代碼:

async function fetchData() {
  try {
    const response = await fetch("my-url");
    if (!response.ok) {
      throw new Error("Network response was not OK");
    }
    return response.json()['some_property'];
  } catch (error) {
    console.error("There has been a problem with your fetch operation:", error);
  }
}
           

使用回調進行錯誤處理是一種值得了解的重要模式,不僅僅在 JavaScript 中如此,人們在 C 語言中也使用了很多年。但是,它現在已經不太常見了,你很可能會用的是某種形式的async/await。

函數式語言的 Result

我最後想要讨論的一種模式起源于函數式語言,比如 Haskell,但是由于 Rust 的流行,它已經變得非常主流了。

它的創意是提供一個Result類型,例如:

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

這是一個具有兩種結果的類型,一種表示成功,另一種表示失敗。傳回結果的函數要麼傳回一個Ok 對象(可能包含有一些資料),要麼傳回一個Err 對象(包含一些錯誤詳情)。函數的調用者通常會使用模式比對來處理這兩種情況。

為了在調用堆棧中抛出錯誤,通常會編寫如下的代碼:

let result = match my_fallible_function() {
  Err(e) => return Err(e),
  Ok(some_data) => some_data,
};
           

由于這種模式非常常見,Rust 專門引入了一個操作符(即問号 ?) 來簡化上面的代碼:

let result = my_fallible_function()?;   // 注意有個"?"号
           

這種方法的優點是它使錯誤處理既明顯又類型安全,因為編譯器會確定處理每個可能的結果。

在支援這種模式的程式設計語言中,Result 通常是一個 monad,它允許将可能失敗的函數組合起來,而無需使用 try/catch 塊或嵌套的 if 語句。

(譯者注:函數式程式設計認為函數的輸入和輸出應該是純粹的,不應該有任何副作用或狀态變化。monad 是一個函數式程式設計的概念,它通過隔離副作用和狀态來提高代碼的可讀性和可維護性,并允許組合多個操作來建構更複雜的操作)

根據你使用的程式設計語言和項目,你可能主要或僅僅使用其中一種錯誤處理的模式。

不過,我最喜歡的還是 Result 模式。當然,不僅是函數式語言采用了它,例如,在我的雇主 lastminute.com 中,我們在 Kotlin 中使用了 Arrow 庫,它包含一個受 Haskell 強烈影響的類型Either。我有計劃寫一篇關于它的文章,最後感謝你閱讀這篇文章,敬請保持關注。

譯注:還有一篇《Musings about error handling mechanisms in programming languages》文章,同樣分析了不同程式設計語言在錯誤處理時的方案。它還介紹了 Zig 程式設計語言的做法、Go 語言的 defer 關鍵字等内容,可以豐富大家對這個話題的了解,推薦一讀。

繼續閱讀