在計算和電腦編程領域中,例外處理(exception handling,也意譯為例外處理,需注意「异常」一般對應英文abnormality[1]),是對出現的例外的響應處理,在程式執行期間,異常或例外情況需要特殊處理。一般而言,例外打斷正常的執行流程並執行預先登記的「例外處理器」;具體如何去做依賴於它是硬件還是軟件例外,還有軟件例外是如何實現的。
例外是由電腦系統的不同層級來定義的,典型的層級有CPU定義的中斷、作業系統(OS)定義的訊號和程式語言定義的例外。每個層級都要求不同例外處理方式,但是它們可以是關聯的,比如說CPU中斷可能被轉變成OS訊號。一些例外特別是硬件例外,可以被優雅地處理使得程式執行能在它被中斷的地方恢復。
硬件的例外處理
硬件的例外處理機制由CPU完成。這種機制支援錯誤檢測,在發生錯誤後會將程式流跳轉到專門的錯誤處理常式中。發生異常前的狀態儲存在棧上。[2]
作業系統的例外處理
針對程式中可能發生的例外,作業系統可能通過IPC來提供對應的處理設施。行程執行過程中發生的中斷通常由操作提供的「中斷服務子程式」處理,作業系統可以藉此向該行程傳送訊號。行程可以通過註冊訊號處理器的方式自行處理訊號,也可以讓作業系統執行預設行為(比如終止該程式)。
從行程的視角,硬件中斷相當於可恢復異常,雖然中斷一般與程式流本身無關。
程式語言的例外處理
在程式語言領域,通常例外(英語:exception)這一術語所描述的是一種資料結構,該資料結構可以儲存例外的相關訊息。例外處理的常見的一種機制是移交控制權。引發(raise)異常,也叫作投擲(throw)異常,通過該方式達到移交控制權的效果。例外投擲後,控制權會被移交至某處的接(catch),並執行處理。
程式語言對例外有着截然不同的定義,而現代語言大致上可分兩類:[3]
- 用作於控制流程的例外,如:Ada、Java、Modula-3、ML、OCaml、Python、Ruby 。
- 用作於處理異常、無法預測、錯誤性的情況。如:C++[4]、C#、Common Lisp、Eiffel、Modula-2 。
從子程式作者的角度看,如果要表示當前子程式無法正常執行,投擲例外是很好的選擇。無法正常執行的原因可以是輸入參數無效(比如值在函數的定義域之外),也可以是無法獲得所需的資源(比如檔案不存在、硬碟出錯、主記憶體不足)等等。在不支援例外的系統中,子程式需要通過返回特殊的錯誤碼實作類似的功能。然而回傳錯誤碼可能導致不完全預測問題,子程式的使用方需要編寫額外的代碼,才能將普通的回傳值與錯誤碼相區別。
Kiniry強調:「語言設計僅僅部分地影響了對例外的使用,從而影響編程者處理系統執行期間部分或所有失敗的方式。其他主要的影響還有在核心庫、技術書籍、雜誌文章、線上研討討論區和特定組織的代碼標準中的使用範例」。[5]
在1960和1970年代,Lisp語言發展出軟件例外。最初版本是在1962年Lisp 1.5的時候,這時候異常通過ERRSET
關鍵詞進行捕捉,並在出錯時候,通過NIL
進行回傳,而不是以前的終止程式或者進行除錯器。[6]1960年代後半,Maclisp語言通過ERR
關鍵詞引入「引發」(Raise)錯誤機制。[6]Lisp的這種創新不僅僅被應用於投擲錯誤,還被應用於「非局部控制流」。在在1972年6月,Maclisp語言通過CATCH
和THROW
兩個新的關鍵詞來實現非局部控制流,並保留ERRSET
和ERR
專門做錯誤處理。在1970中後,NIL(「新實現的LISP」)衍生出清除操作UNWIND-PROTECT
,對應着現今常見的finally
。[7]該操作也被Common Lisp使用了。與之同時代,Scheme也誕生了dynamic-wind
,用於處理閉包中的異常。Goodenough (1975a)和Goodenough (1975b)是介紹結構化的例外處理的開創性文章。[8] 1980年後,例外處理被廣泛利用於許多程式語言。
PL/I語言使用的是動態作用域例外,然而稍微現代的程式語言多用詞法作用域的例外。PL/I語言的例外處理包含事件(不是錯誤)、注意(Attention)、EOF、列舉了的變數的修改(Modification of listed variables)。雖然現在的一些程式語言支援不含錯誤資訊的例外,但是他們並不常見。
一開始,軟件的例外處理是包含可恢復的例外,它具有恢復語意,就像大部分的硬件例外一樣,以及不恢復的例外,它具有終止語意。但是,在1960和1970時代,在實踐中得出恢復語意是十分低效的(C++標準相關的討論可見[9]),因此恢復語意就很少再出現了,通常只能在類似Common Lisp和Dylan這種語言中見到。
1980年Tony Hoare在評論Ada語言時,將例外處理提及為危險特徵。[10]
對於軟件而言,例外處理經常無法正確的處理,尤其是當這裏有多種來自不同原始碼的異常時。在對五百萬行Java代碼進行數據流分析時,我們發現了超過1300個例外處理。[11]這是1999-2004年的前沿報告以及他們的結論,Weimer和Necula寫到,異常是一個十分嚴峻的問題,他們會創造隱藏的控制流途徑,這種途徑是編程人員很難去推理的。
Go語言的初始版本並沒有例外處理,而因此被有的開發者認為控制流十分冗餘。[12]後來,追加了類似的例外處理的語法panic
/recover
機制,但是Go語言的作者建立這僅僅在整個程式不可恢復的錯誤時候使用它。[13][14][15][16]
異常,作為一個非結構化的流程,它會增加資源泄露的可能性(如:從鎖住的代碼中逃脫,在打開檔案時候逃脫掉),也有可能導致狀態不一致。因此,出現了集中例外處理的資源管理技術,最常見的結合dispose pattern和解除保護(unwind protection)一起使用(如finally
陳述式),會在這段代碼的控制權結束時自動釋放資源。
許多常見的程式語言支援例外處理,包括:
多數語言的異常機制的語法是類似的:用throw
或raise
投擲一個異常對象(Java或C++等)或一個特殊可延伸的列舉類型的值(如Ada語言);例外處理代碼的作用範圍用標記子句(try
或begin
開始的語言作用域)標示其起始,以第一個例外處理子句(catch, except, rescue
等)標示其結束;可連續出現若干個例外處理子句,每個處理特定類型的異常。某些語言允許else
子句,用於無例外出現的情況。更多見的是finally, ensure
子句,無論是否出現異常它都將執行,用於釋放例外處理所需的一些資源。
C語言沒有try-catch例外處理,而是使用返回碼用於錯誤檢查;setjmp
與longjmp
標準庫函數可以被用來通過宏實現try-catch處理[17]。一般在例外處理代碼的搜尋過程中會逐級完成堆疊輾轉開解(stack unwinding);但Common Lisp中進行例外處理的條件系統,不採取堆疊輾轉開解,因此允許例外處理完後在投擲異常的代碼處原地恢復執行。
C++例外處理是資源取得即初始化(RAII)的基礎。異常事件在C++中表示為「異常對象」(exception object)。異常事件發生時,由作業系統為程式設置當前異常對象,然後執行程式的當前例外處理代碼塊,在包含了異常出現點的最內層的try
塊,依次匹配同級的catch
陳述式。如果匹配catch
陳述式成功,則在該catch塊內處理異常;然後執行當前try...catch...
塊之後的代碼。如果在當前的try...catch...
塊沒有能匹配該異常對象的catch
陳述式,則由更外一層的try...catch...
塊處理該異常;如果當前函數內的所有try...catch...
塊都不能匹配該異常,則遞歸回退到呼叫棧的上一層函數去處理該異常。如果一直回退到主函數main()
都不能處理該異常,則呼叫系統函數terminate()
終止程式。
在Python中只存在語法錯誤和例外。語法錯誤是在執行之前發生的。而例外是在執行時發生的錯誤,除非進行捕捉處理,否則它將無條件停止程式。可以書寫代碼來處理選定的例外。[18]
Python語言中對例外處理機制的採用是非常普遍深入的,這種編碼風格被稱為EAFP(請求原諒比得到許可更容易)[19],它假定有效的鍵或特性存在,並在這個假定證明失敗時擷取例外。Python社區認為這種風格是清晰而快速的,它的特徵是會出現很多try
和except
陳述式。這種技術對立於常見於很多其他語言比如C語言中的LBYL(看好再跳)風格。
在Java中異常是異常事件(exceptional event)的縮寫。異常是一個事件,它發生在程式執行時並會打亂程式指示的正常流程。當方法出現了錯誤時,方法會建立一個對象並將它交給執行時系統,所建立的對象叫「異常對象」,該對象包含了錯誤的資訊(描述了出錯時的程式的類型和狀態)。建立錯誤對象和轉交給執行時系統的過程,叫投擲異常。[20]
class RuntimeException
和class Error
均是不檢查的異常(Unchecked Exceptions)。[21]錯誤不等於錯誤類(class Error
),錯誤類代表着不應該被捕捉的嚴重的問題。[22]class RuntimeException
意味着程式出現問題了。[21]
Go語言提倡的是錯誤處理(error handling)。Go語言設計者系統希望用戶在錯誤出時,顯式地檢查錯誤。[23] Go雖然不提供與Java語言的try..catch
同等的功能陳述式,但是取而代之,提供了輕型的例外處理機制panic...recover
。[24]
大多數.NET程式語言,內建的異常機制都是沿着函數呼叫堆疊的函數呼叫逆向搜尋,直到遇到例外處理代碼為止。而 Visual Basic(尤其是在其早於 .net 的版本,例如 6.0 中)走得更遠:on error
陳述式可輕易指定發生異常後是重試(resume
)還是跳過(resume next
)還是執行程式設計師定義的錯誤處理程式(goto ***
)。
錯誤處理(error handling)是通過處理常式的返回值的形式從而處理錯誤的一種編程方式。在Go等返回值可為複數的語言中,可通過將其中一個值設為錯誤值,從而達到錯誤處理的效果。
f, err := os.Open("filename.ext")
if err != nil {
log.Fatal(err)
}
// do something with the open *File f
在僅僅支援返回狀態碼的語言裏,可通過處理錯誤碼,達到錯誤處理的效果。shell語言可通過$?
獲得函數執行的退出碼,從而判斷是否出錯。
在其他語言中,可以通過判斷結果的某一個特徵,從而達到錯誤處理部分的效果,但不意味着這些語言自身支援錯誤處理。如,Java等物件導向的語言往往會通過null值判斷是否執行失敗,但有時候也會通過例外處理判斷是否執行失敗。
如果一個異常投擲後,沒有被捕捉,那麼未捕捉異常(uncaught exception)將會在執行時被處理。進行該處理的常式叫「未捕捉例外處理器」(uncaught exception handler)[25][26]。大部分的處理是終止程式並將錯誤資訊列印至控制台,該資訊通常包含除錯用的資訊,如:異常的描述資訊、棧追蹤。[27][28][29]通常處於最進階(應用級別)的處理器,即便捕捉到異常也會避免終止自身(如:線程出現異常,主線程也不會終止)。[30][31]
值得了解的是,在即便未捕捉異常導致了程式異常中斷(如:異常沒被捕捉、捲動未完成、沒釋放資源),程式仍舊能正常地順序性地關閉。只要確保執行時系統能正常地執行,因為執行時系統控制着整個程式的執行。
作為預設的未捕捉例外處理器是可以被替換的,不管是全域還是單線程的,新的未捕捉例外處理器可以嘗試做這些事情:未捕捉異常導致關閉了的線程,使之重新啟動;提供另一種方式記錄紀錄檔;讓用戶報告未捕捉異常等等。在Java中,單一線程可以使用Thread.setUncaughtExceptionHandler
[32],全域可以用Thread.setDefaultUncaughtExceptionHandler
[33];在python中,可通過修改sys.excepthook
[34]。
Java的設計者設計了[35] 檢查性異常(Checked exceptions)[36]。當方法引發「檢查性異常」時,「檢查性異常」將成為方法符號的一部分。例如:如果方法投擲了IOException
,我們必須顯式地使用方法符號(在Java中是try...catch
),如果不這樣做的話將會導致編譯時錯誤。
一段代碼是「異常安全的」,如果這段代碼執行時的失敗不會產生有害後果,如主記憶體泄露、儲存數據混淆、或無效的輸出。異常安全可分成不同層次:
- 「失敗透明」,也稱作「不投擲保證」:代碼的執行保證能成功並滿足所有的約束條件,即使存在異常情況。如果出現了異常,將不會對外進一步投擲該異常。(異常安全的最好的層次)
- 「提交或卷回的語意」,或稱作「強異常安全」或「無變化保證」:執行可以是失敗,但失敗的執行保證不會有負效應,因此所有涉及的數據都保持代碼執行前的初始值。[37]
- 「基本異常安全」:失敗執行的已執行的操作可能引起了副作用,但會保證狀態不變。所有儲存數據保持有效值,即使這些數據與異常發生前的值有所不同。
- 「最小異常安全」,也稱作「無泄漏保證」:失敗執行的已執行的操作可能在儲存數據中儲存了無效的值,但不會引起崩潰,資源不會泄漏。
- 「沒有異常安全」:沒有保證(最差的異常安全層次)。
例如,考慮一個smart vector類型,如C++的 std::vector
或Java的 ArrayList
。當一個數據項x
插入vector v
,必須實際增加x
的值到vector的內部對象列表中並且修改vector的計數域以正確表示v
中儲存了多少數據項;此時如果已有的儲存空間不夠大,就需要分配新的主記憶體。主記憶體分配可能會失敗並投擲異常。因此,vector資料類型如果是「失敗透明」保證將會非常困難甚至不可能實現。但vector類型提供「強異常安全」保證卻是相當容易的;在這種情況下,x
插入v
或者成功,或者v
保持不變。如果vector類型僅提供「基本異常安全」保證,如果數據插入失敗,v
可能包含也可能不包含x
的值,但至少v
的內部表示是一致的。但如果vector資料類型是「最小異常安全」保證,v
可能會是無效的,例如v
的計數域被增加了,但x
並未實際插入,使得內部狀態不一致。對於「異常不安全」的實現,程式可能會崩潰,例如寫入數據到無效的主記憶體。
通常至少需要基本異常安全。失敗透明是難於實現的,特別是在編寫庫函數時,因為對應用程式的複雜知識缺少獲知。
參照
參考文獻
外部連結
Wikiwand in your browser!
Seamless Wikipedia browsing. On steroids.
Every time you click a link to Wikipedia, Wiktionary or Wikiquote in your browser's search results, it will show the modern Wikiwand interface.
Wikiwand extension is a five stars, simple, with minimum permission required to keep your browsing private, safe and transparent.