代碼實踐 l 異常和錯誤的處理

2022-01-24 Munger編程問答

異常和錯誤,乍一看往往會以為是一個事情。為了區分通用的概念,首先要定義本文中的「異常」和「錯誤」。

* 在程序運行中發生了問題,但是這個問題可以通過增加相應的程序邏輯恢復的叫做異常

*因為程序邏輯問題引起的不可恢復的異常叫錯誤BUG)

定義好這兩個概念後,大家可能會疑問為什麼要這樣定義,異常和錯誤在我們通用理解的意義上是一個同義詞,沒有本質的區別。

其實在程序設計的發展過程中,最開始這兩個概念也是沒什麼區別的,但是隨著軟體設計越來越複雜慢慢衍生出來這兩個概念。

首先看下比較早期的C語言。 

Assert 這個關鍵字在C語言中很常見,本質上是用來處理程序設計中的錯誤,一旦出現斷言執行,函數提供者是沒有能力恢復這個問題的,需要調用方檢查問題,後面如果程序繼續運行下去,可能發生不可預知的問題。

一旦執行到 Assert ,說明函數的調用方的程序是有bug的,沒有正確的使用函數,顯然是符合我們上面定義的錯誤概念。

再來說下異常,如何在C語言中處理異常的情況?

在調用C函數的時候,如果發生異常,往往使用函數返回值表示:

bool createMap(int num) {        if (num > 0) {        ...        return true;    }    return false;}

調用者看到這種類型的API,往往需要增加 if-else 的判斷,來讓程序正確的執行,其實這就是早期異常處理的方式。

現在很多語言也是用這種方式來處理,但是缺點也很明顯,程序中需要寫大量的 if-else 語句,不利於代碼的閱讀和維護。

為了保證程序的健壯性,同時保證代碼書寫的便捷性。很多高級語言開始定義了異常的概念,例如C++是很早就在語法中定義了exception。

try {      createMap();   userMap();   releaseMap();} catch( ExceptionName e ) {    handlerExcepiton();}

這樣try模塊中,所有可能異常的函數都可以用一行代碼調用,不用像C語言這樣寫大量的 if-else 處理,這對於代碼的閱讀性自然很好,但是同樣存在一個問題—性能。

為什麼大量使用異常處理代碼會引起性能問題呢?

這個就涉及到異常實現的邏輯了,在函數中為了捕獲異常,需要額外的開闢一些空間給異常對象使用。同時異常一旦發生需要中斷函數的調用堆棧,指向異常處理函數,這個過程被稱為堆棧展開。

「當我們調用某些函數時,它將地址存儲到調用堆棧中,從函數返回後,需要彈出該地址以開始其剩餘的工作。堆棧展開是一個在運行時刪除函數調用堆棧條目的過程。要刪除堆棧元素,我們可以使用異常。如果內部函數引發異常,則將刪除堆棧的所有條目,並返回到主調用程序函數。」

除了上述這個開銷,還有一個開銷是因為異常出現的函數,可能很早就被調用了,但是很晚才開始使用catch捕獲,這時候就需要把異常數據層層傳遞給需要處理的函數。

所以從上面來看這個異常的處理過程,對比一個 if-else 簡單的語句,必然是很消耗性能的。對於C++這種性能要求比較高的程序語言,異常這個性能問題一直被詬病。

所以C++在使用異常的時候有很多約束,也造成了異常處理在C++語法中,很難廣泛的使用。下面列下微軟對C++異常處理使用的建議:

使用斷言來檢查絕不應發生的錯誤: 使用異常來檢查可能出現的錯誤,例如,公共函數參數的輸入驗證中的錯誤。

當處理錯誤的代碼與通過一個或多個幹預函數調用檢測到錯誤的代碼分離時,使用異常。當處理錯誤的代碼與檢測到錯誤的代碼緊密耦合時,考慮是否使用錯誤代碼而不是在性能關鍵循環中。

對於可能引發或傳播異常的每個函數,請提供以下三種異常保證之一:強保障、基本保證或 nothrow (noexcept) 保證。

