1.6 簡單資料型態
藉由一些例子,我們已經看過 Haskell 的一些數值型別:Int, Float 等等。
在本節中我們將簡短介紹我們將用到的一些其他型別。
1.6.1 布林值
布林值 (Boolean)常用於程式中表達真和假。 在 Haskell 中,我們可假想有這樣的一個型別定義:
data Bool = False | True
其中,data 是 Haskell 宣告新資料型別的保留字。
上述定義可用口語描述成「定義一個稱作 Bool 的新資料型別,
有兩個可能的值,分別為 False 和 True.」
False 和 True 是型別 Bool 的唯二兩個建構元 ---
任何型別為 Bool 的值,如果有正規式,必定是它們兩者之一。
在 Haskell 之中,建構元必須以大寫英文字母或冒號(:)開頭。
樣式配對 有了資料,我們來看看怎麼定義該型別上的函數。以布林值為輸入的函數中,
最簡單又常用的可能是 not:
not :: Bool -> Bool
not False = True
not True = False
這和我們的的直覺理解一致:not False 是 True, not True 是
False. 我們看到這個定義寫成兩行(正式說來是兩個「子句」),\emph{每一個子句分別對應到 Bool 的一個可能的值}。
以下則是邏輯上的「且」和「或」(分別寫作(⋀)與(⋁))的定義:
邏輯「且」又稱作合取(conjunction);邏輯「或」又稱作析取(disjunction).
在 Haskell 中,「且」與「或」需分別寫成 $(\mathtt{\&\&})$ 和 $(\mathtt{||||})$。本書中採用數學與邏輯領域較常使用的 (⋀)與(⋁).
(⋀), (||) :: Bool -> Bool -> Bool
False ⋀ y = False
True ⋀ y = y
False || y = y
True || y = True
運算子(⋀)與(⋁)的定義同樣是各兩個子句,每個子句分別考慮其第一個參數的值。
以 x ⋀ y 為例:如果 x 是 False, 不論 y 的值為何,x ⋀ y 都是 False;
如果 x 是 True, x & y 的值和 y 相同。(⋁) 的情況類似。
例 1.11
以下函數判斷給定年份 y 是否為閏年。
leapyear :: Int -> Bool
leapyear y = (y `mod` 4 == 0) ⋀
(y `mod` 100 /= 0 || y `mod` 400 == 0)
我們來算算看 leapyear 2016。依照定義展開為
(2016 `mod` 4 == 0) ⋀ (2016 `mod` 100 /= 0 || 2016 `mod` 400 == 0)
接下來該怎麼做呢?函數 (⋀) 的定義有兩個子句,我們得知道 2016 `mod` 4 == 0 的值才能得知該歸約成哪個。因此只好先算 2016 `mod` 4 == 0,得到 True:
True ⋀ (2016 `mod` 100 /= 0 || 2016 `mod` 400 == 0)
然後依照(⋀) 的定義歸約為 2016 `mod` 100 /= 0 ⋁ 2016 `mod` 400 == 0.
接下來也依此類推。
我們發現這是第1.1 節中所提及的被迫求值的例子:我們得先把參數算出,才知道接下來如何走。
函數 not, (⋀), (⋁) 定義成許多個子句,每個都分析其參數的可能外觀,據此決定該怎麼走。這種定義方式稱作樣式配對 (pattern matching):等號左手邊的 False, True 等等在此是樣式(pattern)。使用這些函數時,例如 x ⋀ y 中, x 得先被算到可以和這些樣式配對上的程度,才能決定接下來的計算如何進行。
樣式配對也可用在不止一個參數上。例如,以下的運算元 (==) 判斷兩個布林值是否相等。
(==) :: Bool -> Bool -> Bool
False == False = True
False == True = False
True == False = False
True == True = True
讀者可能注意到我們用了同一個符號 (==) 來表示整數與布林值的相等測試。
請讀者暫且接受,相信 Haskell 有某些方式可得知任一個算式中的 (==) 到底是什麼型別的相等。詳情 \todo{where?}
Haskell 中另有一個專用來做樣式配對的 case 算式。例如,(⋀) 也可寫成如下的形式:
(⋀) :: Bool -> Bool
x ⋀ y = case x of
False -> False
True -> y
由於 case 是算式,如同 let 一樣可出現在其他算式中,也可巢狀出現。
練習 1.7
以 case 算式定義 not, (⋁), 和 (==).
練習 1.8
另一個定義 (==) :: Bool -> Bool -> Bool 的方式是
x == y = (x ⋀ y) || (not x ⋀ not y)
請將 (x,y) := (False, False), (x,y) := (False, True)
等四種可能分別代入化簡,看看是否和本節之前的 (==) 定義相同。
1.6.2 字元
我們可把「字元」這個型別想成一個很長的 data 宣告:
data Char = 'a' | 'b' | ... | 'z' | 'A' | 'B' ....
其中包括所有字母、符號、空白... 目前的 Haskell 甚至有處理 Unicode 字元的能力。
但無論如何,Char 之中的字元數目是有限的。我們可用樣式配對定義字元上的函數。
注意:字元以單引號括起來。
我們也可假設字元是有順序的,每個字元對應到一個內碼。
關於 Char 的常用函數中,ord 將字元的內碼找出,chr 則將內碼轉為字元:
ord :: Char -> Int
chr :: Int -> Char
例 1.12
下列函數 isUpper 判斷一個字元是否為大寫英文字母;toLower 則將
大寫字母轉成小寫字母,若輸入並非大寫字母則不予以變動。
isUpper :: Char -> Bool
isUpper c = let x = ord c in ord 'A' <= x ⋀ x <= ord 'Z'
toLower :: Char -> Char
toLower c | isUpper c = chr (ord c - ord 'A' + ord 'a')
| otherwise = c
1.6.3 序對
數學上,將兩個值(如 3 和 'a') 放在一起,就成了一個有序對(ordered pair),可寫成(3,'a')。
之所以稱作「有序」對,因為其中兩個元素的順序是不可忽略的 --- (3,'a') 與 ('a',3) 是不同的有序對。
另一個常見譯名是「數對」。由於我們處理的不只是數字,本書將之簡稱為「序對」。
給兩個集合 A 和 B, 從 A 之中任取一元素 x,從 B 之中也任取一元素 y,
兩者的序對 (x,y) 形成的集合稱作 A 和 B 的笛卡兒積(Cartesian product),寫成 A × B:
A × B = {(x,y) | x ∈ A, y ∈ B}
Haskell 之中也有類似的構造。給定型別 a 與 b, 它們的序對的型別是 (a × b).
然而,由於「程式可能不終止」這個因素作怪,a × b 的元素並不僅是 a 與 b (如果視做集合)的笛卡兒積。詳見 \todo{where?}
我們可以想像 Haskell 有這麼一個型別定義:
data (a × b) = (a,b)
以口語說的話,(a × b) 是一個新型別,而具有此型別的值若有範式,必定是 (x,y) 的形式,其中 x 的型別是 a, y 的型別是 b.
其實這個定義並不符合 Haskell 的語法,因此只是方便理解的想像。另,型別 (a × b) 在 Haskell 中寫成 (a,b). 我的經驗中,讓型別與值的語法太接近,反易造成困惑。
序對的建構元寫成(,),型別為 a -> b -> (a × b).
例如 (,) 4 'b' = (4,'b').
兩個常用的函數 fst 與 snd 分別取出序對中的第一個和第二個元素:
fst :: (a × b) -> a
fst (x,y) = x
snd :: (a × b) -> b
snd (x,y) = y
函數 fst 與 snd 的定義方式也是樣式配對:輸入值必須先計算成 (x,y) 的形式。
例 1.13
以下是一些序對與其相關函數的例子。
(3,'a')是一個型別為(Int × Char)的序對。fst (3,'a') = 3,snd (3,'a') = 'a'函數
swap將序對中的元素調換:
swap :: (a × b) -> (b × a)
swap (x,y) = (y,x)
另一個定義方式是 swap p = (snd p, fst p).
但這兩個定義並不盡然相同。詳見第1.7 節。
序對也可以巢狀構成。例如 ((True,3), 'c') 是一個型別為 ((Bool × Int) × Char) 的序對,而 snd (fst ((True,3), 'c')) = 3. 在 Haskell 之中,((a × b) × c) 與 (a × (b × c)) 被視為不同的型別,但他們是同構的 --- 我們可定義一對函數在這兩個型別之間作轉換:
assocr :: ((a × b) × c) -> (a × (b × c))
assocr ((x,y),z) = (x,(y,z))
assocl :: (a × (b × c)) -> ((a × b) × c)
assocl (x,(y,z)) = ((x,y),z)
並且滿足 assocr . assocl = id, 和 assocl . assocr = id.
練習 1.9
試試看不用樣式配對,而以 fst 和 snd 定義 assocl 和 assocr:
assocl p = ...
assocr p = ...
同構
兩個集合A與B 同構(isomorphic),意思是
A 之中的 每個元素都唯一地對應到 B 之中的一個元素,反之亦然。
一個形式定義是:A與B同構意謂我們能找到兩個全(total)函數
to :: A -> B 和 from :: B -> A, 滿足
from . to = id
to . from = id
此處的兩個 id 型別依序分別為 A -> A 和 B -> B。
將定義展開,也就是說,對所有 x :: A,from (to x) = x; 對所有 y :: B, to (from y) = y. 這個定義迫使對每個 x 都存在一個唯一的 to x, 反之亦然。
我們已有兩個例子:((a × b) × c) 與 (a × (b × c)) 同構,此外,(a × b) 與 (b × a) 也同構,因為 swap . swap = id.
如果集合A與B同構,不僅 A 之中的每個元素都有個在 B 之中相對的元素,給任一個定義在 A 之上的函數 f, 我們必可構造出一個 B 之上的函數,具有和 f 相同的性質。即使A與B並不真正相等,我們也可把它們視為基本上沒有差別的。在許多無法談「相等」的領域中,同構是和「相等」地位一樣的觀念。
另外可一提的是,Haskell 允許我們在 $\lambda$ 算式中做樣式配對。例如 fst 的另一種寫法是:
fst = \(x,y) -> x
Haskell 另有提供更多個元素形成的有序組,例如 (True, 3, 'c') 是一個型別為 (Bool × Int × Char) 的值。但本書暫時不使用他們。
分裂與積 在我們將介紹的程式設計風格中,以下兩個產生序對的運算子相當好用。 第一個運算子利用兩個函數產生一個序對:
fork :: (a -> b) -> (a -> c) -> a -> (b × c)
(fork f g) x = (f x, g x)
給定兩個函數 f :: a -> b 和 g :: a -> c,
fork f g :: a -> (b × c) 是一個新函數,將 f 和 g 的結果
收集在一個序對中。
我們借用範疇論的詞彙,將此稱作分裂(split) ---
fork f g 可讀成「f 與 g 的分裂」。
如果我們已經有了一個序對,我們可用 (f *** g) 算出一個新序對:
(***) :: (a -> b) -> (c -> d) -> (a × c) -> (b × d)
(f *** g) (x,y) = (f x, g x)
函數(f *** g) 將 f 和 g 分別作用在序對 (x,y) 的兩個元素上。
這個操作稱作「f 和 g 的乘績(product)」,同樣是借用範疇論的詞彙。
關於分裂與乘積,有兩條重要的性質,將會在之後用到:
吸收律
(f *** g) . fork h k= fork (f . h) (g . k)融合律
(f *** g) . (h *** k)= ((f . h) *** (g . k))
目前 Haskell 的標準階層函式庫將分裂與乘積收錄在 \texttt{Control.Arrow} 中,
fork f g 寫作:\texttt{f \&\&\& g}, (f *** g) 則寫作 \texttt{f *** g}.
Currying 與 Uncurrying
如前所述,Haskell 的每個函數都只拿一個參數。拿多個參數的函數可以
傳回函數的函數來模擬,稱作 currying.
有了序對之後,另一種模擬多參數的方式是把參數都包到一個序對中。
例如,型別為 (a × b) -> c) 的函數可視為拿了兩個型別為 a 與 b 的參數。
函數 curry 與 uncurry 幫助我們在這兩種表示法之間轉換 ---
curry 將拿序對的函數轉換成 curried 函數,
uncurry 則讓 curried 函數改拿序對當作參數:
curry :: ((a × b) -> c) -> (a -> b -> c)
curry f x y = f (x,y)
uncurry :: (a -> b -> c) -> ((a × b) -> c)
uncurry f (x,y) = f x y
例:如果(==) 的型別為 Int -> Int -> Bool,
uncurry (==) 的型別為 (Int × Int) -> Bool。
後者檢查一個序對中的兩個值是否相等(例:uncurry (==) (3,3) = True)。
練習 1.10
事實上,curry 與 uncurry 的存在證明了 (a × b) -> c
與 a -> b -> c 是同構的。試證明
curry . uncurry = id, 以及 uncurry . curry = id.
答案
欲證明 curry . uncurry = id:
curry . uncurry = id
<=> {- 外延相等及 id 之定義 -}
(∀ f : curry (uncurry f) = f)
<=> {- 外延相等 -}
(∀ f x y : curry (uncurry f) x y = f x y)
因此我們證明 curry (uncurry f) x y = f x y 如下:
curry (uncurry f) x y
= {- curry 之定義 -}
uncurry f (x,y)
= {- uncurry 之定義 -}
f x y
與此相似,欲證明 uncurry . curry = id 我們須證明
uncurry (curry f) (x,y) = f (x,y),其證明也與上面的證明類似。
練習 1.11
請說明 map (uncurry ($)) 的型別與功能。
關於 ($) 請參考第\pageref{para:fun-apply}頁。
答案
map (uncurry ($)) :: List ((a -> b) × a) -> List b.
它拿一個內容均為「(函數 $\times$ 參數)」序對的串列,將每個函數作用在其參數上。
例如:
map (uncurry ($)) [((1+), 3), (square, 4), (smaller 5, 3)]
會得到 [1+3,4*4,3].