Haskell Tutorial(18)認識 Data.List 模組


到目前為止,介紹了不少 Haskell 基本語法,當然,還有些進階語法沒談到,不過,暫且換個方向,來看看 Haskell 中提供的標準模組中,有哪些基本常用的函式可以使用,詳細介紹每個 API 如何使用其實也蠻沒意義的,這是 API 文件該做的事,這邊的重點還是擺在一個足夠讓你往後探索的基礎上。

Hackage 與 Hoogle

Haskell 可用的套件相關說明資訊,可以在 Hackage 找到,標準套件可以在 The base package 找到。

在開始之前,得介紹一下 Hoogle,一個 Haskell API 搜尋引擎,搜尋頁面 https://www.haskell.org/hoogle/,對於查詢 API 如何使用相當方便:

Hoogle

Data.List 模組

之前介紹過不少 List 的應用,也用過幾個函式,像是 ++headtail 等,這些函式實際上是位於 Data.List 模組,不過這幾個函式因為常用,基於方便將之匯入了 Prelude 模組之中,因此,之前不用特別 import 也能使用。

如先前介紹,++ 可以串接兩個 List,這沒什麼好多做解釋的,到是來看看怎麼實現會比較有趣:

add :: [a] -> [a] -> [a]
add [] xs2 = xs2
add (x:[]) xs2 = x : xs2
add (x:xs) xs2 = x : add xs xs2

這個 add 函式,用起來就像 ++,例如:

add 函式

add 實作中可以看到,它會走訪第一個 List,因此若第一個 List 很長,使用 add 會比較沒效率,也就是說,對於 ++ 左邊的 List 很長時,使用 ++ 串接會沒有效率,因此,如果是單純地在 List 中收集元素,應當避免 xs ++ [1]xs ++ [2, 3] 這樣的動作,改使用 1 : xs2 : 3 : xs 會是比較好的選擇。

依此類推的話,就是說,對於字串的串接,若 ++ 左邊的字串很長,也要避免使用 ++ 來串接,例如,單純地收集字元的話,應避免使用 xs ++ "a"xs ++ "bc",而可以改用 'a' : xsb : c : xs,會是比較好的選擇。

headtail 實際上都可以改用 Pattern Matching,視情況而定,有時用 headtail 方便或易讀,有時用 Pattern Matching 方便或易讀,與它們相對的是 initlast,分別取首清單與尾元素,實作上像是:

init' :: [a] -> [a]
init' [] = error "empty list"
init' (x:[]) = []
init' (x:xs) = x : init' xs

last' :: [a] -> a
last' [] = error "empty list"
last' (x:[]) = x
last' (_:xs) = last' xs

對於一個元素在不在 List 中,可以使用 elem,如果想知道索引位置,可以使用 elemIndex,後者沒有匯入 Prelude,需要 import Data.List 才能使用,來看看怎麼實現:

elem' :: Ord a => a -> [a] -> Bool
elem' _ [] = False
elem' ele (x:xs) = if ele == x then True else elem' ele xs

elemIdx :: (Eq a, Num a) => a -> [a] -> Maybe a
elemIdx _ [] = Nothing
elemIdx ele xs = idxOf xs 0
    where
        idxOf (x:xs) idx =
            if ele == x then Just idx 
            else if null xs then Nothing else idxOf xs (idx + 1)

elem' 比較簡單,就是傳回 TrueFalse,而在 elemIdx 這方面,因為元素可能不存在於 List 中,因此搜尋結果可能有索引值也可能沒有,因此,傳回型態會是 Maybe

像這樣來試著做一些基本函式的實作,對於模組中函式的原理與使用,就會有比較多的認識。

關於 fold 的相關函式

在〈Haskell Tutorial(7)filter、map、fold 模式〉中,談過 fold 模式,當時的 fold 函式實現方式,其實就類似 Haskell 內建的 foldr 函式,不過,實際的 foldr 函式還接受一個起始值,例如:

foldl 函式

實現的方式會像是:

foldR :: (a -> b -> b) -> b -> [a] -> b
foldR f z []     = z
foldR f z (x:xs) = x `f` (foldR f z xs)