按值引發異常,按引用來捕獲異常, 不要捕獲無法處理的內容

應用使用標準庫異常類型, 從 exception 類層次結構派生自定義異常類型。

並且C++是兼容C語言的,C++裡一些庫是用C實現的,如果引入異常的語法,是需要兼容很多C的庫,可以看出來異常在C++中使用的難處了。

引用一下C++創始人施特勞斯的原話,可以看出異常處理的難言之隱。

「對於異常處理的性能問題,其實是一個很有爭議的問題,有人覺得異常處理是多做了一些工作,肯定對性能是有影響的。但是也有人覺得異常處理的影響,和增加一個 if-else 屬於同種量級,對性能的影響其實微乎其微,是在可以接受的範圍內的。強大的錯誤處理對於任何程式語言都很有挑戰性。儘管異常提供了多個支持良好錯誤處理的功能,但它們無法為你完成所有工作。若要實現異常機制的優點,請在設計代碼時記住異常。」

雖然通用的異常處理對於性能有一定消耗,但是它的優點還是很誘人的,尤其對於不依賴C語言的高級語言,沒有了兼容性的負擔,好處更是大於缺點。

下面列出來對於很多高級語言,需要異常處理語法的理由:

# 代碼的閱讀更順利。

# 如果不用通用的異常處理,構造函數初始化異常必須要開發者處理。

# 可以在運行時,減少錯誤崩潰的發生。

# 沒有通用異常處理,API需要寫大量的處理函數返回值的邏輯,如果是異步的需要大量的callback。

# 沒有兼容C語言的負擔。

基於上面的優點像java,C#,swift這些強類型語言都有異常處理語法,其中java算是比較早的完善了異常處理的語法。但是各個語言異常處理的邏輯還是有細微的差別,這裡拿java和swift語言對比下各自的異常處理。

try {    int[] array = new int[] { 1, 2, 3 };     System.out.println(array[3]);} catch (Exception e) {    e.printStackTrace();}

在java中如果執行這段代碼會列印出 ArrayIndexOutOfBoundsException 異常,異常可以被捕獲不會崩潰。

下面看下swift同樣想捕獲數組越界的異常

let arrayList = [0]do {    print(arrayList[1])} catch {    print("Array out of bounds")}

編譯時會拋出來  『catch』 block is unreachable because no errors are thrown in 『do』 block 這個警告,本質上編譯器是不處理這個異常的。然而運行時,會崩潰拋出 Fatal error: Index out of range 。

因為swift語言的設計理念認為數組越界是程序編寫的邏輯錯誤,一旦出現就無法恢復程序正常的邏輯,所以理應崩潰,便於開發者發現問題。

而java語言是認為這個異常,如果程序捕獲了就代表有能力處理恢復這個異常。所以相對來講java的異常的範圍更寬泛,而swift相對比較苛刻。

從上面的對比可以看出,java語言設計更傾向於程序的開發便捷性和安全性,可以適當犧牲性能的開銷。

還有一個明顯的例子在java中,如果一個函數做了如下的異常定義:

public static void createMap(int x) throws Exception{    if(x<0) throw new Exception("Map must be greater than zero");    else...}

函數調用者必須使用如下的方式處理,否則編譯會報錯:

try {    createMap();} catch (Exception e) {    System.out.println(e);}

java語言設計者認為:如果函數拋出了異常,那麼代表著調用者有能力去恢復這個異常,這保證了程序的健壯性。

雖然swift設計和java這點類似,函數拋異常不處理就會編譯錯誤。但是可以使用 try! 這個便捷的語法忽略異常,這就代表swift還是要考慮異常性能的開銷。

不過事物都有兩面性,使用了 try!一旦出現異常程序就要崩潰,這會對程式設計師的代碼質量進行嚴格考驗。

最後想說下Rust語言異常處理的邏輯,和大多數 try-catch 設計方式還是不一樣的,下面是官方的解釋:

