談談錯誤處理

作爲程序員要虛心 ——魯迅
這裏寫的是自己對於錯誤處理的一些理解,末尾列出了參考文章,如果有侵權,可以聯繫我修改。如果有寫得不對的地方,請重拍!

1. 引言

錯誤處理是一個歷史很悠久的話題了,其中也有很多相關的文獻,很多大牛也針對這個話題頻出奇招。作爲一個軟件開發者,也應該對其中不同的處理模式和背後的思想有些許瞭解。

2. 錯誤/異常

那什麼是錯誤呢?對於軟件代碼而言,調用方違反被調用函數的precondition、或一個函數無法維持其理應維持的invariants、或一個函數無法滿足它所調用的其它函數的precondition、或一個函數無法保證其退出時的postcondition;以上所有情況都屬於錯誤。

wikipedia把工程學中的錯誤定義爲:系統或對象中,預期中的結果和實際的結果之間的差異。Error

而對於異常,除了“錯誤”包含的屬性外,wikipedia中還提到了“異常”的“終止語義Termination semantics

我理解中,“異常”是比“錯誤”更嚴重的錯誤,或者說異常是錯誤的子集。發生異常時會終止當前處理的流程,例如終止當前請求、當前事務,甚至終止服務。大部分情況下,錯誤和異常在含義上相同。但有時候錯誤和異常的分界線並不清楚,而是需要根據不同的場景來定義的。例如打開配置文件的操作,如果找不到文件,對於某些場景,需要拋異常來提示配置文件不存在(流程被終止了);而對有些流程,只需要加載默認的配置即可,流程還是能繼續走下去(這裏相當於對錯誤做了降級處理)。

爲什麼會發生錯誤呢?或者說能不能把代碼寫得儘量完美,那樣是不是就沒有討論這個話題的必要了?首先,一個方案難免會存在邏輯思維漏洞,或者考慮不周的情況,這時候錯誤也應該是預期之中的,所以我們有“迭代”的概念。另外,現在的工程代碼,不再是幾個人就能手擼出來的,要依賴了大量的外部組件、外部模塊,外部服務等。種種的外部依賴也表示就算你的代碼沒問題,代碼也不會一直遵循happy path走到底。

所以在現實中,如果服務出現可恢復的錯誤時,儘快恢復,不影響到服務繼續運行;當不可恢復時,應做好妥善的後置操作,例如釋放資源、保護用戶數據、記錄錯誤信息等,必要時重啓服務。

2.1. 異常安全

boost庫對於通用組件的異常安全的非正式定義是:模塊中的異常安全意味着,當組件在執行過程中拋異常時,它會表現出合理的行爲。對於大部分人,“合理”一詞包括所有對錯誤處理的常見預期:資源不能泄漏;程序應該保持在一個明確定義的狀態所以能繼續執行。對於大多數組件,當錯誤發生時,應該讓調用方知曉。

wikipedia定義稍微學術些:
異常安全,exception safety是,類庫實現者和用戶在任何帶有異常的編程語言上,可以用來推導異常處理安全性的一系列的協議指導。其中包含4個等級(從強到弱):錯誤透明、強異常安全(事務語義)、基本異常安全、無異常安全。

2.2. 異常中立 exception neutrality

異常應該被特定的try catch塊捕獲並處理,並且允許未被捕獲的異常繼續向上傳遞。如果存在一個終止的catch(...),則需要最終把當前的異常
re-throw出去。簡單而言,除非異常被捕獲,不然異常對象應該保持不變,一直傳上去。

3. C/C++方式

C語言的函數沒法擁有多個返回值。caller想要得到函數執行的結果,又想拿到函數執行過程中可能引發的錯誤。這時候就有點讓人頭大了。所以C提供了一種方式來解決這一類問題,使用整數狀態碼(內部庫實現用errno) + 指針的方式。

  • 狀態碼用來指示該函數執行是否成功,errno是線程安全的;
  • 指針提供了一種可以將函數改動後數據傳遞給函數外的方式;

C語言使用了狀態碼(status code)模式,但返回的狀態碼不爲0並不代表發生了錯誤,而是不同的接口自己定義的。舉個例子:

// 除法
int division(int dividend, int divisor, float* quotient) {
   if( divisor == 0){
      exit(-1);
   }
   // process divide
   exit(0);
}

// 查找子字符串
int index = find(str, sub_str)
if(index != -1) {// case 1
} else {// case 2
}

上面的例子中,除法遇到除數爲0時,返回了-1的錯誤碼,而caller需要根錯誤碼映射關係才能知道錯誤的含義。而在查找的例子中,返回的狀態碼只表示找不到對應的子字符串,並不能代表程序發生了錯誤。這其實讓C函數的返回含義嚴重依賴於文檔說明,沒有文檔的話,除了閱讀源碼,不然你哪知道返回的-1是個啥意思。另外,無法簡單得到函數的調用棧(當然你也可以說逐層的狀態碼檢查其實不需要調用棧)。

到C++時,增加了對異常的支持,try-catch語法。代碼可以寫成

