1. 程式人生 > >錯誤、異常與自定義異常

錯誤、異常與自定義異常

程式設計師對於異常(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 codeerror 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"

注意上面的程式碼同時也包含了第三個問題的解決方案,即確保statusalive或者dead二者之一。不過我們重點關注對hits為空的處理。有兩點值得注意:

  1. 丟擲的是自定義異常NotFoundError,而不是IndexError。這是一個明智的選擇,因為hits為空是一個實現細節,呼叫者很難想象為啥要丟擲IndexError。關於自定義異常,後面還有專門的章節討論。
  2. 通過嘗試捕獲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)