「Rust 有一套獨特的處理異常情況的機制,它並不像其它語言中的 try 機制那樣簡單。首先,程序中一般會出現兩種錯誤:可恢復錯誤和不可恢復錯誤。可恢復錯誤的典型案例是文件訪問錯誤,如果訪問一個文件失敗,有可能是因為它正在被佔用,是正常的,我們可以通過等待來解決。但還有一種錯誤是由編程中無法解決的邏輯錯誤導致的,例如訪問數組末尾以外的位置。大多數程式語言不區分這兩種錯誤,並用 Exception (異常)類來表示錯誤。在 Rust 中沒有 Exception。對於可恢復錯誤用 Result類來處理,對於不可恢復錯誤使用 panic! 宏來處理。」

所以rust語言對異常處理感覺更符合異常定義的本質,只是對於開發者來講可能會麻煩些,寫之前要分清楚異常和錯誤這個概念了,不能無腦的寫 try-catch 了。不過這樣也會讓程序的安全性、健壯性、性能開銷等方面更加優秀。

所以我們在寫代碼的時候,尤其是寫對外使用的API時,一定要先搞清楚這個API的使用過程中可能產生的異常和錯誤。這裡我列出來幾個日常設計的點。

因為接口設計時,往往對數據的要求是寬進嚴出,以方便其他程序將輸出作為輸入,說白了就是「把複雜留給自己,把簡單留給別人」,但是哪些參數是需要處理為異常呢 ? 這裡舉個簡單的例子:

void answerCall(Person person) {
       if (person isNot Person) {        Assert("error");    }    if (!person.iscomming) {        return;    }
       ...}

可以看出來上面的接聽電話的邏輯,首先接收的參數不符合函數的定義那肯定是調用方的bug,應該拋出錯誤。

不過現在很多程式語言基本都會做類型檢測,不符合就會編譯出錯或者警告,所以這種防禦不太會寫。

再看第二個條件判斷,接聽的電話不是正在來電的用戶,那說明這個參數也是有問題的,需要告訴調用者這個異常。

在這裡 return 就顯得不是很合理,因為這個異常其實被隱藏了,上層感知不到就無法排查錯誤。

如果按照C異常的常用寫法就需要給answerCall增加一個是否成功的返回值。

如果是java等高級語言只要如下寫:

void answerCall(Person person) throws Exception{
   if (!person.iscomming) {        throw new Exception();        return;    }
       ...}

這個原則說白了,就是不要出現中間狀態,保證出現異常後所有的狀態恢復成原始狀態。

我們繼續看上面的例子。接聽的參數檢測成功後,進入接聽狀態,首先改變接聽者本地的狀態,然後請求網絡,最後接聽成功。

假如說上層的API連續調用兩次,為了防止頻繁做網絡請求做了下面的防禦編程。

void answerCall(Person person) throws Exception{
   if (!person.iscomming) {        throw new Exception();        return;    }
       ...    changeStatus();        if (self.isAnsering) {        return;    }    requestAccept();    reportAccept();}

這種方式其實就違背了「不要增加無用的輸出原則」。因為changeStatus()被調用了兩次,一旦有地方監聽這個狀態變化可能就造成未知的問題。

這時候最好的選擇,就是和參數攔截一樣,在最開始的位置就拋出異常。

void answerCall(Person person) throws Exception{
   if (!person.iscomming) {        throw new Exception();    }
       if (self.isAnsering) {        throw new Exception();    }
       changeStatus();    requestAccept();    reportAccept();}

當函數的提供方認為多次調用這種異常,自己可以處理,比如直接返回就代表處理了這個異常,上層可以不感知:

void answerCall(Person person) throws Exception{
   if (!person.iscomming) {        throw new Exception();    }
       if (self.isAnsering) {        return;    }
       changeStatus();    requestAccept();    reportAccept();}

其實函數的冪等性要求,本質上就是對多次輸入這種異常處理的過程。這種寫法代表接收方處理了這種錯誤,不需要調用方關心,並且保證每次調用都返回同樣的結果。

