Haskell Tutorial(31)do 區塊與 <- 綁定 << 前情
如果你是初學 Haskell,且能一路看到這篇文章,應該算是步入 Haskell,或說是步入了函數式程式設計的大門了,而這也是我寫 Haskell Tutorial 的目的,往後還有更多的東西等著發掘,為此,我這邊以〈發掘更具組合性的抽象〉為題,作為 Haskell Tutorial 的結束。
加總樹的節點值
記得〈Haskell Tutorial(19)Data.Set 與 Data.Map 模組〉中,最後的題目是希望你實作出一個 Tree 型態嗎?我是這麼實作的:
data Tree a = EmptyTree | Node a (Tree a) (Tree a)
singleNode :: a -> Tree a
singleNode x = Node x EmptyTree EmptyTree
insert :: (Ord a) => a -> Tree a -> Tree a
insert x EmptyTree = singleNode x
insert x (Node a l r)
| x == a = Node x l r
| x < a = Node a (insert x l) r
| x > a = Node a l (insert x r)
fromList :: Ord a => [a] -> Tree a
fromList [] = EmptyTree
fromList (x:xs) = insert x $ fromList xs
node :: (Ord a) => a -> Tree a -> Bool
node x EmptyTree = False
node x (Node a l r)
| x == a = True
| x < a = node x l
| x > a = node x r
toList :: Tree a -> [a]
toList EmptyTree = []
toList tree = con [] tree
where
con lt EmptyTree = lt
con lt (Node a l r) = con r (a : (con l lt))
instance Show a => Show (Tree a) where
show tree = "fromList " ++ (show . toList) tree
現在將重點放在 toList 的實作,我做了什麼?我從 [] 開始,在呼叫內部的 con 函式過程中,如果到達了左子樹的葉節點,就折下這個葉節點放到 List 前端(也就是使用 : 函式),左子樹訪問完就談問右子樹,每次也都是折下一個葉節點放到 List 前端。
如果使用動畫來顯示這個過程的話,就可以清楚地看到,就像是在折疊一顆樹:

假設現在有一顆樹,節點值都是整數,想將所有節點值加總該怎麼做呢?我們可以使用上頭的 toList 將樹轉為 List,然後再使用 sum 來求得加總,不過,我們自己來寫一個:
sumTree :: Tree Integer -> Integer
sumTree tree = sumIt 0 tree
where
sumIt init EmptyTree = init
sumIt init (Node a l r) = sumIt (a + (sumIt init l)) r
如何?仔細一看,這跟上頭的 toList 根本就是差不多的東西,只是一個用了 : ,而另一個用了 + ,如果這些函式是被當成引數傳入的話,那我們可以寫個 foldTree :
foldTree :: (a -> b -> a) -> a -> Tree b -> a
foldTree _ z EmptyTree = z
foldTree f z (Node a l r) = foldTree f (f preZ a) r
where preZ = foldTree f z l
那麼,toList 與 sumTree 就可以分別如下使用 foldTree 實作:
toList :: Tree a -> [a]
toList tree = foldTree (\z a -> a : z) [] tree
sumTree :: Tree Integer -> Integer
sumTree tree = foldTree (+) 0 tree
Foldable Typeclass
foldTree 看起來很像可以處理 List 的 foldl (也就是定義在 Data.List 中的 foldl ),實際上,如果折疊樹時是從右子樹開始,實作出來的就像是可處理 List 的 foldr 了,看來可以折疊的並不只有 List,對於可折疊的行為,Haskell 在 Data.Foldable 模組中定義了 Foldable 這個 Typeclass。
在 Data.Foldable 模組中也有定義 foldl 等函式,先來看看看它們的型態與使用方式:

相對於只能處理 List 的 foldl :: (a -> b -> a) -> a -> [b] -> a ,Data.Foldable 模組中的 foldl 函式,第三個可接受的引數必須具有 Foldable 的行為,最直覺的就是先試試看 List:

想要實現 Foldable ,可以實作它的 foldr :: (a -> b -> b) -> b -> t a -> b 或 foldMap :: Monoid m => (a -> m) -> t a -> m ,對於 List,實作 Foldable 的 foldr ,只要令其等於 Data.List 中的 foldr 函式就可以了,如果想要讓上頭的 Tree 也成為 Foldable ,可以如下實作:
import Prelude hiding (foldr)
import Data.Foldable hiding (toList)
...
instance Foldable Tree where
foldr _ z EmptyTree = z
foldr f z (Node a l r) = foldr f (f a preZ) l
where preZ = foldr f z r
這麼一來,我們自定義的 Tree 也可以直接使用 Data.Foldable 模組的 foldr 、foldl 等函式了:

Monoid Typeclass
另一個實作 Foldable 的方式,是實作它的 foldMap :: Monoid m => (a -> m) -> t a -> m ,不過受到了 Monoid 的限制,這是什麼?你可以先思考一下,如果想要實作一個通用的 foldr 會需要哪些條件?
你能接受的函式必須是二元函式,而且必須具有結合律(Associativity),例如,因為 (1 + 2) + 3 的結果會與 1 + (2 + 3) 相同,因此 + 具有結合律,這是因為事先無法預料可以折疊的資料型態會是什麼樣的結構,被丟進函式處理的兩個值,可能是從結構中任意處取得,如果函式不具備結合律,使用通用的 foldr 結果就可能不正確。
另外,你的函式必須具有恒等值(Identity),函式套用恒等值與某值,結果還會是該值,例如,0 對 + 是個恒等值,因為任何數與 0 相加都會是該數,1 + 0 還是 1 、2 + 0 還是 2 ,類似地,1 對 * 是個恒等值,因為任何數與 1 相乘,結果還是該值,2 * 1 還是 2 、3 * 1 還是 3 。
這個考量同樣是因為,事先無法預料可以折疊的資料型態會是什麼樣的結構,因此通用的 foldr 首次執行時,必須有個初始的恒等值才能做為開始。
這些行為被 Haskell 定義為 Data.Monoid 中的 Monoid 這個 Typeclass,它要實作的函式有 mempty :: m 與 mappend :: m -> m -> m ,來看看 List 怎麼實作 Monoid :
instance Monoid [a] where
mempty = []
mappend = (++)
對於 List 來說,++ 是個具有結合律的函式,因為 ([1, 2] ++ [3, 4]) ++ [5, 6] 結果與 [1, 2] ++ ([3, 4] ++ [5, 6]) 同樣是 [1,2,3,4,5,6] ,而對於 ++ 來說,[] 就是恒等值,因為 [1, 2] ++ [] 還是 [1, 2] ,[3, 4] + [] 還是 [3, 4] 。
因此,mappend 就是用來實現具有有結合律的二元函式,而 mempty 就是用來設定恒等值了,知道這些之後,就可以知道,為什麼可以使用 F.foldl (++) [] [[1, 2], [3, 4], [5, 6]] 來取得一個 [1, 2, 3, 4, 5, 6] 的結果了。
那麼,該怎麼實作 foldMap ?重新來看一下它的型態 Monoid m => (a -> m) -> t a -> m ,foldMap 接受一個函式,一個 Foldable 的實例,最後傳回一個值,我們接受的函式之傳回值與 foldMap 的傳回值,都是 Monoid 實例。
對於一個 List,是這樣想的,可以看成是 x:xs ,實作 foldMap 時,foldMap f xs 會得到一個 List(一個 Monoid ),而 f x 也會得到一個 List(一個 Monoid ),結果就是 (f x) ++ (foldMap f xs) ,也可以寫成 (f x) `mappend ` (foldMap f xs) 。
在 Haskell 的 Foldable Typeclass 原始碼中,foldr 實作時使用了 foldMap ,f 是在 foldr 當中產生並給 foldMap 的;實際上不用管 f 是什麼,只要記得,f 會套用到目前的元素值得到一個 Monoid ,而 foldMap 會套用到其他子結構得到其它 Monoid ,mappend 再將這些 Monoid 結合起來運算,這就是 foldMap 的實作方式了。
那麼樹的話,就可以看成是 f 套用到目前的節點值得到一個 Monoid ,foldMap 套用到左子樹得到一個 Monoid ,foldMap 套用到右子樹得到一個 Monoid ,然後,你使用 mappend 將這三個 Monoid 結合運算:
instance Foldable Tree where
foldMap f EmptyTree = mempty
foldMap f (Node x l r) =
(foldMap f l) `mappend` (f x) `mappend` (foldMap f r)
簡單來說,如果是 List,想著可以從首元素得到一個 Monoid ,尾清單得到一個 Monoid ,如果是樹,可以想著從左子樹、節點、右子樹分別得到一個 Monoid ,而這些 Monoid 又該如何結合就對了。
繼續前進
真是有趣!如果你直接看 Monoid ,大概會摸不著頭緒,為什麼一個恒等值,一個具結合律的二元函式,要合起來定義為一個 Typeclass,然而,如果從折疊資料結構這類的例子來想,試著抽取出共同行為,讓它通用化,就會發現這樣的考量有其道理。
重點是,當這樣的共同行為被抽取出來、通用化之後,就可以將既有的函式等組合進來使用,而不用重新實作類似的行為,就像 List 在實作 Fordable 時,就不要令 Data.List 的 foldr 為 Foldable 就可以了。
這類具有組合性且可抽取的行為還有很多,就如這篇一開始說的,你已經進入了大門,不過,後續還有很多可以探索的!至少,對我來說,還有很多可以玩的呢!
|