深入理解 Java 異常處理:從基礎概念到最佳實踐指南

深入理解 Java 異常處理:從基礎概念到最佳實踐指南

Overview of Content

在軟體開發中,處理例外情況是至關重要的一環。本文將帶您深入探討Java中的異常處理機制,從理解異常的含意開始,涵蓋了 C 語言和 Java 的例外處理方式,以及Java虛擬機(JVM)與異常之間的關係

我們將探討捕捉異常的不同方式,包括 finally 區塊的特殊狀況和 Java 中不同類型異常的區別

最後,我們會分享使用例外的最佳原則,幫助您在開發過程中更有效地處理例外情況,保證程式碼的可靠性和穩定性

寫文章分享不易,如有引用參考請詳註出處,如有指導、意見歡迎留言(如果覺得寫得好也請給我一些支持),感謝 😀


例外、異常的含意

例外是會改變正常的程式流程

● 如果有可能產生例外,可以有以下處理方式

A. 盡量不要發生例外;若不能完全避免應該盡可能的降低例外發生的機率

B. 在可控的範圍內捕捉例外,並 安全的處理 (後面會說明)

C. 如果是程序本身例外,那就只能靠程序自身處理;而有些例外則是自身程序無法處理的,就必須靠系統處理

D. 當程序本身發生例外應該進可能的處理(或是轉換),避免例外的連鎖反應,否則可能造成錯誤不明確(不易定位錯誤位置)

文檔的重要性

如果是使用第三方程式應該 詳細閱讀文檔,以保證你可以掌握使用該程式時可能造成的所有例外


C 語言的例外處理

● C 語言為例,它沒有拋出例外的機制,傳統的例外處理方式都是返回一個特殊碼,來代表例外,以 Java 程序來表示就如下


class MockCError {

    public static final int HANDLE_OK = 0;
    public static final int HANDLE_FAIL = -1;

    public int checkData(String data) {

        if (data.length() < 10) {
            return HANDLE_FAIL;
        }

        return HANDLE_OK;
    }

}

這種方式(返回錯誤碼)的例外處理會產生幾個問題

A. 處理例外的能力有限:只靠方法的返回值無法代表完整的錯誤資訊

利用錯誤碼大致上能描述該錯誤的方向,而錯誤方向內容其實可以分類非常多種(除非你把錯誤碼定義的相當詳盡)

B. 無法快速追蹤錯誤來源:這會導致分析錯誤的速度變慢,增加複雜性


class MockCCreateData {

    public static final int GEN_VALID_DATA = 666;
    public static final int GEN_INVALID_DATA = -666;

    public int startCheckData(String data) {

        if (new MockCError().checkData(data) != MockCError.HANDLE_OK) {
            return GEN_VALID_DATA;
        }

        return GEN_INVALID_DATA;
    }

}