假如一些程序由於特殊狀態的原因,無法應用冪等性原理就應該及時拋出異常,告訴上層的調用者出了問題,讓上層調用者來恢復這個操作。

記住錯誤是讓程序以最低成本恢復正常的一種方式。如果你定義的API有嚴格的狀態維護,讓程序及時的崩潰比拋出異常更容易發現問題

斷言是一種出現嚴重錯誤才會使用的方式,一旦使用斷言就代表調用方的程序一定是出了bug,並且接收方無法處理這種情況,需要及時的終止程序以便於調用方修改此處的bug。

不過現在很多軟體為了用戶體驗,減少閃退的次數,往往斷言在release版本中會移除掉。帶來的後果是出問題的代碼被隱藏了,一些奇怪的問題就很難及時發現。所以經常有開發者在爭論斷言是否應該使用在release版本。

據說NASA和很多銀行系統的軟體,斷言都會在release版本中,因為這些系統對安全性要求極高,一旦出了bug需要立即終止,否則可能造成不可估量的災難。

而對於安全性要求不高的系統,為了獲得更高的用戶體驗往往release版本會移除斷言。不過孰對孰錯怕是永遠沒有完美的答案。

上面分析了這麼多,那我們實際編碼中,如何對異常處理呢?是否需要寫大量的 if-else 或 try-catch語句呢?

其實本質上來講,可以按照下面的原則:

1.首要要看產生的問題是因為調用者不正確的使用API造成的?如果是就用斷言攔截

2.如果調用一個API,某些異常無法避免(比如很多IO設備的讀取,很可能因為硬體不可用的問題造成。這種往往無法避免),這時候遵循的原則:看是否應該讓調用者來感知這個異常?並有能力恢復這個問題?如果是就果斷拋出異常

3.如果異常,函數提供者本身可以處理,那麼就處理掉,不用再拋給上層。比如接口冪等性的設計,就是處理這種異常。

