堆疊追蹤與 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
還有接受 PrintStream
、 PrintWriter
的版本,可以將堆疊追蹤訊息以指定方式至輸出目的地(例如檔案)。
編譯位元碼檔案時,預設會記錄原始碼行數資訊等除錯資訊,在使用 javac
編譯時指定 -g:none
引數就不會記錄除錯資訊,編譯出來的位元碼檔案容量會比較小。
如果想要取得個別的堆疊追蹤元素進行處理,則可以使用 getStackTrace
,這會傳回 StackTraceElement
陣列,陣列中索引 0 為例外根源的相關資訊,之後為各方法呼叫中的資訊,可以使用 StackTraceElement
的 getClassName
、getFileName
、getLineNumber
、getMethodName
等方法取得對應的資訊。
要善用堆疊追蹤,前題是程式碼中不可有私吞例外的行為,例如在捕捉例外後什麼都不做:
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
不應當做程式執行流程的一部份。