1.2 函數定義
考慮如下的函數定義:
smaller :: Int -> Int -> Int
smaller x y = if x <= y then x else y
我們可粗略地理解為:smaller 是一個函數,拿兩個參數 x 與 y,傳回其中較小的那個。
如 smaller (double 6) (3+4) 的值為 7.
練習 1.3
用前一節介紹的求值順序,將smaller (double 6) (3+4)化簡為範式。
守衛 如果函數本體做的第一件事就是條件判斷,Haskell 提供另一種語法:
smaller :: Int -> Int -> Int
smaller x y | x <= y = x
| x > y = y
這麼寫出的 smaller 的行為仍相同:如果 x <= y 成立,傳回 x;如果 x > y, 傳回 y。
但這種語法較接近一些數學教科書中定義函數的寫法。
其中,放在 x <=y 和 x > y 等位置的必須是型別為 Bool 的算式。
它們擋在那兒,只在值為 True 的時候才讓執行「通過」,因此被稱為守衛(guard)。
如果有三個以上的選項,如下述例子,使用守衛比 if .. then.. else 更清晰可讀:
sign :: Int -> Int
sign x | x > 0 = 1
| x == 0 = 0
| x < 0 = -1
遇到數個守衛時,Haskell 將依照順序嘗試每個選項,直到碰到第一個為真的守衛,然後只執行該項定義。
也就是說, 即使我們把 sign 定義中的 x == 0 改為 x >= 0, sign 10 的值仍會是 1。
若每個守衛都是 False, 程式則將中斷執行(並傳回一個錯誤訊息)。
在許多程式中,我們會希望最後一個守衛能捕捉所有的漏網之魚:如果其他條款都不適用,就執行最後一個。一種做法是讓一個守衛是 True。或著,在 Haskell 中有個 otherwise 關鍵字可讓定義讀來更口語化些:
sign :: Int -> Int
sign x | x > 0 = 1
| x == 0 = 0
| otherwise = -1
事實上,otherwise 的定義就是 otherwise = True.
區域識別字 Haskell 提供兩種宣告區域識別字的語法:let 與 where.
也許最容易理解的方式是用例子說明。
例 1.1
工讀生每小時的時薪為新台幣 130 元。
假設一週有五個工作天,每天有八小時上班時間。
定義一個函數 payment :: Int -> Int,輸入某學生工作的週數,計算其薪資。
答案
我們當然可直接用一個式子算出薪資。但為清楚起見,我們可用兩個區域識別字 days 和 hours,
分別計算該學生工作的日數和時數。如果用 let, 可這麼做。
payment :: Int -> Int
payment weeks = let days = 5 * weeks
hours = 8 * days
in 130 * hours
let 算式的語法為
let x1 = e1
x2 = e2 ....
in e
其中 e 為整個算式的值,而 x1, x2 等等為區域識別字。兩個區域識別字的有效範圍包括 e, 以及 e1 與 e2。
另一種語法是 where 子句。若用它定義 payment, 可寫成這樣:
payment :: Int -> Int
payment weeks = 130 * hours
where hours = 8 * days
days = 5 * weeks
該用 let 或是 where?
大部份時候這可依個人習慣,看寫程式的人覺得怎麼說一件事情比較順。
使用 let 時,敘事的順序是由小到大,先給「工作日數」、「工作時數」等小元件的定義,再用他們組出最後的式子 130 * hours。
使用 where 時則是由大到小,先說我們要算「工作時數乘以 130」,然後補充「其中,工作時數的定義是...」。
但,Haskell 之所以保留了兩種語法,是為了因應不同的用途。語法上,let 是一個算式,可出現在算式中。如下的算式是合法的,其值為 24:
(1 + 1) * (let y = double 5 in y + 2)
where 子句的一般語法則如下例所示:
f x0 = d0
where y0 = e0
f x1 = d1
where y1 = e1
由語法設計上即可看出,子句 where y0 = e0 只能放在 f x0 = ... 的一旁當作補述,不能出現在 d0 之中。
這個例子中, y0 的有效範圍含括 d0 與 e0。另,e0 可以使用 x0.
算式中只能用 let. 相對地,也有些只能使用 where 的場合。我們來看
我們來看一個只能使用 where 的例子:
例 1.2
延續例 1.1 。
今年起,新勞動法規規定工作超過 19 週的工讀生必須視為正式雇員,
學校除了薪資外,也必須付給勞保、健保費用。
學校需負擔的勞健保金額為雇員薪資的百分之六。請更新函數 payment,
輸入某工讀生工作週數,計算在新規定之下,學校需為工讀生付出的總額。
答案
一種可能寫法是先使用守衛,判斷工作週數是否大於 19:
payment :: Int -> Int
payment weeks | weeks > 19 = round (fromIntegral baseSalary * 1.06)
| otherwise = baseSalary
where baseSalary = 130 * hours
hours = 8 * days
days = 5 * weeks
在 where 子句中,我們先算出不含勞健保費用的薪資,用識別字 baseSalary 表達。
如果 weeks 大於 19, 我們得將 baseSalary 乘以 1.06;否則即傳回 baseSalary.
函數 fromIntegral 把整數轉為浮點數,round 則把浮點數四捨五入為整數。
請注意:兩個被守衛的算式都用到了 baseSalary ---
where 子句中定義的識別字是可以跨越守衛的。
相較之下,let 算式只能出現在等號的右邊,而在守衛 weeks > 19 = ... 之後出現的
let 所定義出的區域識別字,顯然無法在 otherwise = ... 之中被使用,反之亦然。
巢狀定義 let 算式之中還可有 let 算式,
where 子句中定義的識別字也可有自己的 where 子句。
我們看看兩個關於 let 的例子:
例 1.3
猜猜看 nested 和 recursive 的值分別是什麼。
將他們載入 Haskell 直譯器,看看和你的猜測是否相同。
nested :: Int
nested = let x = 3
in ( let x = 5
in x + x) + x
recursive :: Int
recursive = let x = 3
in let x = x + 1
in x
答案
nested 的值是 13,因為 x+x 之中的 x 在 let x = 5 的範圍中,
而 .. + x 中的 x 則在 let x = 3 的範圍中。在各種語言中,範圍的設計都是為了給程式員方便:在寫子算式時,可不用擔心是否與外層的識別字撞名。在教學時,我們難免舉各種撞名的例子作為說明。若把這些刁鑽例子當作考題,就是違反設計者本意的發展了。
至於 recursive 的值,關鍵在於 x = x + 1 中右手邊的 x 指的是哪個。
若是 x = 3 的那個 x, 整個算式的值將是 4. 若 x = x + 1 中,
等號右手邊的 x 也是左手邊的 x, recursive 就是 ((..)+1)+1 , 沒有範式。
這兩種設計都有其道理。
Haskell 選了後者:在 let x = e in ... 之中,x 的有效範圍包括 e.
因此 recursive 在 Haskell 中會無窮地求值下去。
但也有些函數語言中的 let 採用前者的設計。通常這類語言中會另有一個 $\Keyword{letrec}$
結構,和 Haskell 的 let 功能相同。