堆疊追蹤與 assert

June 19, 2022

在多重方法呼叫下,例外發生點可能是在某個方法之中,若想得知例外發生的根源,以及多重方法呼叫下例外的堆疊傳播,可以利用例外物件自動收集的堆疊追蹤(Stack Trace)來取得相關資訊。

認識堆疊追蹤

查看堆疊追蹤最簡單的方法,就是直接呼叫例外物件的 printStackTrace。例如:

package cc.openhome;

public class Main {   
    public static void main(String[] args) {
        try {
            c();
        } catch(NullPointerException ex) {
            ex.printStackTrace();
        }
    }

    static void c() {
        b();
    }

    static void b() {
        a();
    }

    static String a() {
        String text = null;
        return text.toUpperCase();
    }
}

這個範例程式中,c 方法呼叫 b 方法,b 方法呼叫 a 方法,而 a 方法中會因 text 參考至 null,而後試圖呼叫 toUpperCase 而引發 NullPointerException

假設事先並不知道這個呼叫的順序(也許你是在使用一個程式庫),當例外發生而被捕捉後,可以呼叫 printStackTrace 在主控台顯示堆疊追蹤:

java.lang.NullPointerException
        at cc.openhome.Main.a(Main.java:22)
        at cc.openhome.Main.b(Main.java:17)
        at cc.openhome.Main.c(Main.java:13)
        at cc.openhome.Main.main(Main.java:6)

堆疊追蹤訊息中顯示了例外類型,最頂層是例外的根源,以下是呼叫方法的順序,程式碼行數是對應於當初的程式原始碼,如果使用IDE,按下行數就會直接開啟原始碼並跳至對應行數(如果原始碼存在的話)。printStackTrace 還有接受 PrintStreamPrintWriter 的版本,可以將堆疊追蹤訊息以指定方式至輸出目的地(例如檔案)。

編譯位元碼檔案時,預設會記錄原始碼行數資訊等除錯資訊,在使用 javac 編譯時指定 -g:none 引數就不會記錄除錯資訊,編譯出來的位元碼檔案容量會比較小。

如果想要取得個別的堆疊追蹤元素進行處理,則可以使用 getStackTrace,這會傳回 StackTraceElement 陣列,陣列中索引 0 為例外根源的相關資訊,之後為各方法呼叫中的資訊,可以使用 StackTraceElementgetClassNamegetFileNamegetLineNumbergetMethodName 等方法取得對應的資訊。

要善用堆疊追蹤,前題是程式碼中不可有私吞例外的行為,例如在捕捉例外後什麼都不做:

try {
    ...
} catch(SomeException ex) {
    // 什麼也沒有,絕對不要這麼做!
}

這樣的程式碼會對應用程式維護造成嚴重傷害,因為例外訊息會完全中止,之後呼叫此片段程式碼的客戶端,完全不知道發生了什麼事,造成除錯異常困難,甚至找不出錯誤根源。

另一種對應用程式維護會有傷害的方式,就是對例外做了不適當的處理,或顯示了不正確的資訊。例如,有時由於某個例外階層下引發的例外類型很多:

try {
    ...
} catch(FileNotFoundException ex) {
    作一些處理
} catch(EOFException ex) {
    作一些處理
}

有些程式設計人員為了省麻煩,或因為經常處理找不到檔案的錯誤,因而寫成這樣:

try {
    ...
} catch(IOException ex) {
    System.out.println("找不到檔案");
}

這類的程式碼在專案中還蠻常見的,假以時日或者是別人使用程式時,真的發生了 EOFException(或其它原因導致了 IOException 或其子類型例外),但錯誤訊息卻會一直顯示找不到檔案,因而誤導了除錯的方向。

在使用 throw 重拋例外時,例外的追蹤堆疊起點,仍是例外的發生根源,而不是重拋例外的地方。例如:

package cc.openhome;

public class Main2 { 
    public static void main(String[] args) {
        try {
            c();
        } catch(NullPointerException ex) {
            ex.printStackTrace();
        }
    }

    static void c() {
        try {
            b();
        } catch(NullPointerException ex) {
            ex.printStackTrace();
            throw ex;
        }
       
    }  

    static void b() {
        a();
    }   

    static String a() {
        String text = null;
        return text.toUpperCase();
    }   
}

執行這個程式,會發生以下的例外堆疊訊息,可看到兩次都是顯示相同的堆疊訊息:

java.lang.NullPointerException
        at cc.openhome.Main2.a(Main2.java:28)
        at cc.openhome.Main2.b(Main2.java:23)
        at cc.openhome.Main2.c(Main2.java:14)
        at cc.openhome.Main2.main(Main2.java:6)
