錯誤、異常與自定義異常
- 2019 年 10 月 10 日
- 筆記
程序員對於異常(Exception)這個詞應該都不陌生,尤其現在Exception基本上是OOP編程語言的標配。於我而言,這個詞既熟悉又陌生,熟悉是因為聽過了很多遍、似乎也有大量使用;陌生是因為很少真正思考過到底什麼是異常,以及如何使用異常。本文記錄我對如何使用異常、自定義異常的一些看法,不一定正確,還請多多指教。
本文地址:https://www.cnblogs.com/xybaby/p/11645885.html
什麼是異常
異常是錯誤處理的一種手段:
exception handling is an error-handling mechanism
上述定義中的error是廣義的error,任何代碼邏輯、操作系統、計算機硬件上的非預期的行為都是error。並不是Java語言中與Exception對立的Error(Java中,Error和Exception是有區別的,簡而言之,Error理論上不應該被捕獲處理,參見Differences between Exception and Error),也不是golang中與panic對立的error。
在編程語言中,對於error的分類,大致可以分為Syntax errors、Semantic errors、Logical errors,如果從error被發現的時機來看,又可以分為Compile time errors、Runtime errors。
結合實際的編程語言,以及wiki上的描述:
Exception handling is the process of responding to the occurrence, during computation, of exceptions – anomalous or exceptional conditions requiring special processing – often disrupting the normal flow of program execution.
可以看出,一般來說,Exception對應的是Runtime error,比如下面的代碼
FileReader f = new FileReader("exception.txt"); //Runtime Error
如果文件不存在,就會拋出異常,但只有當程序運行到這一行代碼的時候才知道文件是否存在。
需要注意的是,異常並不是錯誤處理的唯一手段,另一種廣為使用的方式是error code
,error code
是一種更為古老的錯誤處理手段,下一章節將會就error code
與exception的優劣介紹。
什麼時候使用異常
下面用兩個例子來闡釋什麼時候使用異常。
初探異常
第一個例子來自StackExchange When and how should I use exceptions? .
題主需要通過爬取一些網頁,如http://www.abevigoda.com/
來判斷Abe Vigoda(教父扮演者)是否還在世。代碼如下:
def get_abe_status(url): # download the page page = download_page(url) # get all mentions of Abe Vigoda hits = page.find_all_mentions("Abe Vigoda") # parse the first hit for his status status = parse_abe_status(hits[0]) # he's either alive or dead return status == "alive" def parse_abe_status(s): '''Param s: a string of the form "Abe Vigoda is something" and returns the "something" part''' return s[13:]
簡而言之,就是下載網頁內容,提取所有包含"Abe Vigoda"的句子,解析第一個句子來判斷"Abe Vigoda"是否尚在人世。
上述的代碼可能會出現幾個問題:
download_page
由於各種原因失敗,默認拋出IOError- 由於url錯誤,或者網頁內容修改,hits可能為空
- 如果hits[0]不再是"Abe Vigoda is something" 這種格式,那麼
parse_abe_status
返回的既不是alive,也不是dead,與預期(代碼注釋)不相符
首先,對於第一個問題,download_page
可能拋出IOError,根據函數簽名,函數的調用者可以預期該函數是需要讀取網頁,那麼拋出IOError是可以接受的。
而對於第二個問題 — hits可能為空,題主有兩個解決方案。
使用error code
在這裡,就是return None
def get_abe_status(url): # download the page page = download_page(url) # get all mentions of Abe Vigoda hits = page.find_all_mentions("Abe Vigoda") if not hits: return None # parse the first hit for his status status = parse_abe_status(hits[0]) # he's either alive or dead return status == "alive"
顯然,這裡是通過error code(None)來告訴調用者錯誤的發生,上一章節也提到 error code是除了Exception handling之外的另一種廣泛使用的error handling 手段。
那麼error code相比Exception有哪些優缺點呢?
首先是優點:
- 沒有引入新的概念,僅僅是普通的函數調用
- 易於理解,不會打亂當前的執行流
相比Exception,其缺點包括:
- 代碼時刻都需要檢查返回值,而調用者很容易遺漏某些檢查,這就可能隱藏、推遲更嚴重問題的暴露
- 缺乏錯誤發生的上下文信息
- 有的時候一個函數根本沒有返回值(比如構造函數),這個時候就得依賴全局的error flag(errno)
比如Linux環境下,linux open返回-1
來表示發生了錯誤,但具體是什麼原因,就得額外去查看errno
回到上述代碼,從函數實現的功能來說,check_abe_is_alive
應該是比get_abe_status
更恰當、更有表達力的名字。對於這個函數的調用者,預期返回值應該是一個bool值,很難理解為什麼要返回一個None。而且Python作為動態類型語言放大了這個問題,調用很可能對返回值進行conditional execution
,如if check_abe_is_alive(url):
, 在這裡None
也被當成是False
來使用,出現嚴重邏輯錯誤。
返回None也體現了error code的缺點:延遲問題的暴露,且丟失了錯誤發生的上下文。比如一個函數應該返回一個Object,結果返回了一個None,那麼在使用這個返回值的某個屬性的時候才會出trace,但使用這個返回值的地方可能與這個返回值創建的地方已經隔了十萬八千里。沒有讓真正的、原始的錯誤在發生的時候就立刻暴露,bug查起來也不方便。
拋出異常
class NotFoundError(Exception): """Throw this when something can't be found on a page.""" def get_abe_status(url): # download the page page = download_page(url) # get all mentions of Abe Vigoda hits = page.find_all_mentions("Abe Vigoda") try: hits[0] except IndexError: raise NotFoundError("No mentions found.") # parse the first hit for his status status = parse_abe_status(hits[0]) if status not in ['alive', 'dead']: raise SomeTypeOfError("Status is an unexpected value.") # he's either alive or dead return status == "alive"
注意上面的代碼同時也包含了第三個問題的解決方案,即確保status
是alive
或者dead
二者之一。不過我們重點關注對hits
為空的處理。有兩點值得注意:
- 拋出的是自定義異常
NotFoundError
,而不是IndexError
。這是一個明智的選擇,因為hits
為空是一個實現細節,調用者很難想像為啥要拋出IndexError
。關於自定義異常,後面還有專門的章節討論。 - 通過嘗試捕獲
IndexError
來判斷hits為空,這個是不太推薦的做法,因為這裡明顯可以通過if not hits
來判斷hits
是否為空
關於用條件判斷(if
) 還是 try-catch
, 在Best practices for exceptions中是這樣描述的
Use exception handling if the event doesn’t occur very often, that is, if the event is truly exceptional and indicates an error (such as an unexpected end-of-file). When you use exception handling, less code is executed in normal conditions.
Check for error conditions in code if the event happens routinely and could be considered part of normal execution. When you check for common error conditions, less code is executed because you avoid exceptions.
用if
還是 try-catch
,其實暗示了關於異常本身一個有爭議的點:那就是exception是否應該充當流程控制的手段,wiki上總結說不同的語言有不同的偏好。不過,個人認為,如果能用if
,就不要使用try-catch
,exception僅僅使用在真正的異常情況。
再探異常
第二個例子來自stackoverflow When to throw an exception ,題主的習慣是針對任何非預期的情況都定義、拋出異常,如UserNameNotValidException
, PasswordNotCorrectException
, 但團隊成員不建議這樣做,因此題主發帖尋求關於異常使用的建議。
我想這是一個我們都可能遇到的問題,捕獲並處理異常相對簡單,但什麼時候我們應該拋出異常呢,該拋出標準異常還是自定義異常呢?我們先看看StackOverflow上的回答
高票答案1:
My personal guideline is: an exception is thrown when a fundamental assumption of the current code block is found to be false.
答主舉了一個Java代碼的例子:判斷一個類是不是List<>的子類,那麼理論上不應該拋出異常,而是返回Bool值。但是這個函數是有假設的,那就是輸入應該是一個類,如果輸入是null,那麼就違背了假設,就應該拋出異常。
高票答案2:
Because they’re things that will happen normally. Exceptions are not control flow mechanisms. Users often get passwords wrong, it’s not an exceptional case. Exceptions should be a truly rare thing, UserHasDiedAtKeyboard type situations.
答主直接回答題主的問題,強調異常應該是在極少數(預期之外)情況下發生的錯誤才應該使用,異常不應該是流程控制的手段
高票答案3:
My little guidelines are heavily influenced by the great book "Code complete":
- Use exceptions to notify about things that should not be ignored.
- Don’t use exceptions if the error can be handled locally
- Make sure the exceptions are at the same level of abstraction as the rest of your routine.
- Exceptions should be reserved for what’s truly exceptional.
答主參考《代碼大全》認為僅僅在出現了當前層次的代碼無法處理、也不能忽略的錯誤時,就應該拋出異常。而且異常應該僅僅用於真正的異常情況。
高票答案4:
One rule of thumb is to use exceptions in the case of something you couldn’t normally predict. Examples are database connectivity, missing file on disk, etc.
異常應該僅僅由於意料之外、不可控的情況,如數據連接,磁盤文件讀取失敗的情況
高票答案5:
Herb Sutter in his book with Andrei Alexandrescu, C++ Coding Standards: throw an exception if, and only if
- a precondition is not met (which typically makes one of the following impossible) or
- the alternative would fail to meet a post-condition or
- the alternative would fail to maintain an invariant.
從上述回答可以看出,如果違背了程序(routine)的基本假設(assumption、prediction、setup、pre-condition)h或者約束(post-condition、invariant),且當前層次的代碼無法恰當處理的時候就應該拋出異常。
現代軟件的開發模式,比如分層、module、component、third party library使得有更多的地方需要使用異常,因為被調用者沒有足夠的信息來判斷應該如何處理異常情況。比如一個網絡鏈接庫,如果連接不上目標地址,其應對策略取決於庫的使用者,是重試還是換一個url。對於庫函數,拋出異常就是最好的選擇。
自定義異常
在上一章節中我們已經看到了自定義異常(NotFoundError
)的例子.
程序員應該首先熟悉編程語言提供的標準異常類,需要的時候盡量選擇最合適的標準異常類。如果標準異常類不能恰如其分的表達異常的原因時,就應該考慮自定義異常類,尤其是對於獨立開發、使用的第三方庫。
自定義異常有以下優點:
- 類名暗示錯誤,可讀性強, 這也是標準庫、第三方庫也有很多異常類的原因
- 方便業務邏輯捕獲處理某些特定的異常
-
可方便添加額外信息
For example, the FileNotFoundException provides the FileName property.
在Why user defined exception classes are preferred/important in java?中也有類似的描述
To add more specific Exception types so you don’t need to rely on parsing the exception message which could change over time.
You can handle different Exceptions differently with different catch blocks.
一般來說,應該創建框架對應的特定異常類,框架裏面所有的異常類都應該從這個類繼承,比如pymongo
class PyMongoError(Exception): """Base class for all PyMongo exceptions.""" class ProtocolError(PyMongoError): """Raised for failures related to the wire protocol.""" class ConnectionFailure(PyMongoError): """Raised when a connection to the database cannot be made or is lost."""
異常使用建議
在知道什麼時候使用異常之後,接下來討論如何使用好異常。
下面提到的實踐建議,力求與語言無關,內容參考了9 Best Practices to Handle Exceptions in Java、Best practices for exceptions
Exception應該包含兩個階段,這兩個階段都值得我們注意:
- Exception initialization:通過raise(throw)拋出一個異常對象,該對象包含了錯誤發生的上下文環境
- Exception handling,通過try – catch(expect) 來處理異常,通常也會通過finally(ensure)來處理一下無論異常是否發生都會執行的邏輯,以達到異常安全,比如資源的釋放。
try-catch-finally
try-catch-finally代碼塊就像事務,無論是否有異常發生,finally語句都將程序維護在一種可持續,可預期的狀態,比如上面提到的資源釋放。不過為了防止忘掉finally的調用,一般來說編程語言也會提供更友好的機制來達到這個目的。比如C++的RAII,python的with statement,Java的try-with-resource
如果可以,盡量避免使用異常
前面提到,exception應該用在真正的異常情況,而且exception也會帶來流程的跳轉。因此,如果可以,應該盡量避免使用異常。“Specail case Pattern“`就是這樣的一種設計模式,即創建一個類或者配置一個對象,用來處理特殊情況,避免拋出異常或者檢查返回值,尤其適合用來避免return null。
自定義異常, 應該有簡明扼要的文檔
前面也提到,對於第三方庫,最好先有一個於庫的意圖相匹配的異常基類,然後寫好文檔。
exception raise
對於拋出異常的函數,需要寫好文檔,說清楚在什麼樣的情況下會拋出什麼樣的異常;而且要在異常類體系中選擇恰到好處的異常類,Prefer Specific Exceptions
。
clean code vs exception
《clean code》建議第三方庫的使用者對第三方庫可能拋出的異常進行封裝:一是因為對這些異常的處理手段一般是相同的;二是可以讓業務邏輯於第三方庫解耦合。
In fact, wrapping third-party APIs is a best practice. When you wrap a third-party API, you minimize your dependencies upon it
exception handling
捕獲異常的時候,要從最具體的異常類開始捕獲,最後才是最寬泛的異常類,比如python的Exception
。
In catch blocks, always order exceptions from the most derived to the least derived
程序員應該認真對待異常,在項目中看到過諸多這樣的python代碼:
try: # sth except Exception: pass
第一個問題是直接捕獲了最寬泛的類Exception
;其次並沒有對異常做任何處理,掩耳盜鈴,當然,實際中也可能是打印了一條誰也不會在乎的log。
如果我們調用了一個接口,而這個接口可能拋出異常,那麼應該用當前已有的知識去儘力處理這個異常,如果當前層次實在無法處理,那麼也應該有某種機制來通知上一層的調用者。checked exception
肯定是比函數文檔更安全、合適的方法,不過諸多編程語言都沒有checked exception
機制,而且《clean code》也不推薦使用checked exception
,因為其違背了開放關閉原則,但是也沒有提出更好的辦法。
Wrap the Exception Without Consuming It
有的時候拋出自定義的異常可能會比標準異常更有表達力,比如讀取配置文件的時候 ConfigError(can not find config file)
比IoError
更合適,又比如前面例子中的NotFoundError
不過,重要的是要保留原始的trace stack,而不是讓re-raise的stack。比如以下Java代碼:
public void wrapException(String input) throws MyBusinessException { try { // do something may raise NumberFormatException } catch (NumberFormatException e) { throw new MyBusinessException("A message that describes the error.", e); } }
python2中是下面的寫法
def bar(): try: foo() except ZeroDivisionError as e: # we wrap it to our self-defined exception import sys raise MyCustomException, MyCustomException(e), sys.exc_info()[2]
references
- clean code
- Differences between Exception and Error
- Programming Error and exception handling
- 9 Best Practices to Handle Exceptions in Java
- Best practices for exceptions
- When to throw an exception?
- linux open
- When and how should I use exceptions?
- Why user defined exception classes are preferred/important in java?
- Properly reraise exception in Python (2 and 3)