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 分而治之,因而,很容易發覺 plus5To
與 multify3To
有著類似流程,因而重構出 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
的相加順序是 5
與 4
相加得到 9
,然後 9
與 3
相加得到 12
,然後 12
與 2
相加得到 14
,然後 14
與 1
相加得到最後的 15
。
1 + (2 + (3 + (4 + 5)))
1 + (2 + (3 + 9))
1 + (2 + 12)
1 + 14
15
這就像是在折紙,每折一次,就將上一次執行結果與傳入的元素用 reducer
處理,這就是為什麼它叫做 fold
的原因,簡單來說,如果你打算走訪整個 list 來求得單一值,可以使用這個 fold
。
Haskell 有些函式是以 fold 開頭,像是 foldl
、foldr
、foldl1
、foldr1
,概念類似,不過有些比較深入的細節要探討,以後有機會再來談!
實際上 list 實現了 Haskell 的 Fordable
行為,而且可進行 fold 的,也不只有 list,詳情可見後續文件〈發掘具有組合性的行為〉。