map/filter/fold

February 1, 2022

在〈無所不在的函式〉談過 filter 函式,這是觀察到程式執行的流程具有重複性的樣版,將之抽象化後建立的函式,這邊來從 list 處理看看如何進行這類的抽象化。

map

先來看看如何將數字 list 中的元素全部加 5,傳回一個新 list:

plus5To :: Num a => [a] -> [a]
plus5To lt =
    if null lt then lt
    else head lt + 5 : (plus5To $ tail lt)

空 list 就直接回傳,要不然就是首元素加 5,然後剩下的 list 繼續 plus5To。如果要寫個 multify3To,將全部的元素乘以 3 呢?

multify3To :: Num a => [a] -> [a]
multify3To lt =
    if null lt then lt
    else head lt * 3 : (multify3To $ tail lt)

你應該發現了,除了 + 5* 3 以及函式名稱不同,兩個流程幾乎是一模一樣,+ 5* 3 是函式,何不將相同的流程抽取出來,將 + 5* 3 作為函式傳入就好呢?例如寫個 map' 呢?

map' :: (a -> a) -> [a] -> [a]
map' mapper lt =
    if null lt then lt
    else (mapper $ head lt) : (map' mapper $ tail lt)
    
main = do
    putStrLn $ show $ map' (+ 5) [3, 4, 5]
    putStrLn $ show $ map' (* 3) [3, 4, 5]

Haskell 本身就有定義 map 函式,因此這邊的範例就在 map' 名稱上多了個 ',以區別內建的 map 函式;在函式型態定義中,a -> a 表示接受 a 傳回 a 的函式,a 型態變數這邊不用約束其必須具有什麼行為,真正要的行為是由 mapper 函式的本體決定,a -> a 僅表示接受的參數與傳回型態必須一致。

進一步地,(a -> a) -> [a] -> [a] 表示接受一個函式,一個 list,傳回一個 list,執行後會顯示 [8, 9, 10][9, 12, 15]

看到了嗎?因為將 list 分而治之,因而,很容易發覺 plus5Tomultify3To 有著類似流程,因而重構出 map' 函式,單看這函式也很簡單,如果 list 為空,那就直接回傳,否則依指定的 mapper 進行元素轉換,將結果置於轉換後的尾 list 之前。

實際上 list 實現了 Haskell 的 Functor 行為,而且可進行映射換的,也不只有 list,詳情可見後續文件〈可以映射的 Functor〉。

filter

類似地,若想寫個 greaterThan5 函式,如果 list 為空,那就直接回傳,否則測試首元素是否為大於 5,是的話留下,不是就別理它了,然後測試剩餘 list:

greaterThan5 :: (Ord a, Num a) => [a] -> [a]
greaterThan5 lt =
    if null lt then lt
    else 
        if head lt > 5 then head lt : (greaterThan5 $ tail lt)
        else greaterThan5 $ tail lt

因為要能比較順序,a 也必須要具有 Ord 的行為;如果想寫一個 smallerThan4 函式呢?

smallerThan4 :: (Ord a, Num a) => [a] -> [a]
smallerThan4 lt =
    if null lt then lt
    else 
        if head lt < 4 then head lt : (smallerThan4 $ tail lt)
        else smallerThan4 $ tail lt

應該也發現了,兩個流程是類似的,就只有 > 5< 4 以及函式名稱不同,> 5< 4 是函式,何不將相同的流程抽取出來,將 > 5< 4 作為函式傳入就好呢?例如寫個 filter' 呢?

filter' :: (a -> Bool) -> [a] -> [a]
filter' predicate lt =
    if null lt then lt
    else
        if predicate $ head lt then head lt : (filter' predicate $ tail lt)
        else filter' predicate $ tail lt
    
main = do
    putStrLn $ show $ filter' (> 5) [3, 4, 5, 6, 7]
    putStrLn $ show $ filter' (< 4) [3, 4, 5, 6, 7]

Haskell 本身就有定義 filter 函式,因此這邊的範例就在 filter' 名稱上多了個 ',以區別內建的 filter 函式;在函式型態定義中,a -> Bool 表示接受 a 傳回 Bool 的函式,(a -> Bool) -> [a] -> [a] 表示接受一個函式,一個 list,傳回一個 list,執行後會顯示 [6, 7][3]

filter' 本身也不難理解,如果是空 list 就直接回傳,要不然就用 predicate 斷言首元素,看看要不要留下來,剩下的 list 繼續 filter',遞迴就是這麼一回事,只要關心這次遞迴該做什麼就好,剩下的 list 如何處理?那是下一次 filter' 該擔心的事。

其實可以過濾的來源,並不限於 list,也可能是樹狀結構等其他類型,有些 Haskell 套件(package),進一步提供了 Filterable 型態類別,用來規範過濾的行為。

fold

來看看怎麼寫個將 list 元素加總的函式:

sum' :: Num a => [a] -> a
sum' lt =
    if null $ tail lt
        then head lt
        else head lt + (sum' $ tail lt)

main = do
    putStrLn $ show $ sum' []         -- 顯示 0
    putStrLn $ show $ sum' [1, 2]     -- 顯示 3
    putStrLn $ show $ sum' [3, 4, 5]  -- 顯示 12

若 list 內容是數字,如果是空 list,加總結果當然就是 0;接下來,若想實作串接字串 list 的 join' 呢?

join' :: [String] -> String
join' lt =
    if null lt
        then ""
        else head lt ++ (join' $ tail lt)

main = do
    putStrLn $ show $ join' []               -- 顯示 ""
    putStrLn $ show $ join' ["1", "2"]       -- 顯示 "12"
    putStrLn $ show $ join' ["3", "4", "5"]  -- 顯示 "345"

流程上非常類似,差別在於空 list 時的傳回值、函式名稱以及 +++,有沒有辦法抽取可共用的流程呢?這要先處理一下空 list 時的傳回值,你可以指定空 list 時應該傳回什麼預設值,或者是收到空 list 時發生錯誤,若採取後者的話,sum'join' 可以這麼實作:

sum' :: Num a => [a] -> a
sum' lt =
    let t = tail lt in
    if null t
        then head lt
        else head lt + (sum' t)

join' :: [String] -> String
join' lt =
    let t = tail lt in
    if null t
        then head lt
        else head lt ++ (join' t)

tail 函式若傳入空 list 會引發錯誤,因此,sum'join' 若指定空 list 也會引發錯誤,現在可以來重構,將重複的流程抽取為 fold

fold :: (a -> a -> a) -> [a] -> a
fold reducer lt =
    let t = tail lt in
    if null t
        then head lt
        else (head lt) `reducer` (fold reducer t)

main = do
    putStrLn $ show $ fold (+) [1, 2]            -- 顯示 3
    putStrLn $ show $ fold (++) ["3", "4", "5"]  -- 顯示 "345"

因為 +++ 是需要兩個引數的函式,因此 reducer 的函式型態是 (a -> a -> a),如上所示,可以使用 fold 來做加總或串接的動作。

感覺很神奇嗎?若不是用重構的方式,而是直接單看 fold 流程,感覺會有點抽象,以 fold (+) [1, 2, 3, 4, 5] 來說,fold 的相加順序是 54 相加得到 9,然後 93 相加得到 12,然後 122 相加得到 14,然後 141 相加得到最後的 15

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

這就像是在折紙,每折一次,就將上一次執行結果與傳入的元素用 reducer 處理,這就是為什麼它叫做 fold 的原因,簡單來說,如果你打算走訪整個 list 來求得單一值,可以使用這個 fold

Haskell 有些函式是以 fold 開頭,像是 foldlfoldrfoldl1foldr1,概念類似,不過有些比較深入的細節要探討,以後有機會再來談!

實際上 list 實現了 Haskell 的 Fordable 行為,而且可進行 fold 的,也不只有 list,詳情可見後續文件〈發掘具有組合性的行為〉。

分享到 LinkedIn 分享到 Facebook 分享到 Twitter