try/catch/throw
December 20, 2021Shit Happens!
Toy 語法
以下這個程式,預期使用者要輸入整數:
def isEven(n) {
if Number.isNaN(n) {
return 'error: 不允許 NaN'
}
return n % 2 == 0
}
n = Number.parseInt(input('輸入整數:'))
result = isEven(n)
if result == 'error: 不允許 NaN' {
println('請輸入數字')
}
else {
println('偶數' if result else '奇數')
}
在 Toy Lang 中,Number.parseInt
若無法剖析數字,會傳回 Number.NaN
,可以使用 Number.isNaN
來判定數字是否為 NaN
,為了避免使用者輸入非數字,isEven
會檢查引數,若是數字會傳回 true
或 false
,非數字會傳回字串 'error: 不允許 NaN'
。
呼叫 isEven
時,若要針對非數字進行處理,就必須如範例中檢查回傳值,某些情況下,檢查回傳值是否代表錯誤也是可行的,然而,就這個例子來說,既然函式名稱是 isEven
,基本上回傳值應該只會有 true
或 false
,傳回字串反而令人困惑。
這時候可以使用 try
、catch
、thorw
,例如:
def isEven(n) {
if Number.isNaN(n) {
throw 'error: 不允許 NaN'
}
return n % 2 == 0
}
n = Number.parseInt(input('輸入整數:'))
try {
println('偶數' if isEven(n) else '奇數')
}
catch e {
println(e)
println('請輸入數字')
}
throw
後面可以是任意的值,執行 throw
的話,在沒有處理的情況下,後續陳述都會被中斷,就算是離開函式之後,也是自動從函式呼叫處中斷,直到呼叫的最頂層為止。
想要處理 throw
拋出的值,必須使用 try
、catch
,若 throw
傳播至 try
包含的環境時,會執行 catch
,拋出的值會指定給 catch
處的變數,然後執行 catch
區塊中的陳述。
throw
後面可以是任意的值,然而,若想要自動收集堆疊追蹤,必須是 Traceable
類別或它的 Child 類別實例,例如 Exception
,被拋出的 Traceable
實例上有 printStackTrace
方法可以顯示堆疊訊息:
def isEven(n) {
if Number.isNaN(n) {
throw new Exception('不允許 NaN')
}
return n % 2 == 0
}
n = Number.parseInt(input('輸入整數:'))
try {
println('偶數' if isEven(n) else '奇數')
}
catch e {
println('請輸入數字')
e.printStackTrace()
}
這個範例在輸入非數字時,會顯示以下內容:
請輸入數字
Exception: 不允許 NaN
at throw new Exception('不允許 NaN') (/main.toy:3)
at println('偶數' if isEven(n) else '奇數') (/main.toy:11)
你也可以使用 stackTraceElements
來取得堆疊清單,若想要自訂例外,建議繼承 Exception
類別,例如:
class ValueException(Exception) {
def init() {
this.super(Exception, 'init', arguments)
}
}
def isEven(n) {
if Number.isNaN(n) {
throw new ValueException('不允許 NaN')
}
return n % 2 == 0
}
n = Number.parseInt(input('輸入整數:'))
try {
println('偶數' if isEven(n) else '奇數')
}
catch e {
println('請輸入數字')
e.printStackTrace()
}
之後還會談到 Toy Lang 中如何定義類別與實作繼承。這個範例在輸入非數字時,會顯示以下內容:
請輸入數字
ValueException: 不允許 NaN
at throw new ValueException('不允許 NaN') (/main.toy:9)
at println('偶數' if isEven(n) else '奇數') (/main.toy:17)
對於被拋出的值,若沒有處理,會一路傳播至呼叫的頂層,若還是沒有處理,預設會顯示例外堆疊訊息後中斷程式:
def isEven(n) {
if Number.isNaN(n) {
throw new Exception('不允許 NaN')
}
return n % 2 == 0
}
n = Number.parseInt(input('輸入整數:'))
println('偶數' if isEven(n) else '奇數')
這個程式在輸入非數字時,會顯示底下內容:
Exception: 不允許 NaN
at throw new Exception('不允許 NaN') (/main.toy:3)
at println('偶數' if isEven(n) else '奇數') (/main.toy:10)
可以使用 sys
模組的 unhandledExceptionHandler
,註冊處理器來處理這種傳播至頂層的例外:
import '/lib/sys'
sys.unhandledExceptionHandler(e -> println(e))
def isEven(n) {
if Number.isNaN(n) {
throw new Exception('不允許 NaN')
}
return n % 2 == 0
}
n = Number.parseInt(input('輸入整數:'))
println('偶數' if isEven(n) else '奇數')
這個程式在輸入非數字時,會顯示「Exception: 不允許 NaN」。
在 Toy Lang 中,有些內建的錯誤也會自動收集堆疊訊息,像是屬於剖析或執行環境方面的錯誤,這些錯誤都會以 Error 名稱結尾,你無法捕捉,必須從程式碼與邏輯上修正錯誤,一個例子是 ClassError
,像是呼叫 this.super
函式時,第一個引數若不是 Child 類別的 Parent 類別,就會引發 ClassError
:
class ValueException(Exception) {
def init() {
this.super(List, 'init', arguments)
}
}
def isEven(n) {
if Number.isNaN(n) {
throw new ValueException('不允許 NaN')
}
return n % 2 == 0
}
n = Number.parseInt(input('輸入整數:'))
try {
println('偶數' if isEven(n) else '奇數')
}
catch e {
println('請輸入數字')
e.printStackTrace()
}
這個例子會出現底下的訊息:
ClassError: obj.super(parent): the type of obj must be a subtype of parent
at this.super(List, 'init', arguments) (/main.toy:3)
at throw new ValueException('不允許 NaN') (/main.toy:9)
at println('偶數' if isEven(n) else '奇數') (/main.toy:17)
Toy 實作
例外處理本質上,只是用來回報錯誤的一種機制,適當的情境使用,可以解決一些問題,不適當的情境使用,自然會造就更多問題。
在沒有例外處理的情況下,怎麼回報錯誤呢?透過傳回值!無論是傳回錯誤代碼,或者是某個字串,甚至像 Go 之類的語言,規定函式若可能引發錯誤,必須傳回 (type, error)
,開發者呼叫這類函式,必須主動檢查 error
來確認函式執行是否發生錯誤。
問題就在於,若你開發的函式,某天打算被整合入一個應用程式的底層,在這種情況下,若錯誤必須傳播至應用層式頂層(也許是使用者 UI 層),有可能要求一路修改程式碼,層層檢查是否有錯誤嗎?不太可能!
這時你就會希望有個機制,就像 return
一樣會中斷函式執行,而呼叫函式的一方,也會自動 return
錯誤,這就是例外處理的目的之一,本質上,throw
這東西,就像是自動一路 return
的機制。
如果想要停止例外傳播呢?若是 return
,就是在 if/else
檢查到錯誤且處理過後,決定中斷傳播了,因此,try/catch
本質上,也是一種 if/else
的變化。
知道這點之後,就自然可以辨別,何時該使用 return
以及 if/else
檢查傳回值來確認與處理錯誤,何時又該使用 throw
、try
、catch
。
簡單來說,在不需要自動傳播錯誤的場合,使用了例外機制,在需要自行檢查錯誤的場合,卻使用了例外,在能夠或在不能夠處理錯誤的地方,將例外吞掉,或者不使用 if/else
檢查,就只會找 Bug 找到死!
將這些實際的需求變成語法,就會是各種語言中看到的 Error、Exception、Checked Exception、RuntimeException、Panic 等,說實在的,這些都只是名詞而已,沒搞清楚的話,再好的機制也是被濫用。
回過頭來,既然知道 throw
就是一種 return
,try/catch
就是一種 if/else
的話,就應該能想像出例外處理是怎麼實作出來的。例如 statement.js 中的 Throw
節點:
class Throw extends Stmt {
constructor(value) {
super(1);
this.value = value;
}
evaluate(context) {
const maybeCtx = this.value.evaluate(context);
return maybeCtx.notThrown(v => context.thrown(new Thrown(v)));
}
}
如同 return
,被 throw
的值會記錄在環境物件之中,只要環境物件中註記著這個值,之後的陳述就會被中斷。
接著就是要考慮自動傳播了,在這之前回顧一下,break
在環境物件中註記的值,會在離開 while
迴圈時被註銷;return
呢?因為每個函式的環境物件,都是從 Parent 環境物件衍生出來的 Child 新環境物件,函式呼叫結束後,函式的環境物件就會被丟棄,不用特別做任何註銷的動作。
自動傳播顯然地,就是在檢查出 Child 環境物件有被註記 throw
的值時,直接將 Child 環境物件傳回,如此註記就會一直存在,也就會自動中斷函式呼叫處後續的陳述,一路往上傳播。
如果函式呼叫是在 try/catch
環境中執行,那就是在檢查出環境物件有被註記 throw
的值時,停止傳播環境物件,並執行 catch
區塊陳述:
class Try extends Stmt {
constructor(tryStmt, exceptionVar, catchStmt) {
super(tryStmt.lineCount + catchStmt.lineCount + 4);
this.tryStmt = tryStmt;
this.exceptionVar = exceptionVar;
this.catchStmt = catchStmt;
}
evaluate(context) {
const tryContext = this.tryStmt.evaluate(context);
if(tryContext.thrownNode) {
tryContext.thrownNode.pushStackTraceElementsIfTracable(context);
return runCatch(context, this, tryContext.thrownNode.value).deleteVariable(this.exceptionVar.name);
}
return context; // 將引數 context 傳回,而不是 tryContext
}
}
function runCatch(context, tryNode, thrownValue) {
return tryNode.catchStmt.evaluate(
context.assign(tryNode.exceptionVar.name, thrownValue)
);
}
比想像中簡單,不是嗎?只要會 break
、return
的實作,例外處理基本上就不難,因為本質上,也是一種錯誤檢查與處理,這呼應了前頭談到的:「例外處理本質上,只是用來回報錯誤的一種機制,適當的情境使用,可以解決一些問題,不適當的情境使用,自然會造就更多問題。」
至於堆疊的記錄,如果目前的環境物件,不同於傳回的環境物件,表示陳述句不在同一個函式,若傳回的環境物件被註記了 throw
的值,表示發生例外,這時就可以收集一些堆疊資訊了,像是程式碼行數之類的。
class StmtSequence extends Stmt {
constructor(firstStmt, secondStmt, lineNumber) {
super(firstStmt.lineCount + secondStmt.lineCount);
this.firstStmt = firstStmt;
this.secondStmt = secondStmt;
this.lineNumber = lineNumber;
}
evaluate(context) {
try {
const fstStmtContext = this.firstStmt.evaluate(context);
return addTraceOrStmt(context, fstStmtContext, this.lineNumber, this.secondStmt);
} catch(e) {
if(this.lineNumber) {
addStackTrace(context, e, {
fileName : context.fileName,
lineNumber : this.lineNumber,
statement : context.lines.get(this.lineNumber)
});
}
throw e;
}
}
}
function addTraceOrStmt(context, preStmtContext, lineNumber, stmt) {
return preStmtContext.either(
leftContext => {
if(leftContext.thrownNode.stackTraceElements.length === 0 ||
context !== leftContext.thrownContext) {
leftContext.thrownNode.addStackTraceElement({
fileName : context.fileName,
lineNumber : lineNumber,
statement : context.lines.get(lineNumber)
});
}
return leftContext;
},
rightContext => rightContext.notReturn(
ctx => ctx.notBroken(c => stmt.evaluate(c))
)
);
}
實際上,每執行一次陳述,就得檢查一次註記,是很麻煩的一件事,因此在這邊,採用 either
函式,它的原理與 notBroken
、notReturn
類似,採用替換回呼函式的方式,省去了每次的檢查動作,有興趣就自己看看實現方式吧!