try {
    int code = operation();
    switch(code) {
        case case1:
        //...
        break;
        case case2:
        //...
        break;
        default:
        //...
        break;
} catch(...) {
    // exception handling
}

這樣其實就把錯誤和異常給區分開了,對於錯誤,直接通過錯誤碼來返回;而對於異常,通過throw Error的方式讓外層catch住來處理,異常可以在調用棧的任意一層處理,如果不處理該異常,異常會一直往上冒泡。C++用RAII技術來保證異常安全,對象在析構的時候自己處理資源的釋放等。C++的異常對比C的模式,能承載更多信息,包括錯誤信息,錯誤調用棧等。

4. 進入node.js

node.js的錯誤處理最佳實踐可以參考Joyent的線上實踐,下面的文字很多都是參考自該文章。這裏之所以加上nodejs的錯誤處理,是我覺得一方面javascript是動態語言;另一方面,nodejs推崇異步操作。

Joyent將錯誤主要分爲以下兩大類:

  • 操作性錯誤 operational errors(以下簡稱OE):代表正確的代碼在運行時觸發的錯誤。代碼裏面沒有bug,問題出現在其他地方:系統本身(OOM,打開太多文件),網絡(請求超時,socket掛起)。
  • 程序員錯誤 programmer errors(以下簡稱PE):程序裏面的bug。例如:少傳遞參數,參數類型不匹配(靜態語言中,有類型檢查和編譯期,可以攔住其中的多種情況)。出於服務可用性考慮,從PE中恢復的最好方式就是立即崩潰,並重新啓動。

node.js中有多種錯誤處理模式,包括

  • try-catch-finally
  • callback(err, …): 這種callback的錯誤傳遞模式看起來很像go語言的模式。
  • promise reject
  • event emit

通常會將後面三種歸結爲異步的錯誤傳遞,而第一種稱爲同步的錯誤傳遞。對於一段代碼,可能會傳遞同步的錯誤(通過throw),也可能會傳遞異步的錯誤(通過傳遞到callback中、通過EventEmitter來觸發),但不應該兩種都使用。

上訴4種錯誤處理模式中,try-catch-finally是同步代碼中最常見的處理模式,寫法類似於C++。在異步代碼中,callback是最基本的處理模式,但太多callback會引起callback hell的問題,promise的出現解決了這問題,所以promise reject是異步代碼中最常見的處理方式。ES6(nodejs@8)中增加了async/await語法,讓異步代碼寫起來和同步代碼一樣,同時也能在異步代碼(僅限於async/await的異步代碼)中加try-catch-finally來捕獲異常了。event emit則在複雜的情況下才會派上用場,例如某些操作會產生多種結果或者多種錯誤、又或者操作存在多種狀態。(值得注意的是,try-catch是無法在callback和event emit中捕獲到throw出來的異常的)

5. 進入go

go對於錯誤處理的態度算是一股清流,真正區分開了錯誤和異常的處理。go語言的函數多值返回機制也爲返回error提供了便利。而對於異常,未實現主流的try-catch-finally,採用了defer-panic-recover的語法。

We believe that coupling exceptions to a control structure, as in the try-catch-finally idiom, results in convoluted code. It also tends to encourage programmers to label too many ordinary errors, such as failing to open a file, as exceptional.

In Go, error handling is important. The language’s design and conventions encourage you to explicitly check for errors where they occur (as distinct from the convention in other languages of throwing exceptions and sometimes catching them).

go實現者認爲將異常和控制結構耦合在一起,代碼會變得複雜。並且好像在鼓勵程序員將普通錯誤也標識成異常。go語言的設計鼓勵程序員明確的處理每一個出現的錯誤。

The Go paradigm is to check for errors explicitly. A program should only panic if the circumstances under which it panics do not happen during ordinary program executing.

因爲不想程序員濫用panic,go區分了error和exception(panic)的使用場景。在go社區之中,普遍認爲儘可能不用panic。panic預示着這是個fatal的錯誤,程序應當立馬終止。當使用panic時,你應當假設caller無法處理該問題,並且你的代碼,或是集成了你代碼的程序無法繼續進行下去。

我理解中,

  • go的設計想區分開錯誤處理和控制流的跳轉,在大量使用try-catch的語言中,代碼並沒有逐層去catch住異常,程序員隨心所欲地在某個地方加上try-catch,這樣便導致程序的控制流變得很複雜。
  • go真正賦予了異常”終止語義“。

當然,也有人覺得go的這種設計好像回退到C的時代,

rr := doStuff1()
if err != nil {
    //handle error...
}

err = doStuff2()
if err != nil {
    //handle error...
}

err = doStuff3()
if err != nil {
    //handle error...
}

上面代碼看起來確實很像C的status code的方式,只不過將errno換成了error對象。但Go作者之一的Russ Cox覺得選擇返回值的錯誤處理方式適用於大型項目,而try-catch的模式適合小程序。

6. 總結

以上寫的只是我接觸過的語言對錯誤處理的不同模式,但在我看來,只是不同語言推崇的處理模式(或者說編程習慣)不同,而在語言的機制上,給了相應的便利,但並不代表A語言沒法採用B語言的錯誤處理模式。更多的是,透過不同語言的錯誤處理方式,能窺到作者對於這個語言的期望和設計思想。所以,還是一句老話,沒有最好的技術,只有最適合場景的技術。

參考:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章