譯:當(dāng)心泛型異常帶來的風(fēng)險

原文鏈接 Beware the dangers of generic Exceptions

捕獲和拋出泛型異常(Generic Exceptions)會讓你不知不覺地陷入困境。

在最近從事的一個項目中,我發(fā)現(xiàn)了一段清理資源的代碼。因為這段代碼有各種各樣的調(diào)用,它可以拋出6種不同的異常。為了簡化代碼(或者是不愿意敲更多代碼),當(dāng)初設(shè)計程序的程序員聲明這個方法拋出Exception異常,而不是六種潛在的不同異常。這就迫使調(diào)用代碼必須被封裝在一個捕獲Exception異常的try/catch代碼塊中。程序的設(shè)計者認(rèn)為這段代碼的目的是清理資源,失敗的情況并不重要,所以catch塊為空直到系統(tǒng)被關(guān)閉a。

a:這里代碼的編寫者在捕獲了異常之后,直接忽略了處理異常。

顯然,這些都不是最佳的編程實踐,但似乎也算不上嚴(yán)重的錯誤...除了代碼第三行中一個小小的邏輯錯誤:

代碼清單1. 最初的清理代碼

private void cleanupConnections() throws ExceptionOne, ExceptionTwo {
    for (int i = 0; i < connections.length; i++) {
        connections[i].release(); // Throws ExceptionOne, ExceptionTwo
        connections[i] = null;
    }
    connections = null;
}

protected abstract void cleanupFiles() throws ExceptionThree, ExceptionFour;
protected abstract void removeListeners() throws ExceptionFive, ExceptionSix;

public void cleanupEverything() throws Exception {
    cleanupConnections();
    cleanupFiles();
    removeListeners();
}

public void done() {
    try {
        doStuff();
        cleanupEverything();
        doMoreStuff();
    } catch (Exception e) {}
}

在代碼的另一部分,connections數(shù)組在第一個connection被創(chuàng)建了之后才被初始化。但是,如果沒有任何connection被創(chuàng)建,那么connections數(shù)組為空,即null。所以在一些情況下,調(diào)用connections[i].release()將會導(dǎo)致NullPointerException。這是一個處理起來相對簡單的問題,只要為connections數(shù)組添加加一個檢查就行:connections != null。

但是這個異常從未被報告過。該異常被cleanupConnections()拋出,接著被cleanupEverything()再次拋出,最后在done()方法中被捕獲。而done()方法并未對異常作出任何處理,甚至沒有日志記錄。由于cleanupEverything()只在done()方法中被調(diào)用,因此這個異常從未被發(fā)現(xiàn)。所以代碼也從未得到修復(fù)。

因此,在cleanupConnections()失敗的情況下,cleanupFiles()方法和removeListeners()方法均不會被調(diào)用(所以他們占用的資源也從未被釋放),并且doMoreStuff()也從未被調(diào)用,這樣在done()方法中的最后處理永遠(yuǎn)都沒有完成。讓情況更糟的是,在系統(tǒng)關(guān)閉的時候,done()并沒有被調(diào)用;相反,done()方法被調(diào)用來完成每一次事務(wù)。所以在每一次事務(wù)中都會產(chǎn)生資源泄漏。

顯然這是一個很重要的問題:錯誤未被報告并且產(chǎn)生了資源泄漏。但是代碼本身看起來似乎很無辜,并且從代碼的編寫方式來看,這個問題很難被追蹤。但是,通過應(yīng)用以下幾個簡單的原則,這個問題就可以被發(fā)現(xiàn)并且被修復(fù):

  • 不要忽略異常
  • 不要捕獲泛型異常(Generic Exceptions)
  • 不要拋出泛型異常(Generic Exceptions)

1. 不要忽略異常

代碼清單1中最明顯的問題就是程序中的錯誤被完全忽略了。一個非預(yù)期的異常(異常,從他們的屬性上來說,就是非預(yù)期的)被拋出了,然而程序并未做好處理這個異常的準(zhǔn)備。這個異常甚至從未被報告過,因為程序假設(shè)那些預(yù)期的異常并不重要。