如下,如果發生錯誤那錯誤點到底在哪?必須先找到呼叫的表層(-666),才能去找出裡面的錯誤原因(-1

● 尤其當 系統不斷增大時,就可能導致維護程式障礙

Java 例外機制

● Java 是物件導向著名的語言,所以處理例外的方式也是 物件導向 的方式,Java 處理例外有以下優點

A. 將原先的錯誤透過 物件的方式包裹程不同類型(用物件取代了錯誤碼),充分發揮了 可拓展可複用 的特性

B. 錯誤類別可 提高程式的 可讀性(更清晰),並簡化結構


// 可拓展
class MessageException extends Exception {

    public MessageException(String msg) {
        super("Message msg:" + msg);    
    }

}

class ShowMessage {

    // 清楚定義例外後,可以快速知道該方法會產生哪種可能錯誤
    void showMsg(String msg) throws MessageException {
        if (msg == null) {
            // 依照情況可複用
            throw new MessageException("Message was null");
        }

        if (msg.isEmpty()) {
            // 依照情況可複用
            throw new MessageException("Message was is zero");
        }

        System.out.println(msg);
    }

}

C. 若是當前方法沒有辦法處理例外,則 自動將例外往上傳遞,不用自己想辦法再定義一個錯誤拋出


class AutoThrow {

    void throwRuntime() {
        // 錯誤自動往上拋出
        throw new RuntimeException();
    }

    void mockFunction() {
        throwRuntime();
    }

    public static void main(String[] args) {
        new AutoThrow().mockFunction();

    }

}

晚點說明必須捕捉例外(checked)、不必捕捉例外(Runtime

可以看到 Java 詳細的錯誤棧

明確的異常 很重要?

依照函數意圖,拋出正確的異常相當重要,這樣才能表達出明確的異常


class Rest {

    static class RestException extends Exception { }

    void takeNap(int time) throws RestException {
        if (time < 5) {
            throw new RestException();
        }
    }

}

class WorkDay {

    static class WorkException extends Exception { }

    private final Rest rest = new Rest();

    // 使用該函數就應該拋出與該函數最相關的異常
    void workTime() throws WorkException {
        try {
            rest.takeNap(3);
        } catch (Rest.RestException e) {
            // 捕捉異常,並轉換更與使用當前函數相關的異常類
            throw new WorkException();
        } 
    }

}

JVM 處理異常

Java 虛擬機中,每個 Thread 都會有一個私有棧(Stack 結構方式),它可以保存所有方法的呼叫順序(由於 Stack 所以是先進後出),當發生異常時方法就會按照順序出棧

● JVM 中棧的概念

● 正常呼叫時 JVM 中棧的堆疊

graph BT; main_function-->sub_function_1; sub_function_1-->sub_function_2; sub_function_2-->sub_function_3_棧頂_運行中;

● 當出現異常時 JVM 中棧的堆疊依照 Stack 結構彈出

graph TD; sub_function_3_發生異常-->sub_function_2; sub_function_2-->sub_function_1; sub_function_1-->main_function;

● 而每次彈出到上一層函數是都會檢查該函數是否會 catch 捕捉的區塊,如果有的話則檢查是否有捕捉到指定異常

如果都沒有,就往更上一層拋出


異常的效率

● 一般來說處理 try/catch 並不會對 JVM 性能產生太大的影響;但是由於在 發生例外時,JVM 需要額外執行錯誤定位

● 如果丟出例外的程式碼、捕捉例外的程式碼在同一個區塊,那就不會造成太大影響(如下)


void sameLayer() {

    try {
        throw new IllegalAccessException();
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    }

}

● 如果異常沒有即時捕捉,那 JVM 必須 必須呼叫堆疊,並尋找合適處理例外的地方這就會對性能產生較大的影響!


class JVMException {

    void function3() {
        throw new RuntimeException();
    }

    void function2() {
        function3();
    }

    void function1() {
        function2();
    }

    // JVM 底層方法
    public static void main(String[] args) {
        try {
            new JVMException().function1();
        } catch (RuntimeException e) {
            // 搜尋到底層才找到對應的處理函數,這時效能就會差
            e.printStackTrace();
        }
    }

}

Java 捕捉異常

如果你處理異常的位置在 Stack 底,那 JVM 就必須為了處理異常產生大量的工作

這裡不提及基礎的異常如何使用,而是針對幾個特殊案例來實驗測試

throwthrows 兩個關鍵字很像,但用在完全不同的地方

關鍵字說明
throw用來拋異常
throws用來聲明方法會拋出的異常類有哪些(有多個)

finally 特殊狀況

finally 特殊狀況

A. 虛擬機結束 finally 不執行:finally 敘述不會被執行的唯一情況是,你調用 System.exit 方法,來中止虛擬機,就不會執行 finally 區塊的方法了


public static void main(String[] args) {

    try {
        System.out.println("Function working");

        System.exit(-1);

        System.out.println("Function finish");
    } finally {
        System.out.println("Finally done");
    }

}

B. 就算有 return 也會先執行 finally


void returnWithFinally() {
    try {
        System.out.println("Function start");
        return;
    } finally {
        System.out.println("Finally done");
    }

}

C. finally 發生時不可以用來賦予值


int setValueInFinally() {
    int value = -1;
    try {
        System.out.println("Function start");
        // 返回以這邊為準
        return 10;
    } finally {
        // 在這邊設定數據無效
        value = 100;
        System.out.println("Finally set value to 100, done");
    }

}

finally & return 的差異、使用時機

● 建議不要在 finally 中使用 return,這可能會導致以下兩種

A. finally 區塊的 return 為主會覆蓋一般執行、catch 區塊的 return


static int normalFunction() throws Exception {
    throw new Exception();
}

static int useReturnInFinally_1() {
    try {
        return normalFunction();
    } catch (Exception e) {
        return 10;
    } finally {
        return 100;
    }
}

B. 覆蓋 catch 原先會拋出的錯誤資訊


int useReturnInFinally_2() {
    try {
        throw new Exception();
    } catch (Exception e) {
        // 原先要表達的錯誤
        throw new IllegalAccessException();
    } finally {
        // 由於 return 導致錯誤無法正常拋出!
        return 999;
    }
}

Java 例外的差異

所有的例外類別的父類都是 Throwable,而 Throwable 又有兩個子類,分別是

A. Error:它代表了嚴重,程序本身無法修復的錯誤

像是 OOM、ClassNotFounded、堆疊溢出... 等等錯誤

B. Exception:程式預料之內的意外(也可以說是由於操作函數但卻不符合規範時的異常),通常有程序自身去做處理;又有分為

必須捕捉(Catched

非必須捕捉(Runtime

Exception 必須捕捉

● 除了 RuntimeException 之外,全部的 Exception 子類都是必須捕捉的 Exception,必須公開表明該函數會拋出的錯誤


// 必須使用 `throwsa` 標明清楚會拋出的錯誤
void testFunction() throws Exception {
    // to something
}


// 如果不捕捉的話,則無法通過編譯!
void useThrowableFunction() {
    try {
        testFunction();
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

Exception 非必須捕捉:RuntimeException

● RuntimeException & 其子類都是運行實例外,它的特點是 Java 編譯器不會檢查(不會強制我們進行 try/catch

常見的 RuntimeException 子類如下(這些類都不會強制檢查)

範例:


void state() {
    throw new IllegalStateException();
}

void npe() {
    throw new NullPointerException();
}

void formatError() {
    throw new NumberFormatException();
}

何時使用 RuntimeException

當錯誤屬於操作錯誤,並且建議使用者中止程式去做檢查時,就拋出 RuntimeException 異常

通常用於使用者不遵守 API 規範時

A. 一旦發生這種錯誤將會導致損失慘重

B. 即使錯誤被捕捉,也 可能導致程式的邏輯錯亂,甚至更嚴重的意外

Error 類與 RuntimeException 差異

Error 類別 & 其子類表示的是 應用本身 無法修復的錯誤,Java 編譯器同樣不會檢查 Error 類

一般來說這種錯誤都是 JVM 拋出的錯誤,應用層不會拋出該類錯誤

例外轉譯 & 例外鏈

例外轉譯:就是將例外捕捉,並重新包裝後拋出,這可以將錯誤定義的更加清晰,更符合該類應該拋出的錯誤!(不會讓使用者關注整條例外鏈)

graph TD; 使用者呼叫-->應用層錯誤; 應用層錯誤-->表達層錯誤; 表達層錯誤-->資料層錯誤;

使用者呼叫應用層,就應該關注應用層呼叫時的錯誤!不應該關心到 表達層錯誤、資料層錯誤

範例:


class SendMessage {

    static class MessageException extends Exception {
        public MessageException(String msg) {
            super(msg);
        }
    }

    void send(String msg) throws MessageException {
        if (msg.isEmpty()) {
            throw new MessageException("msg is empty");
        }
        // send message
    }

}

class  ChatRoom {

    private final SendMessage sender = new SendMessage();


    static class ChatRoomException extends Exception {
        public ChatRoomException(String msg) {
            super(msg);
        }
    }

    // 根據每個方法,拋出對應的錯誤
    // 使用者能更關注於單一的錯誤,而不是關注整個例外鏈
    void sendMessageTo(String to, String msg) throws ChatRoomException {
        if (to.isEmpty()) {
            throw new ChatRoomException("The msg receiver cannot be empty");
        }

        try {
            sender.send(msg);
        } catch (SendMessage.MessageException e) {
            throw new ChatRoomException("Sender handle failure: " + e.getMessage());
        }
    }

}

使用例外的原則

我們常常會考慮到如何處理使用者的錯誤,如果有參數應該返回 null 還是 拋出異常呢?接下來我們以最佳化程式的角度出發,研究 如何 正確、合理的使用例外處理

最基本的是

A. 為例外提供說明檔

B. 盡量避免例外(這需要按情況區分,有些例外就是不該捕捉的例外

不用於控制流程

● 何謂控制流程?其實最基本的控制流程就是判斷(if/else


void checkArrayElement10Is100(int[] array) {

    int value = -1;
    try {
        value = array[9];
    } catch (ArrayIndexOutOfBoundsException e) {
        // ignore
    }

    if (value == 100) {
        System.out.println("The element of 10 that value is 100");
    } else {
        System.out.println("The element of 10 that value is not 100");
    }

}

如果是使用拋出例外的方式來處理基礎的流程判斷,會有以下缺點

濫用例外會導致性能降低,因為對於 JVM 來說,例外是要另外處理的

要另外追蹤到錯誤點

違背設計概念,牛刀小用

應該先判斷在進行相對應的處理

模糊函數的意圖,影響可讀性


void checkArrayElement10Is100_fix1(int[] array) {
    if (array.length < 10) {
        return;
    }
    int value = array[9];

    if (value == 100) {
        System.out.println("The element of 10 that value is 100");
    } else {
        System.out.println("The element of 10 that value is not 100");
    }

}

不該掩蓋錯誤、單一目標

要清楚的知道該函數的意圖,才能正確的知道何時該拋出

● 有時候錯誤捕捉會導致,除錯的複雜度增加,反而創造了隱性不易被發現的錯誤


void initArray(int[] array) {
    try {
        int index = 1;    // 不小心寫錯導致錯誤無法被捕捉

        // 使用捕捉錯誤來完成迴圈中斷
        while (true) {
            array[index] = 0;

            index++;
        }

    } catch (ArrayIndexOutOfBoundsException e) {
        // ignore
    }
}

● 但這並不是說不可以這樣做,反而是應該重新檢視函式的意圖,在決定這樣做是否合適

單一目標

如果更多邏輯包裹在 try/catch 之中,那會導致錯誤更容易被掩蓋,也會無法以眼看出錯誤在哪裡


void initArray(int[] array) {
    try {
        int index = 0;
        
        // 錯誤點 1
        initArray[array.length] = array.length
            
        // 錯誤點 2
        while (true) {
            array[index] = 0;

            index++;
        }

    } catch (ArrayIndexOutOfBoundsException e) {
        // ignore
    }
}

創建函數來避免例外

● 我們可以創建一些函數來避免主要函數的處理不當所導致的例外(使用者只須閱讀文檔就可以知道呼叫了某函數就可以保證目標函數不會拋出例外)


class FunctionForException {

    // 呼叫該函數就可以避免 sendMessage 拋出異常
    boolean isMessageValid(String msg) {
        return !msg.isEmpty() && msg.length() < 100;
    }

    void sendMessage(String msg) {
        if (msg.isEmpty()) {
            throw new RuntimeException("The message was empty");
        }

        if (msg.length() > 100) {
            throw new RuntimeException("The message too long");
        }

        System.out.println("Send message: " + msg);
    }

    public static void main(String[] args) {
        FunctionForException e = new FunctionForException();

        String msg = "Hello World";
        if (e.isMessageValid(msg)) {
            e.sendMessage(msg);
        }

    }

}

保持例外的單元性

單元性

例外的單元性是指當例外發生後,各個物件的狀能夠恢復到發生前的初始狀態!這有以下幾種方案可以選擇

A. 使用 clone 設計

● 讓使用者操作 clone 物件!

● 在確保沒有例外發生之後,將 clone 的物件設定到原本的物件

B. 編寫一段初始化邏輯函數,當發生異常時,catch 捕捉並呼叫初始化邏輯函數

不是很常用

C. 最常見的是 先檢查方法的參數是否有效,當參數無效時,禁止使用者操作物件

捕捉具體例外、處理例外

● 有時候我們會偷懶,乾脆的捕捉全部的例外(Exception or Error 甚至是 Throwable),但 這不是好習慣

● 不同的例外往往需要不同的處理方案,全部捕捉意味著一樣的處理方式,不一定好

捕捉嚴重的例外!這很危險,全部捕捉會導致掩蓋重要錯誤!


void initArray(String[] array, String initValue) {
    try {
        int index = 0;

        while (true) {
            array[index] = initValue.length() + initValue;

            index++;
        }


    } catch (Exception e) {
        // ignore
    }
}

應該改成一次只捕捉一種例外,每種例外分開處理


void initArray(String[] array, String initValue) {
    try {
        int index = 0;

        while (true) {
            array[index] = initValue.length() + initValue;

            index++;
        }

    } catch (ArrayIndexOutOfBoundsException e) {
        // do something
    } catch (NullPointerException e) {
        // do something
    }
}

當然在補捉完例外後,建議還要是處理例外

不處理例外的話,至少要列印出例外,才方便在 debug 時判斷


void initArray(String[] array, String initValue) {
    try {

        // do something

    } catch (ArrayIndexOutOfBoundsException e) {
        e.printStackTrace();

    } catch (NullPointerException e) {
        e.printStackTrace();

    }
}

更多的 Java 語言相關文章

Java 語言深入

● 在這個系列中,我們全方位地探討了 Java 語言的各個核心主題,旨在幫助你徹底掌握這門強大的編程語言。無論你是想深入理解 Java 的基礎類型與變數作用域,還是探索異常處理與運算子的細節,這些文章都將為您提供寶貴的知識

深入 Java 物件導向

● 探索 Java 物件導向的奧妙,掌握介面、抽象類、繼承等重要概念!幫助你針對物件導向設計有更深入的了解!


Leave a Comment

Comments

No comments yet. Why don’t you start the discussion?

發表迴響