1 值、函數、與定義

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 功能相同。