在大多數(shù)情況下,一個異常應(yīng)該至少被日志記錄。好幾個日志包都可以在不過多影響系統(tǒng)性能的情況下記錄系統(tǒng)錯誤和異常。大多數(shù)日志系統(tǒng)也允許打印棧記錄,這樣可以為查找異常發(fā)生的位置和原因提供有效的信息。最后,因為日志通常是寫入文件,異常的記錄就可以方便地被查看和分析。

在一些特殊的情況下,用日志記錄異常并不是非常重要的。在finally塊中清理資源就是其中的一種情況。

1.1 finally中的異常

在代碼清單2中,部分?jǐn)?shù)據(jù)是從一個文件中讀取的。這個文件必須被關(guān)閉無論在讀取數(shù)據(jù)的過程中是否發(fā)生異常,因此close()方法就被放在了finally句子中。但是如果在關(guān)閉文件的時候發(fā)生錯誤,那我們也就無能為力了:
代碼清單2:

public void loadFile(String fileName) throws IOException {
    InputStream in = null;
    try {
        in = new FileInputStream(fileName);
        readSomeData(in);
    } finally {
        if (in != null) {
            try { 
                in.close();
            } catch (IOException ioe) {
                // Ignored
            }
        }
    }
}

請注意如果因為I/O問題導(dǎo)致數(shù)據(jù)加載失敗,那么對loadFile()的調(diào)用仍然會拋出一個IOException異常。同時請注意,即使異常在close()方法中被忽略,在程序注釋中的聲明也讓這段代碼對所有使用它的人來說是清晰易懂的。你也可以將同樣的處理流程應(yīng)用到所有的I/O流清理,關(guān)閉socketJDBC接口等等。

忽略異常中關(guān)鍵的一點是保證只有一個方法被放置在了忽略異常的try/catch模塊中(所以try/catch模塊之外的其他方法依然可以被調(diào)用)并且一個指定的異常會被捕獲。這是一種明顯區(qū)別與其他其他捕獲泛型異常的特殊情況。在其他情況下,異常應(yīng)該(至少)被日志記錄,用棧追蹤記錄則更好。

2. 不要捕獲泛型異常

在一個復(fù)雜的軟件中,經(jīng)常有特定的代碼塊執(zhí)行多個可能拋出一系列異常b的方法。動態(tài)加載類和初始化一個對象也可能拋出多個不同的異常,其中包括ClassNotFoundException,InstantiationExceptionIllegalAccessExceptionClassCastException。

譯者注:一個方法拋出多個異常

一個忙兮兮的程序員可能簡單地把方法調(diào)用放在一個捕獲泛型異常的try/catch代碼塊中,而不是為try模塊添加四個不同的catch模塊(請參考代碼清單3)。雖然這種寫法看起來沒什么壞處,卻會帶來一些無意識的負(fù)面效果。例如,如果className()null,Class.forName()就會拋出NullPointerException,而NullPointerException會被該方法捕獲。

這樣,catch塊就捕獲了本來沒打算捕獲的異常,因為NullPointerExceptionRuntimeException的子類,而RuntimeException又是Exception的子類。所以catch (Exception e)捕獲了所有RuntimeException的子類,其中包括NullPointerException, IndexOutOfBoundsExceptionArrayStoreException。通常,一個程序員都不打算去捕獲這些異常。

代碼清單3中,classNamenull將會導(dǎo)致拋出NullPointerException,該異常提示調(diào)用方法這是一個無效的類名:

public SomeInterface buildInstance(String className) {
    SomeInterface impl = null;
    try {
        Class clazz = Class.forName(className);
        impl = (SomeInterface) clazz.newInstance();
    } catch (Exception e) {
        log.error("Error creating class: " + className);
    } 
    return impl;
}

捕獲泛型異常的另一個后果是限制了日志記錄,因為catch代碼塊不知道自己捕獲了哪個一特定的異常。一些程序員在面對這個問題的時候,采用添加檢查來確定異常的類(參考代碼清單4),這一做法是與使用catch塊的目的相矛盾的:
代碼清單4:

