如果想認真地學習Haskell,從中直接體會純函數式語言的種種概念,最難的也許不是不可變動、代數資料型態等的掌握,而是搞清楚它的型態(type)吧!

面對Haskell時,無論是誰應該都會發現,我們對型態的考量往往不夠周詳,因為在Haskell要能編譯成功本身就是件難事,因此有「編譯過了!交貨!(It Compiles! Ship it!)」的笑話。

哪類值?哪類型態?

例如,Haskell中寫下的10會是什麼型態呢?Int?不一定!有時試著檢驗10這類型態,會發現編譯器推論出來的型態是Num?Int具體而言,是data關鍵字定義的型態,10、20等整數值,屬於Int這一類的值(category of values);Num具體而言,是class定義的型態類別(typeclass),Int、Float等型態,都是Num這一類型態(class of types),型態類別是用來定義一組行為,因此可以將型態類別理解為「行為」的分類。

由於category、class往往會譯為「類」、「類別」等,再加上支援物件導向的程式語言,又往往有class關鍵字,許多人面對Haskell的data、class就被混淆了。

category與class其實是數學上的概念區別,前者是代表一組值(a collection of values),後者代表一組集合(a collection of sets)。例如,Int代表一組整數值,Num代表一組型態(像是Int、Float)。簡單來說,Haskell看待型態的方式有兩種,一類(category)的值具有特定「結構」,另一類(class)的型態具有特定「行為」的型態。

Haskell編譯器可根據程式的前後文,推斷出值或運算式的合適型態,在可行的情況下,會試著推斷出值需要有什麼行為,找出對應的型態類別作為值的型態,以便取得運算時在型態上的彈性。如果想自行標註型態,也建議優先考量型態類別。

Haskell型態類別 vs. Java介面?

在Haskell中定義型態類別時,主要是透過class定義型態類別名稱,以及一組函式簽署(包含函式名稱、參數型態、傳回型態等),代數型態可以透過instance實作指定的型態類別。這感覺很像Java中使用interface定義介面,透過implements實作指定的介面,確實也有不少開發者,會以這種方式來過渡Java的經驗,以理解Haskell的型態類別。

雖然這不乏是個過渡語言經驗的聯想方式,但要小心的是,Java是支援物件導向的語言,interface是用來實現次型態多型(subtype polymorphism),編譯時期只檢查傳入的物件是否實作指定介面,執行時期則是視物件實際型態,動態呼叫對應的方法。

然而,Haskell沒有物件的觀念,沒有次型態多型,例如,若某函式要求傳入的引數,必須具有Eq行為,編譯時期就會依值的型態,選定對應的instance實作,這聽來像是重載(overload)?是的!型態類別所實現的其實是特定多型(ad hoc polymorphism),讓同一個函式,可以因不同型態而有各異的實作(可參考先前專欄〈思考Haskell的多型〉)。

因為代數資料型態只定義值的結構,怎麼利用結構,則是取得值的開發者各自實現(參考〈模式比對與多型〉)。如果發現多個型態都實現了類似的一組函式,我們可以將這組抽取出來、定義為型態類別F,然後,既有的函式就直接重構為型態類別F的實作。

接下來,在後續的開發中,若發現有多個型態都基於型態類別F,實現了類似的另一組函式,此時,我們可以將這組函式抽取出來定義為型態類別A,由於這些函式都要有F的行為,因此,新的型態類別必須以=>約束具有F的行為。

這過程就與interface有根本性不同了,因為=>並不是繼承而是約束。如果有某個型態在某地方實現了F,你可以在任何地方實現A,然而如果是interface,你必須在同一個類別實作F與A。

別從抽象理解抽象

Haskell中有些簡單的型態類別,像是規範相等性的Eq、順序行為的Ord、列舉行為的Enum等,它們的行為容易理解,相關函式的宣告也不複雜,常作為型態類別入門介紹的對象;然而,有些高階的型態類別常讓許多開發者卡住,像是Functor、Applicative、Monad、Foldable等。

這一方面是因為Haskell的函式,天生就是柯里化函式(curried function),連續的單參數型態宣告,再加上實際型態未定的型態參數(type variable),容易在視覺上妨礙理解。

另一方面,則與方才談到從F到A的過程有關。因為流程的抽象會經由抽取再抽取,從而構成看似高深莫測的型態類別,所以,有不少初學者認為這些行為難以理解。就某些程度而言,這是因為未先觀察具體實作,試圖直接從抽象去理解行為,才會造成這種誤會。

舉例來說,以下是Functor型態類別的定義,其中f、a、b都是型態參數,如果沒看過未抽取抽象前的多個函式,應該很難理解fmap是什麼吧!

class Functor f where
fmap :: (a -> b) -> f a -> f b

然而,初學Haskell,一定會談到map函式吧!map (+10) [1,2,3]會是[11,12,13],map可以指定a->b的函式,將元素型態為a的List,對應至另一個元素型態為b的List,也就是[a]->[b],如果自己定義map函式,你的map函式宣告會是(a -> b) -> [a] -> [b]。

如果要你定義一個maybeMap函式,可以指定a->b的函式,將Just 10對應至Just "10"呢?最後你實現的maybeMap函式,函式宣告就會是(a -> b) -> Maybe a -> Maybe b。

嗯?你可能發現有兩個類似的函式宣告,差別在於一個使用了[](也就是List型態),一個使用了Maybe。這表示型態可以參數化,最後抽取出來的就是(a -> b) -> f a -> f b,其中的f,可以是[]或Maybe等型態。

也就是說,如果你曾試著自行實作,將f a映射至f b,那麼,就有可能是在實現Functor的行為,這時就可以考慮讓你的f型態作為Functor的實例。

因此,別再試著從f (a -> b) -> f a -> f b去理解Applicative,或者從m a -> (a -> m b) -> m b等去理解Monad,而應該試著去觀察重複、抽取重複,知道那些抽象行為定義前,是為了因應什麼樣的情境!

map/filter/reduce

無論是命令式語言中要談函數式,或純函數式語言,List結構的map/filter/reduce經常被拿來作為入門的範例,如方才談到的,map實現的就是Functor的行為,reduce呢?reduce行為在Haskell常命名為fold,但是,可以折疊運算的對象並不限於List。例如,你也可以折疊一棵樹,若節點都是數字,你可以用折疊的概念來取得各節點數字的加總。

不難想像的,Haskell就有Foldable型態類別,而List是Foldable的實例之一,那麼filter呢?可以過濾運算的對象,不限於List,例如,也可以過濾樹的節點,而Haskell有些套件,確實也提供了Filterable型態類別!

記得!通常data定義了特定結構屬於哪一類(category),class則是定義特定行為屬於哪一類(class),別一味地從interface去理解型態類別,重點在於觀察重複、抽取行為,而不是那些神祕的函式型態宣告!

專欄作者


熱門新聞

Advertisement