java.lang.NullPointerException
        at cc.openhome.Main2.a(Main2.java:28)
        at cc.openhome.Main2.b(Main2.java:23)
        at cc.openhome.Main2.c(Main2.java:14)
        at cc.openhome.Main2.main(Main2.java:6)

如果想要讓例外堆疊起點為重拋例外的地方,可以使用 fillInStackTrace,這個方法會重新裝填例外堆疊,將起點設為重拋例外的地方,並傳回 Throwable 物件。例如:

package cc.openhome;

public class Main3 {
    public static void main(String[] args) {
        try {
            c();
        } catch(NullPointerException ex) {
            ex.printStackTrace();
        }
    }  

    static void c() {
        try {
            b();
        } catch(NullPointerException ex) {
            ex.printStackTrace();
            Throwable t = ex.fillInStackTrace();
            throw (NullPointerException) t;
        }
    }

    static void b() {
        a();
    }

    static String a() {
        String text = null;
        return text.toUpperCase();
    }  
}

執行這個程式,會發生以下的訊息,可看到第二次顯示堆疊追蹤的起點,就是重拋例外的起點:

java.lang.NullPointerException
        at cc.openhome.Main3.a(Main3.java:28)
        at cc.openhome.Main3.b(Main3.java:23)
        at cc.openhome.Main3.c(Main3.java:14)
        at cc.openhome.Main3.main(Main3.java:6)
java.lang.NullPointerException
        at cc.openhome.Main3.c(Main3.java:17)
        at cc.openhome.Main3.main(Main3.java:6)

關於 assert

有時候,需求或設計時就可確認,程式執行的某個時間點或某個情況下,一定是處於或不處於何種狀態,若不是,則是個嚴重錯誤,開發過程中發現這種嚴重錯誤,必須立即停止程式確認需求與設計。

程式執行的某個時間點或某個情況下,必然處於或不處於何種狀態,這是一種斷言(Assertion),例如某個時間點程式某個變數值一定是多少。斷言的結果一定是成立或不成立,預期結果與實際程式狀態相同時,斷言成立,否則斷言不成立。

Java 提供 assert 語法,有兩種使用的語法:

assert boolean_expression;
assert boolean_expression : detail_expression;

boolean_expression 若為 true,什麼事都不會發生,如果為 false,會發生 java.lang.AssertionError,此時若採取的是第二個語法,則會將 detail_expression 的結果顯示出來,如果當中是個物件,則呼叫 toString 顯示文字描述結果。

一個使用斷言的時機是內部不變量(Internal invarant)判斷,也就是某時間點上斷言某變數必然是或不是某值,像是代表儲值卡的 CashCard 物件在扣款成功後,餘額一定不能是負數:

...
    public void charge(int money) throws BalanceNotEnoughException {
        checkGreaterThanZero(money);
        checkBalance(money);

        this.balance -= money;
       
        assert this.balance > -1; // 一定不能是負數
    }

    private void checkGreaterThanZero(int money) {
        if(money < 0) {
            throw new IllegalArgumentException("扣負數?這不是叫我儲值嗎?");
        }
    }

    private void checkBalance(int money) throws BalanceNotEnoughException {
        if(money > this.balance) {
            throw new BalanceNotEnoughException("錢不夠啦!");
        }
    }
...

若粗體字的斷言不成立,表示 charge 流程一定有嚴重錯誤,開發過程中必須停下來檢查問題出在哪。

預設上執行時不啟動斷言檢查。如果要在執行時啟動斷言檢查,可以在執行 java 指令時,指定 -enableassertions 或是 -ea 引數。

另一個使用斷言的時機為控制流程不變量(Control flow invariant)判斷,例如:

...
    public static void play(int action) {
        switch(action) {
            case Action.STOP:
                out.println("播放停止動畫");
                break;
            case Action.RIGHT:
                out.println("播放向右動畫");
                break;
            case Action.LEFT:
                out.println("播放向左動畫");
                break;
            case Action.UP:
                out.println("播放向上動畫");
                break;
            case Action.DOWN:
                out.println("播放向下動畫");
                break;
            default:
                assert false : "非定義的常數";
        }
    }
...

開發人員使用 play 時,一定要使用 Action 定義的列舉常數,如果不是,就有可能執行到 default,若此情況發生視為開發時期的嚴重錯誤,所以直接 assert false,必然斷言失敗。

斷言是判定程式中的某個執行點必然是或不是某個狀態,不要當作像 if 之類的判斷式來使用,assert 不應當做程式執行流程的一部份。

分享到 LinkedIn 分享到 Facebook 分享到 Twitter