catch (Exception e) {
    if (e instanceOf ClassNotFoundException) {
        log.error("Invalid class name: " + className + ", " + e.toString());
    } else {
        log.error("Cannot create class: " + className + ", " + e.toString());
    }
}

代碼清單5提供了一個完整的捕獲特定異常的例子,一些程序員可能會感興趣。這樣,instanceOf就不是必要的了,因為對應(yīng)的異常會被捕獲。每一個受檢異常(ClassNotFoundException,InstantiationExceptionIllegalAccessException)都會被捕獲并且處理。通過檢查相對應(yīng)的異常,產(chǎn)生ClassCastException的特殊情況也會被查證:

public SomeInterface buildInstance(String className) {
    SomeInterface impl = null;
    try {
        Class clazz = Class.forName(className);
        impl = (SomeInterface)clazz.newInstance();
    } catch (ClassNotFoundException e) {
       log.error("Invalid class name: " + className + ", " + e.toString());
    } catch (InstantiationException e) {
       log.error("Cannot create class: " + className + ", " + e.toString());
    } catch (IllegalAccessException e) {
       log.error("Cannot create class: " + className + ", " + e.toString());
    } catch (ClassCastException e) {
       log.error("Invalid class type, " + className
       + " does not implement " + SomeInterface.class.getName());
    }
    return impl;
}

在某些情況下,更好的辦法是重新拋出一個已知的異常(或者創(chuàng)建一個新的異常),而不是直接在方法中處理異常。這就允許調(diào)用方法通過把異常放置在已知的上下文中來處理錯誤情況。

代碼清單6提供了另外一個版本的buildInterface()方法,如果在加載和初始化類的時候發(fā)生錯誤,那么該方法將會拋出ClassNotFoundException。在這個例子中,程序會確保調(diào)用方法得到一個合理初始化的對象或者是一個異常。因此,調(diào)用方法并不需要去檢查返回值是否為null。

注意這個例子使用了Java 1.4中的通過封裝另外一個異常來創(chuàng)建一個新的異常的方法,這個方法可以保留原有的棧追蹤信息。不然,棧追蹤將會指明buildInterface()是產(chǎn)生異常的方法,而不是更深層的真正拋出異常的newInstance()方法。

代碼清單6:

public SomeInterface buildInstance(String className) throws ClassNotFoundException {
    try {
        Class clazz = Class.forName(className);
        return (SomeInterface)clazz.newInstance();
    } catch (ClassNotFoundException e) {
        log.error("Invalid class name: " + className + ", " + e.toString());
        throw e;
    } catch (InstantiationException e) {
        throw new ClassNotFoundException("Cannot create class: " + className, e);
    } catch (IllegalAccessException e) {
        throw new ClassNotFoundException("Cannot create class: " + className, e);
    } catch (ClassCastException e) {
        throw new ClassNotFoundException(className
        + " does not implement " + SomeInterface.class.getName(), e);
    }
}

在一些情況下,代碼也許能夠從特定的錯誤狀態(tài)中恢復(fù)正常。在這些情況中,捕獲一些特定的異常是非常重要的,因為代碼可以查證在一個特定錯誤狀態(tài)是否可以被恢復(fù)。請帶著上述觀點來查看代碼清單6中的類初始化例子。

代碼清單7中,當(dāng)參數(shù)className無效時,代碼返回一個默認(rèn)的對象,但是在非法操作的情況下會拋出一個異常,例如一個無效的類型轉(zhuǎn)換或者是訪問權(quán)限不夠。

注意: 這里提到的IllegalClassException是一個用來演示的局域異常類。
代碼清單7

