8.1 例外處理
假設我們添了一個整數除法運算元:
data Expr = Num Int | Neg Expr | Add Expr Expr | Div Expr Expr
我們便碰到了一個小難題:要怎麼處理分母是零的狀況呢?
Haskell 的 div 函數可做整數除法。碰到分母為零時,div 和大部分語言的除法函數一樣讓整個程式當掉。我們不希望程式當掉,因此得在呼叫 div 前先檢查一下分母是否為零。但當分母真為零,eval 應該傳回什麼呢?
eval (Div e0 e1) = let v1 = eval e1
in if v1 /= 0 then eval e0 `div` v1 else ???
用 |Maybe| 表達部份函數
一種看法是,有了除法後,eval 對於某些輸入沒有對應的值,因此 eval 成了一個部份 (partial) 函數。
操作上,我們希望 Haskell 提供拋出例外的功能,eval 無值可傳的時候便拋出例外。在程式的另外某處可能接住這些例外,另行處理。
本章開頭提及,一個傳回 Int、但可能拋出例外的函數可用傳回 Except Int 的全函數來模擬。Except 再給一次如下:
data Except b = Expt String | Return b
我們把 eval 的型別改為 Expr -> Except Int, 正常運算結束便傳回 Return, 碰到分母為零的情形則傳回 Expt.
這個版本的 eval 該怎麼寫呢?碰到 Num n 自然是傳回 Return n:
eval (Num n) = Return n
處理 Neg e 的條款則比以前稍微複雜了。我們得檢查 eval e 的結果,若是 Return v,應傳回 -v,但也需包上一個 Return. 如果遇到 Expt msg, 表示 e 的運算失敗了,我們只好把 Expt msg 再往上傳。
eval (Neg e) = case eval e of
Return v -> Return (-v)
Expt msg -> Expt msg
Add 的情況下,程式碼看來更冗長了些:
eval (Add e0 e1) = case eval e0 of
Return v0 -> case eval e1 of
Return v1 -> Return (v0 + v1)
Expt msg -> Expt msg
Expt msg -> Expt msg
我們得分別檢查 eval e0 與 eval e1 的結果是 Return 或是 Expt,因此用兩層 case 分出了四個狀況 --- 雖然它們是重複性很高的程式碼。
最後是 Div e0 e1 的情況,我們先計算 e1, 如果是零的話傳回 Expt, 含錯誤訊息 "division by zero", 否則做除法運算。
在這過程中我們也需要許多 case :
eval (Div e0 e1) = case eval e1 of
Return 0 -> Expt "division by zero"
Return v1 -> case eval e0 of
Return v0 -> Return (v0 `div` v1)
Expt msg -> Expt msg
Expt msg -> Expt msg
很自然地,我們會希望把這些例行公事抽象掉。
|Except| 作為單子
再看一次處理 Neg 的條款:
eval (Neg e) = case eval e of
Return v -> Return (-v)
Expt msg -> Expt msg
我們真正想表達的僅是「把 eval e 的結果送給 \v -> Return (-v) --- 如果有結果的話」。這只是多加了一點手續的函數應用 (application)。回想 Haskell 有個函數應用運算元($) :: (a -> b) -> a -> b.
給定 f :: a -> b 和 x :: a, 那麼 f $ x 就是把 x 餵給 f(也就是 f x)。
也許我們可以自己定義一個函數應用運算元 (=<<). 概念上,f =<< x 仍是把 x 餵給 f,但 x 的型別是 Except a, f 的型別是 a -> Except b, 而「檢查 x 是 Return 或 Expt」的動作就可藏在 (=<<) 之中(讀者可把 (=<<) 的型別和 ($) 做比較):
(=<<) :: (a -> Except b) -> Except a -> Except b
f =<< Return x = f x
f =<< Expt msg = Expt msg
如此一來,eval (Neg e) 的條款可簡潔地寫成:
eval (Neg e) = (\v -> Return (-v)) =<< eval e
如果定義 negate x = -x, 上式的右手邊可以更簡潔地寫成 Return . negate =<< eval e.
但,目前較常見的習慣是把這個運算子的左右顛倒:參數寫前面,函數寫後面。也就是定義:
(>>=) : Except a -> (a -> Except b) -> Except b
Just x >>= f = f x
Nothing >>= f = Nothing
運算子 (>>=) 唸作 ``bind'', 是我們將會細談的重要元素。
有了它,函數 eval 的前兩個條款可以改寫如下:
eval (Num n) = return n
eval (Neg e) = eval e >>= \v -> return (-v)
我們把 Return 寫成函數:return = Return。等下會解釋為什麼。
注意:依照 Haskell 的運算元優先順序,
m >>= \v -> e 會被理解成 m >>= (\v -> e).
Add 和 Div 的情況也可以用 (>>=) 改寫:
eval (Add e0 e1) = eval e0 >>= \v0 ->
eval e1 >>= \v1 ->
return (v0 + v1)
eval (Div e0 e1) = eval e1 >>= \v1 ->
if v1 == 0 then throw "division by zero"
else eval e0 >>= \v0 ->
return (v0 `div` v1)
此處我們把 Expt 寫成 throw.
如此一來,我們的程式變得簡短了一些。例如,eval (Add e0 e1) 直覺上彷彿在說「把 eval e0 的結果叫做 v0, eval e1 的結果叫做 v1, 然後傳回 v0 + v1」,至於判斷 eval e0 與 eval v1 到底是 Return 還是 Expt 的動作則藏在 (>>=) 之中了。
但引入 (>>=) 這個抽象的用意並不只是把程式變短,而是可推廣到其他的副作用之上。這將是接下來幾小節的主題。
提醒一下:
有些讀者可能認為 eval 的後兩個條款看來像命令式語言:eval e1 >>= \v1 -> .. 看來就像是 v1 := eval e1 --- 把 eval e1 的值寫到 v1 中。
但其實 (>>=) 並沒有賦值(改變變數現有的值)之意,要做比較的話,它更像是增強版的 let.
算式 p >>= \x -> e 就像是 let x = p in e.
兩者都是「把 p 的值算出,給予一個名字 x, 然後計算 e」。
差別則是:
let處理純的值:在let x = p in e之中,如果p的型別是a,x的型別也是a; 算式e的型別若是b, 整個式子的型別也是b;e的型別相同。(>>=)處理含有Except的值:在p >>= \x -> e之中,p的型別得有Except. 如果p的型別是Except a, 則x的型別是a;e的型別得有Except. 如果e的型別是Except b, 整個式子的型別也是Except b.因此
\x -> e是一個型別為a -> Except b的函數。
捕捉例外
最後,我們可以定義一個捕捉例外的運算子。算式 catch p hdl 嘗試配對 p 的值,
如果是 Return x, 就傳回結果 x;如果是 Expt msg, 就把錯誤訊息 msg 交給例外處理函數 hdl。
當 p 的型別是 Except a, catch p hdl 的型別也是 a, 而 hdl 的型別必須是 String -> a:
catch :: Except a -> (String -> a) -> a
catch (Return x) hdl = x
catch (Expt msg) hdl = hdl msg
例如,下式嘗試計算 e 並把結果轉成字串。如果 e 能正常計算,字串會是 "Result:" 開頭,否則是 "Error:" 開頭:
catch (eval e) (\v -> "Result: " ++ show v)
(\msg -> "Error: " ++ msg)
下式則無論如何都傳回 Int, 如果 e 之中出現了分母為零的除法,我們便傳回 65536:
catch (eval e) id (const 65536)