之所以稱之為 foldr,是因為這就像是在折紙,把 12345 由左而右畫在紙上,你就是從右折紙,每折一次,就將上次執行結果與左邊的元素用指定的函式處理,foldr 的 r 就是 right 的意思,記得,foldr 接受的函式,左邊參數當次的元素,第右參數是上一次計算的結果,記法是,每次折紙時處理的是左邊的元素。以下是 foldr (+) 0 [1, 2, 3, 4, 5],在走訪整個 List 到最右端之後,接下來就是:

1 + (2 + (3 + (4 + (5 + 0)))
1 + (2 + (3 + (4 + 5)))
1 + (2 + (3 + 9))
1 + (2 + 12)
1 + 14
15

foldr,那是不是表示有 foldl?是的!foldl 也像是在折紙,foldl (+) 0 [1, 2, 3, 4, 5] 的話,就像是把 12345 由左而右畫在紙上,你就是從左折紙,每折一次,就將上次執行結果與右邊的元素用指定的函式處理,實作上像是:

foldL :: (a -> b -> a) -> a -> [b] -> a
foldL f z []     = z
foldL f z (x:xs) = let z' = z `f` x  
                   in foldL f z' xs

記得,foldl 接受的函式,左邊參數是上一次計算的結果,右邊參數是當次的元素,與 foldr 接受的函式是相反的,記法是,每次折紙時處理的是右邊的元素。單從 foldL 示範實作看來,如果執行 foldl (+) 0 [1, 2, 3, 4, 5],過程會像是:

foldl f (0 + 1) [2, 3, 4, 5]
foldl f (1 + 2) [3, 4, 5]
foldl f (3 + 3) [4, 5]
foldl f (6 + 4) [5]
foldl f (10 + 5) []
15  

這就像是逐步消減 List 來計算,因此,foldl 有時在其他語言中稱為 reduce,不過,上面的過程,應該是嚴格版本的 foldl' 之行為,foldl' 不會進行惰性求值,而 foldl 會進行惰性求值,因此,foldl 實際在運算時,會像是:

foldl f (0 + 1) [2, 3, 4, 5]
foldl f ((0 + 1) + 2) [3, 4, 5]
foldl f (((0 + 1) + 2) + 3) [4, 5]
foldl f ((((0 + 1) + 2) + 3) + 4) [5]
foldl f (((((0 + 1) + 2) + 3) + 4) + 5) []
foldl f ((((1 + 2) + 3) + 4) + 5) []
foldl f (((3 + 3) + 4) + 5) []
foldl f ((6 + 4) + 5) []
foldl f (10 + 5) []
foldl f 15 []
15  

因此,使用 foldl 處理很長的 List 時,常會遇到 stackoverflow 的問題,這時可改用嚴格版本的 foldl'

如果指定的處理函式不具結合律,應瞭解 foldlfoldr 的差別,例如 +* 具有結合律,因為 (a + b) + ca + (b + c) 是相同的,- 就不具結合律,例如 foldl (-) 0 [1 .. 10] 結果是 -55,而 foldr (-) 0 [1 .. 10] 結果是 -5

有時指定的函式本身具有結合律,也要留意一下函式本身的特性,例如 foldl (++) "" ["Justin", "Monica", "Irene"],雖然 ++ 具結合律,不過在上面討論過,++ 會將左邊的 List 整個走訪過一遍,因此左邊的 List 若很長,會有效率上的問題,執行 foldl (++) "" ["Justin", "Monica", "Irene"] 的話,最後會有 "JustinMonica" ++ "Irene" 的結果,也就是左邊的字串會越來越長,++ 效率就會越來越差,換用 foldr (++) "" ["Justin", "Monica", "Irene"] 會比較好一些,類似地,如果結果是要產生新的 List,使用 foldr 效率會好一些。

foldlfoldr 必須指定初值,foldl1foldr1 會使用折紙時第一個遇到的元素當初值,因此使用 foldl1 處理 [1, 2, 3, 4, 5] 時,因為是從左折,初值就是 1,使用 foldr1 處理 [1, 2, 3, 4, 5] 時,因為從右折,初值就是 5

恒等值的考量

除了惰性、結合律以及指定處理函式本身的特性考量之外,fold 相關函式還可以有讓初值為恒等值(Identity)的考量。所謂恒等值,是指使用指定的函式 f 來處理恒等值 n 及 List 中任一元素 m,結果還是還是 m,也就是 f m n 結果會是 m,例如,指定函式為 + 而恒等值為 0 時, 0 + 1 還是 10 + 2 還是 2,這麼做的目的在於,如果 List 很長,打算將之切為數等分來計算時,也不致於因為發生累計問題(處理函式要具有結合律)。

例如,foldl (+) 0 [1 .. 100] 的結果是 5050,也許將來你可以實現一個 parallelFoldl,會將 [1 .. 100] 切成四份平行運算,實作概念相當於 (foldl (+) 0 [1 .. 25]) + (foldl (+) 0 [26 .. 50]) + (foldl (+) 0 [51 .. 75]) + (foldl (+) 0 [76 .. 100]),這樣就沒有問題,結果還是 5050

如果因為某個原因,希望從 5 開始累加,在不平分處理下,雖可以寫成 foldl (+) 5 [1 .. 100],結果就是 5055,不過,若 foldl 改為自定義的 parallelFoldl (+) 5 [1 .. 100],結果會相當於 (foldl (+) 5 [1 .. 25]) + (foldl (+) 5 [26 .. 50]) + (foldl (+) 5 [51 .. 75]) + (foldl (+) 5 [76 .. 100]),也就是 5070,那就不對了,對於這個需求,一開始應該是寫成 5 + foldl (+) 0 [1 .. 100] 才是正確的,這樣若 foldl 改為 parallelFoldl,結果就相當於 5 + (foldl (+) 0 [1 .. 25]) + (foldl (+) 0 [26 .. 50]) + (foldl (+) 0 [51 .. 75]) + (foldl (+) 0 [76 .. 100]),這樣就沒有問題。

誰能處理無限長 List?

foldlfoldr 經常被討論的問題之一就是,誰能處理無限長的 List?這問題就在於,無限長的 List 長什麼樣呢?[1 ..]!從左開始折的話,這紙一定是折不盡的,可是,如果從右邊開始折,開始折的邊界又在哪呢?再來看一下 foldR

foldR :: (a -> b -> b) -> b -> [a] -> b
foldR f z []     = z
foldR f z (x:xs) = x `f` (foldR f z xs)

為了完成 f 的處理,除了 x 之外,還得等待 f 第二個引數,也就是 foldR f z xs 的結果,這就是上面對 foldr 指定 + 時,必須走訪整個 List 才能得到結果的情況。那麼,如果 f 不用等待第二個引數呢?像是指定 \ele y -> ele 會如何?

foldr 函式

這就是了,真的是蠻聰明的,雖說打算從右折無限長的 List,不過,每次都只傳回當次元素,一路往左看到,盡頭不就是個 1 嗎?

不過,指定 \ele y -> ele 看起來沒什麼用,來想個有用的好了!嗯!對一個元素為 Bool 的無限長 List 做 || 處理如何(裏頭可能有 True 也可能有 False)?在 Data.List 中有個 iterate 可以建立無限長的 List,例如,iterate not True 就會產生 [True, False, True, False, True ...] 一直循環下去,那麼 foldr (||) False $ iterate not True 會如何?

foldr 函式

簡單!雖說是無限長,不過 || 的特性是,只要有一個為 True,就可以判定整個為 True 了,不用等另一個引數處理完畢了,那麼 foldr (||) $ iterate not True 又有什麼用?只能判定 List 是不是有 True 存在嘛!那麼這個呢?

any' :: (a -> Bool) -> [a] -> Bool
any' f = (foldr (||) False) . (map f)

f 是可將值對應至 Bool 的函式,map f 就可以將任意 List 轉為 Bool 元素的 List,這個 List 交給 foldr (||) False,就成了一個,可以判斷 List 中,是否有符合特定條件的元素存在之函式,例如,想知道無限長的 List 中,是不是有能某值整除的數:

foldr 函式

Data.List 中,已經存在 any 這個函式,不過以上的探討,是個蠻有趣的過程。

如果你想快速地瞭解 Data.List 中有哪些現成的函式可以使用,可以參考 Data.List 中的說明。