public SomeInterface buildInstance(String className) throws IllegalClassException {
    SomeInterface impl = null;
    try {
        Class clazz = Class.forName(className);
        return (SomeInterface)clazz.newInstance();
    } catch (ClassNotFoundException e) {
        log.warn("Invalid class name: " + className + ", using default");
    } catch (InstantiationException e) {
        log.warn("Invalid class name: " + className + ", using default");
    } catch (IllegalAccessException e) {
        throw new IllegalClassException("Cannot create class: " + className, e);
    } catch (ClassCastException e) {
        throw new IllegalClassException(className + " does not implement " + SomeInterface.class.getName(), e);
    }
    if (impl == null) {
        impl = new DefaultImplemantation();
    }
    return impl;
}

2.1 什么時候應(yīng)該捕獲泛型異常

某些情況下,捕獲泛型異常是便捷而且合理的。這些情況非常特殊,但是對大型并且容錯的系統(tǒng)來說是至關(guān)重要的。在代碼清單8中,請求從queue中被讀出并按順序被處理。但是,如果在請求處理的過程中產(chǎn)生任何異常(不管是 BadRequestException還是RuntimeException的任何子類, 包括NullPointerException),那么該異常會在處理請求的while循環(huán)之外被捕獲。因此任何錯誤豆?jié){導(dǎo)致請求處理循環(huán)中,并且剩下的請求將不會被處理。這展示了在處理請求的過程中一個極不合理的錯誤處理機制:

public void processAllRequests() {
    Request req = null;
    try {
        while (true) {
            req = getNextRequest();
            if (req != null) {
                processRequest(req); // throws BadRequestException
            } else {
                // Request queue is empty, must be done
                break;
            }
        }
    } catch (BadRequestException e) {
        log.error("Invalid request: " + req, e);
    }
}

解決請求處理的一個更好方法是在代碼的邏輯中做兩個重要修改,請查看下面的代碼清單9。第一,將try/catch模塊移到請求處理的循環(huán)內(nèi)。這樣,任何錯誤都會被捕獲并在請求處理循環(huán)內(nèi)得到解決。這樣,即使當(dāng)一個請求處理失敗,循環(huán)依然會繼續(xù)處理其他請求。第二,修改try/catch模塊用以捕獲泛型異常,所以任何異常都會在循環(huán)內(nèi)被捕獲并且剩余請求會被繼續(xù)處理:
代碼清單9:

public void processAllRequests() {
    while (true) {
        Request req = null;
        try {
            req = getNextRequest();
            if (req != null) {
                processRequest(req); // Throws BadRequestException
            } else {
                // Request queue is empty, must be done
                break;
            }
        } catch (Exception e) {
            log.error("Error processing request: " + req, e);
        }
    }
}

捕獲一個泛型異常聽起來違背了第二部分剛開始的觀點--的確是這樣的。但是我們這里討論的是一個特殊的情境。在這種情況下,我們捕獲泛型異常是為了防止系統(tǒng)因為一個異常而停止運行。在用循環(huán)(loop)處理請求、事務(wù)和事件的情況下,即使在處理過程中有異常被拋出,該循環(huán)需要持續(xù)運行。

代碼清單9中,while循環(huán)中的try/catch可以被看作是最高級別的異常管理器,因此這個頂級的異常管理器需要去捕獲并且用日志記錄所有在這個代碼級別引發(fā)的異常。這樣,異常就不會被忽略或者丟失,同時這些異常也不會影響剩余的需要被處理的請求。

每一個大型的、復(fù)雜的系統(tǒng)都有一個頂級的異常管理器(也許每一個子系統(tǒng)都有一個,這取決于系統(tǒng)如何實現(xiàn)處理)。該頂級異常管理器的目的并不是修復(fù)導(dǎo)致異常產(chǎn)生的底層問題,但它必須能夠在不停止處理的情況下捕獲并記錄這些問題。這也并不意味和所有異常都要在這一層被拋出。任何可以在更低層被處理的異常都應(yīng)該在更低層被處理:即當(dāng)問題發(fā)生時,異常應(yīng)該在邏輯可以幫助理解更多狀況的地方被處理。但是如果一個異常無法在一個更低層被處理,那么拋出到更高的級別。這樣無法修復(fù)的錯誤就被被集中到同一個地方(最高級別的異常管理器中)去處理,而不是分散在整個系統(tǒng)中。

3. 不要拋出泛型異常

代碼清單1的最終問題源于程序員決定在cleanupEverything()方法中拋出一個泛型異常。當(dāng)一個方法拋出六種不同的異常時,代碼可能會變得雜亂:方法聲明變得難以閱讀、迫使調(diào)用方法捕獲六種不同的異常,請看代碼清單10

public void cleanupEverything() throws 
        ExceptionOne, ExceptionTwo, ExceptionThree,
        ExceptionFour, ExceptionFive, ExceptionSix {
    cleanupConnections();
    cleanupFiles();
    removeListeners();
}
public void done() {
    try {
        doStuff();
        cleanupEverything();
        doMoreStuff();
    } catch (ExceptionOne e1) {
        // Log e1
    } catch (ExceptionTwo e2) {
        // Log e2
    } catch (ExceptionThree e3) {
        // Log e3
    } catch (ExceptionFour e4) {
        // Log e4
    } catch (ExceptionFive e5) {
        // Log e5
    } catch (ExceptionSix e6) {
        // Log e6
    }
}

但即使是代碼有點雜亂,至少是清晰的。使用特定的異常避免了一些真實存在的問題:首先,拋出一個泛型異常隱藏了潛在問題的細(xì)節(jié),因此也就丟失了處理這個現(xiàn)實問題的機會。其次,拋出一個泛型異常會迫使所有調(diào)用該方法的代碼捕獲泛型異常(如我們之前討論的,這個用法有問題),或者是通過重新拋出泛型異常來方法這個問題。

通常,當(dāng)一個方法聲明它拋出一個泛型異常時有如下兩個原因:其一,該方法調(diào)用了幾個可以拋出多個不同異常的方法(比如調(diào)停者模式和門面模式)并且隱藏了異常狀態(tài)的細(xì)節(jié)。因此該方法簡單地聲明拋出Exception泛型異常并忽略,而不是創(chuàng)建并拋出一個域級異常(用來封裝更低層的異常)。其二,程序并未想清楚應(yīng)該用什么異常來表述當(dāng)前的狀況,因此方法初始化并拋出一個泛型異常(throw new Exception())。

只要稍微思考和設(shè)計,這兩方面的問題都可以解決:究竟應(yīng)該拋出哪個詳細(xì)的域級異常?設(shè)計應(yīng)當(dāng)包含簡單的聲明,該聲明指出方法應(yīng)該拋出那些可能出現(xiàn)的異常。另外一個選擇是創(chuàng)建一個域級異常來封裝和聲明被拋出的異常。大多數(shù)情況下,一個方法拋出的異常(或者一系列不同的異常)應(yīng)該越詳細(xì)越好。詳細(xì)的異常提供了更多有關(guān)錯誤狀態(tài)的信息,因此可以讓錯誤狀況得到處理或至少被詳細(xì)地記錄。

泛型異常Exception類是一個非受檢異常,這意味著調(diào)用任意拋出泛型異常的方法都必須聲明拋出泛型異常,或者將方法調(diào)用封裝在一個捕獲泛型異常的try/catch模塊中。我已經(jīng)在前面的方法中解釋過這個問題。

4. 小心使用泛型異常

這篇文章探索了如何使用泛型異常的幾個方面:永遠(yuǎn)不要拋出泛型異常,并且永遠(yuǎn)不要忽略泛型異常;盡量少或者不要捕獲泛型異常(除非在不得已的特殊情況下)。泛型異常并沒有提供允許你有效處理他們的詳細(xì)信息,并且你最終可能捕獲那些本不打算捕獲的異常。

異常是Java語言中一個強有力的組件,如果使用得當(dāng)?shù)脑?,它能使你稱為一個高效的程序員并且縮短你的開發(fā)周期,特別是在測試和調(diào)試的時候。當(dāng)異常被錯誤使用時,它們可以通過隱藏你系統(tǒng)中的問題來阻礙你。所以,請注意在哪以及如何使用泛型異常。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容