try/catch/throw

December 20, 2021

Shit 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 會檢查引數,若是數字會傳回 truefalse,非數字會傳回字串 'error: 不允許 NaN'

呼叫 isEven 時,若要針對非數字進行處理,就必須如範例中檢查回傳值,某些情況下,檢查回傳值是否代表錯誤也是可行的,然而,就這個例子來說,既然函式名稱是 isEven,基本上回傳值應該只會有 truefalse,傳回字串反而令人困惑。

這時候可以使用 trycatchthorw,例如:

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 拋出的值,必須使用 trycatch,若 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 檢查傳回值來確認與處理錯誤,何時又該使用 throwtrycatch

簡單來說,在不需要自動傳播錯誤的場合,使用了例外機制,在需要自行檢查錯誤的場合,卻使用了例外,在能夠或在不能夠處理錯誤的地方,將例外吞掉,或者不使用 if/else 檢查,就只會找 Bug 找到死!

將這些實際的需求變成語法,就會是各種語言中看到的 Error、Exception、Checked Exception、RuntimeException、Panic 等,說實在的,這些都只是名詞而已,沒搞清楚的話,再好的機制也是被濫用。

回過頭來,既然知道 throw 就是一種 returntry/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)
	);
} 

比想像中簡單,不是嗎?只要會 breakreturn 的實作,例外處理基本上就不難,因為本質上,也是一種錯誤檢查與處理,這呼應了前頭談到的:「例外處理本質上,只是用來回報錯誤的一種機制,適當的情境使用,可以解決一些問題,不適當的情境使用,自然會造就更多問題。」

至於堆疊的記錄,如果目前的環境物件,不同於傳回的環境物件,表示陳述句不在同一個函式,若傳回的環境物件被註記了 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 函式,它的原理與 notBrokennotReturn 類似,採用替換回呼函式的方式,省去了每次的檢查動作,有興趣就自己看看實現方式吧!