相關焦點

  • PHP 中的錯誤和異常處理
    就我自己這些年寫程序的現狀看,我基本上就沒有真正明白什麼是異常處理,經常把異常和錯誤處理混為一談,關於代碼中的那些寫法,不是寫錯了,就是寫的太特麼爛了。恰好最近在寫一些類時用到異常處理了,順便就把這個整理下,但是這個僅代表我個人的一些理解和使用,也可能是錯誤的,還請謹慎閱讀。概述錯誤處理定義錯誤是指導致系統不能按照用戶意圖工作的一切原因、事件。
  • JavaScript 錯誤和異常處理
    這些錯誤不是語法或運行時錯誤的結果。它們出現於在你編寫代碼時有邏輯錯誤從而在執行時不能得到預期的結果。你沒辦法捕獲這些錯誤,因為它們是你的業務代碼。try...catch...finally 語句這是 JavaScript 內置的錯誤處理工具,也是我們日常開發中常用的調試語句。
  • python教程之九錯誤和異常處理
    報錯信息作為新手,前面章節裡,我在敲代碼的時候,有時候敲錯了,都會報錯,Idle裡用紅色字體標識出來。Python 有兩種錯誤很容易辨認:語法錯誤和異常。語法錯誤語法分析器指出了出錯的一行,並且在最先找到的錯誤的位置標記了顏色,如下所示。
  • php7異常與錯誤處理和自定義異常
    接口 並使得大部分的 Error 和Exception 實現了該接口,我們得以在 try-catch 中拋出該錯誤。中斷程序執行,除了修改ini文件,將錯誤信息寫到日誌中,什麼也做不了 E_PARSE //編譯時的語法解析錯誤自定義錯誤處理程序有的時候,php 中自帶的錯誤處理程序,並不能完全滿足我們得需要,大部分時候,我們都需要手動重寫異常處理。
  • 處理Java異常的10個最佳實踐
    線上代碼不要使用printStackTrace()寫完代碼後請一定要檢查下,代碼中千萬不要有printStackTrace()。因為printStackTrace()只會在控制臺上輸出錯誤的堆棧信息,他只適合於用來代碼調試。真正需要記錄異常,請使用日誌記錄。
  • 寫Python代碼過程中碰到各種錯誤異常要怎麼樣去處理?
    錯誤異常即便Python程序的語法是正確的,在程序運行的過程中,也可能發生錯誤。運行期檢測到的錯誤被稱為異常。如果發生了錯誤,可以事先約定返回一個錯誤代碼,這樣,就可以知道是否有錯,以及出錯的原因。所以高級語言通常都內置了一套try...except...finally...的錯誤處理機制,Python也不例外。異常處理當我們某些代碼可能會出錯時,就可以用try來運行這段代碼。如果執行出錯,則後續代碼不會繼續執行,而是直接跳轉至錯誤處理代碼,即except語句塊。執行完except後,如果有finally語句塊,則執行finally語句塊,至此,執行完畢。
  • Java 中處理異常的 9 個最佳實踐
    ,以舉例與代碼展示結合的方式,讓開發者更好的理解這9種方式,並指導讀者在不同情況下選擇不同的異常處理方式。以下為譯文:Java中的異常處理不是一個簡單的話題。初學者很難理解,甚至有經驗的開發人員也會花幾個小時來討論應該如何拋出或處理這些異常。這就是為什麼大多數開發團隊都有自己的異常處理的規則和方法。如果你是一個團隊的新手,你可能會驚訝於這些方法與你之前使用過的那些方法有多麼不同。然而,有幾種異常處理的最佳方法被大多數開發團隊所使用。
  • C語言代碼中異常的處理機制
    程序運行時有些錯誤是不可避免的,如內存不足、文件打開失敗、數組下標溢出等,這時要力爭做到排除錯誤並記錄錯誤,但是同時要保證項目的正常運行。  傳統做法是返回一個錯誤代碼,調用者通過if等語句測試返回值來判斷是否成功。這樣做有幾個缺點:首先,增加的條件語句可能會帶來更多的錯誤;其次,條件語句是分支點,會增加測試難度;另外,構造函數沒有返回值,返回錯誤代碼是不可能的。
  • Python學習第50課-處理錯誤和異常
    在工作當中會經常出現意料不到的錯誤和異常,就需要我們對可能出現的錯誤和異常進行預判,然後加上捕獲和處理錯誤異常的代碼,否則,程序在運行過程中,遇到錯誤和異常就會crash崩潰,無法繼續向下執行。●Python的錯誤種類:①語法錯誤,或稱解析錯誤。
  • 代碼的錯誤處理(Error Handling)方式之二
    第四節 代碼的錯誤處理(Error Handling)方式之二在上一節,我們講解了錯誤處理語句On Error Resume Next及其應用,利用這種語句來處理錯誤,可以忽視發生的錯誤,繼續運行之後的語句。今日我們來講解錯誤處理的第二種方式:On Error GoTo line 語句,這種語句緊跟一個錯誤處理語句的行編號或者標籤。
  • Java中處理異常的9個最佳實踐,你必須要知道!
    }如上,NumberFormatException字面上即可以看出是數字格式化錯誤。3. 對異常進行文檔說明當在方法上聲明拋出異常時,也需要進行文檔說明。和前面的一點一樣,都是為了給調用者提供儘可能多的信息,從而可以更好地避免/處理異常。
  • 一種Vue應用程式錯誤/異常處理機制
    這就需要在構建前端應用程式的時候考慮很多,錯誤/異常處理是最重要的方面之一。在應用程式中擁有良好的錯誤處理機制可以帶來很多的好處,如下:良好的錯誤處理機制可以避免應用程式在出現未處理的異常時崩潰在生產環境下,可以輕鬆地存儲或者跟蹤錯誤記錄日誌,以便異常的處理可以統一處理錯誤信息,例如在不破壞應用程式交互的情況下,更改錯誤信息展示UI在前端應用程式中,最常見的錯誤/異常類型可能包括以下幾種:有很多方法可以解決上面的問題,例如使用 eslint 來檢查語法錯誤,使用適當的 try-catch
  • python入門第十三課:文件的讀寫與分析介紹,異常處理和代碼重構
    ##本教程使用的課本是《Python編程:從入門到實踐》,作者:[美] Eric Matthes學完前面十二節課,已完成Python編程入門了,我們已經能編寫組織有序而易於使用的Python程序了。接來下繼續學習更多應用操作,比如文件操作、數據存儲、異常處理等,這些技巧能讓我們快速的處理大量的數據,讓程序更加健壯。
  • 一篇文章幫你搞定Python異常處理
    異常的定義異常發生之後異常之後的代碼就不執行了異常處理的定義python解釋器檢測到錯誤,觸發異常(也允許程式設計師自己觸發異常)程式設計師編寫特定的代碼,專門用來捕捉這個異常(這段代碼與程序邏輯無關,與異常處理有關)如果捕捉成功則進入另外一個處理分支,執行你為其定製的邏輯,使程序不會崩潰,這就是異常處理關於為什麼要進行異常處理python解析器去執行程序,檢測到了一個錯誤時,觸發異常,異常觸發後且沒被處理的情況下
  • 大型.NET項目中的異常處理機制-最佳實踐
    隨著我們的應用程式的增長,我們希望採用可管理的策略來處理錯誤,以保持用戶的體驗一致,更重要的是,為我們提供解決和修復問題的方法。異常處理的最佳實踐在.NET框架中表達錯誤條件的慣用方法是拋出異常。對於剩餘的意外異常,例如由代碼中的錯誤引起的NullReferenceExceptions,我們可以向用戶顯示一般錯誤消息,為他提供報告錯誤的選項,或者在沒有用戶幹預的情況下自動記錄錯誤。
  • 代碼的錯誤處理(Error Handling)方式之一
    第三節 代碼的錯誤處理(Error Handling)方式之一大家好,我們今日講解代碼在遇到錯誤時的錯誤處理方式:On Error Resume Next語句的利用。這講代碼是什麼意思呢?您可以將錯誤處理過程置於發生錯誤的位置,而不是將控制權轉移到程序中的其他位置。當調用其他過程時,On Error Resume Next 語句將變為不活動狀態,因此,如果需要在該例程中進行錯誤處理,則應在每個調用的過程中執行 On Error Resume Next 語句。l我們需要注意一點,利用這種處理方式程序在執行過程中要忽略對錯誤的處理,除非我們確信這種錯誤是可以忽略的。
  • 一文搞懂Python錯誤和異常
    寫Python代碼的小夥伴不可避免地會遇到代碼執行錯誤和異常,這次就來詳細且不失通俗地總結一下python中的錯誤和異常。
  • Python錯誤、異常和模塊
    本篇主要講兩方面,錯誤和異常以及模塊。在編程時遇見錯誤信息在所難免,Python中會也有很多種錯誤信息,常見的兩種就是語法錯誤和邏輯錯誤,邏輯錯誤的種類有很多,佔據了異常中大部分位置,下面就開始介紹一下這兩個概念的相關知識。
  • [Python學習筆記]Python基礎10之錯誤與異常處理
    >和異常。在執行時檢測到的錯誤被稱為異常,異常不一定會導致嚴重後果。但是,大多數異常並不會被程序處理。異常有不同的類型,而其類型名稱將會作為錯誤信息的一部分中列印出來,上述示例中的異常類型依次是`ZeroDivisionError`,`NameError`和`TypeError`。
  • 【Python基礎】10 異常處理
    一 什麼是異常異常是程序發生錯誤的信號。程序一旦出現錯誤,便會產生一個異常,若程序中沒有處理它,就會拋出該異常,程序的運行也隨之終止。為了保證程序的容錯性與可靠性,即在遇到錯誤時有相應的處理機制不會任由程序崩潰掉,我們需要對異常進行處理,處理的基本形式為try:    被檢測的代碼塊except 異常類型:    檢測到異常,就執行這個位置的邏輯