跳轉到內容

Haskell/列表 III

來自華夏公益教科書

map 類似,摺疊 也是一個高階函式,它接受一個函式和一個列表。但是,摺疊不是逐個元素地應用函式,而是使用它將列表元素組合成一個結果值。

讓我們看幾個具體的例子。sum 可以實現為

示例:sum

sum :: [Integer] -> Integer
sum []     = 0
sum (x:xs) = x + sum xs

product 可以實現為

示例:product

product :: [Integer] -> Integer
product []     = 1
product (x:xs) = x * product xs

concat,它接受一個列表列表並將它們連線(串聯)成一個列表

示例:concat

concat :: [[a]] -> [a]
concat []     = []
concat (x:xs) = x ++ concat xs

所有這些示例都展示了遞迴模式,稱為摺疊。可以將名稱視為將列表“摺疊”成單個值,或將函式“摺疊在”列表的元素之間。

Prelude 定義了四個 fold 函式:foldrfoldlfoldr1foldl1

右結合foldr 從右到左摺疊一個列表。在進行過程中,foldr 使用給定的函式將每個元素與稱為累加器 的執行值組合起來。呼叫 foldr 時,累加器的初始值作為引數設定。

foldr :: (a -> b -> b) -> b -> [a] -> b
foldr f acc []     = acc
foldr f acc (x:xs) = f x (foldr f acc xs)

foldr 的第一個引數是一個帶兩個引數的函式。第二個引數是累加器的值(通常從一箇中性的“零”值開始)。第三個引數是要摺疊的列表。

sum 中,f(+),而 acc0。在 concat 中,f(++),而 acc[]。在許多情況下(就像我們迄今為止的所有示例一樣),傳遞給摺疊的函式將是一個接受相同型別兩個引數的函式,但這並非總是如此(正如我們可以從型別簽名中的 (a -> b -> b) 部分看到的那樣——如果型別必須相同,那麼型別簽名中的前兩個字母將匹配)。

請記住,在 Haskell 中寫成 [a, b, c] 的列表是 a : b : c : [] 的替代(語法糖)風格。

現在,foldr f acc xsfoldr 定義中只是用函式 f 替換 xs 列表中的每個 cons (:),同時用 acc 替換結尾處的空列表

foldr f acc (a:b:c:[]) = f a (f b (f c acc))

注意括號如何巢狀在列表的右端。

一個優雅的視覺化方法是將列表資料結構想象成一棵樹

  :                         f
 / \                       / \
a   :       foldr f acc   a   f
   / \    ------------->     / \
  b   :                     b   f
     / \                       / \
    c  []                     c   acc

我們可以看到 foldr (:) [] 將完全不改變地返回列表。這種沒有效果的函式稱為恆等函式。您應該開始養成在不同情況下尋找恆等函式的習慣,我們將在學習么半群 時更詳細地討論它們。

左結合foldl 從左側第一個元素開始,以相反的方向處理列表。

foldl :: (a -> b -> a) -> a -> [b] -> a
foldl f acc []     =  acc
foldl f acc (x:xs) =  foldl f (f acc x) xs

因此,結果表示式中的括號在列表的左側累積

foldl f acc (a:b:c:[]) = f (f (f acc a) b) c

相應的樹看起來像

  :                            f
 / \                          / \
a   :       foldl f acc      f   c
   / \    ------------->    / \
  b   :                    f   b 
     / \                  / \
    c  []                acc a

由於所有摺疊都包含左側和右側元素,因此初學者可能會被名稱搞糊塗。您可以將 foldr 視為 fold-right-to-left 的縮寫,將 foldl 視為 fold-left-to-right 的縮寫。名稱指的是摺疊的起點。

注意

技術說明:foldl 是尾遞迴 的,也就是說,它立即遞迴,呼叫自身。出於這個原因,編譯器將對其進行最佳化以提高效率,將其轉換為簡單的迴圈。但是,Haskell 是一種惰性語言,因此對f 的呼叫預設情況下將保持未評估狀態,從而在記憶體中構建一個包含列表整個長度的未評估表示式。為了避免記憶體溢位,我們有一個稱為 foldl' 的 foldl 版本,它是嚴格 的——它會在每一步立即強制評估f

函式名稱末尾的撇號發音為“tick”,如“fold-L-tick”或“prime”,如“fold-L-prime”。撇號是 Haskell 識別符號中有效的字元。foldl' 可以從 Data.List 庫模組中找到(透過在原始檔的開頭新增 import Data.List 來匯入)。根據經驗,您應該在可能無限的列表上或摺疊正在構建資料結構的情況下使用 foldr,如果列表已知是有限的並且歸結為單個值,則使用 foldl'。幾乎沒有理由使用 foldl(沒有撇號),雖然如果饋送給它的列表不太長,它可能可以使用。


foldl 和 foldr 是反義詞嗎?

[編輯 | 編輯原始碼]

您可能會注意到,在某些情況下,foldlfoldr 似乎不是 反義詞。讓我們檢查一下其中一個情況,涉及減法作為組合運算。對於以下每個等式,我們將得到 True 還是 False

foldl (-) 6 [3, 2, 1] == 6 - 3 - 2 - 1
foldr (-) 6 [1, 2, 3] == 6 - 3 - 2 - 1

foldr 視為從右到左進行,可能會讓我們假設第二個等式將為真,因為最右側的元素在最左側的元素之前出現。然而,事實並非如此

GHCi> foldl (-) 6 [3, 2, 1] == 6 - 3 - 2 - 1
True
GHCi> foldr (-) 6 [1, 2, 3] == 6 - 3 - 2 - 1
False

發生這種情況是因為 foldrfoldl 之間的差異在於結果表示式的關聯方式,而不是列表元素的從左到右順序。以下是上述表示式的展開,帶有顯式括號

foldl (-) 6 [3, 2, 1] == ((6 - 3) - 2) - 1 -- True
foldr (-) 6 [1, 2, 3] == 1 - (2 - (3 - 6)) -- True

還要注意初始累加器(在本例中為 6)始終位於最裡面的括號中。

為了對比,這裡是一個模擬的命令式風格,它確實改變了列表中元素的順序:[1]

GHCi> import Data.List -- So that we can give foldl' a try
GHCi> reduce      f acc list = foldl' f acc list -- This is the same as plain old foldl'
GHCi> reduceRight f acc list = foldl' f acc (reverse list) -- keep foldl', but reverse list

現在我們從最初的示例中得到了兩個 True,因為兩者都是從左側摺疊的

GHCi> reduce      (-) 6 [3, 2, 1] == 6 - 3 - 2 - 1
True
GHCi> reduceRight (-) 6 [1, 2, 3] == 6 - 3 - 2 - 1
True

foldr1 和 foldl1

[編輯 | 編輯原始碼]

如前所述,foldr 的型別宣告使列表元素和結果可能具有不同的型別。例如,“read” 是一個接受字串並將其轉換為某種型別的函式(型別系統足夠智慧,可以弄清楚是哪種型別)。在本例中,我們將其轉換為浮點數。

示例:列表元素和結果可以具有不同的型別

addStr :: String -> Float -> Float
addStr str x = read str + x

sumStr :: [String] -> Float
sumStr = foldr addStr 0.0

還有一個稱為 foldr1(“fold - R - one”)的變體,它透過採用列表的最後一個元素來放棄顯式的“零”累加器

foldr1           :: (a -> a -> a) -> [a] -> a
foldr1 f [x]     =  x
foldr1 f (x:xs)  =  f x (foldr1 f xs)
foldr1 _ []      =  error "Prelude.foldr1: empty list"

以及 foldl1

foldl1           :: (a -> a -> a) -> [a] -> a
foldl1 f [x]     =  x 
foldl1 f (x:xs)  =  foldl f x xs
foldl1 _ []      =  error "Prelude.foldl1: empty list"

注意:就像 foldl 一樣,Data.List 庫中包含 foldl1',它是 foldl1 的嚴格版本。

使用 foldl1 和 foldr1,所有型別都必須相同,而空列表是錯誤。當沒有明顯的初始累加器值候選並且我們確定列表不會為空時,這些變體很有用。如有疑問,請堅持使用 foldr 或 foldl'。

摺疊和惰性

[編輯 | 編輯原始碼]

右結合摺疊在 Haskell 中比左結合摺疊更自然的一個原因是,右摺疊可以對無限列表進行操作。返回無限列表的摺疊在更大的上下文中完全可用,而更大的上下文不需要訪問整個無限結果。在這種情況下,foldr 可以根據需要進行移動,編譯器會知道何時停止。但是,左摺疊必須遞迴呼叫自身,直到它到達輸入列表的末尾(因為遞迴呼叫不是在 f 的引數中進行的)。不用說,如果輸入列表對 foldl 是無限的,則永遠不會到達末尾。

作為一個玩具示例,考慮一個函式 echoes,它接受一個整數列表並生成一個列表,使得在輸入列表中出現數字 n 的地方,它在輸出列表中被複制 n 次。為了建立 echoes 函式,我們將使用 prelude 函式 replicate,其中 replicate n x 是一個長度為 n 的列表,其中 x 是每個元素的值。

我們可以相當方便地將 echoes 寫成一個 foldr

echoes :: [Int] -> [Int]
echoes = foldr (\ x xs -> (replicate x x) ++ xs) []
GHCi> take 10 (echoes [1..])
[1,2,2,3,3,3,4,4,4,4]

(注意:由於\ x xs ->語法,此定義很簡潔。\,看起來像一個lambda(λ),在不使用函式的任何其他地方時,作為一個未命名函式。因此,我們就地提供了我們的一次性函式的定義。在本例中,xxs是引數,定義的右側是->之後的內容。)

我們也可以使用foldl

echoes :: [Int] -> [Int]
echoes = foldl (\ xs x -> xs ++ (replicate x x)) []
GHCi> take 10 (echoes [1..])     -- not terminating

但只有foldr版本適用於無限列表。如果你只是計算echoes [1..]會發生什麼?試試看!(如果你在GHCi或終端中嘗試過,請記住你可以使用Ctrl-c停止計算,但你必須動作快並留意系統監控器,否則你的記憶體很快就會被消耗掉,你的系統就會卡住。)

作為最後一個例子,map本身可以用fold實現

map :: (a -> b) -> [a] -> [b]
map f = foldr (\ x xs -> f x : xs) []

摺疊需要一些時間才能習慣,但它是函數語言程式設計中的一個基本模式,最終會變得非常自然。任何時候你想要遍歷一個列表並從它的成員中構建一個結果,你可能想要一個摺疊。

練習
  1. 遞迴地定義以下函式(就像上面sumproductconcat的定義一樣),然後將它們轉換為fold
    • and :: [Bool] -> Bool,如果一系列Bool都是True,則返回True,否則返回False。
    • or :: [Bool] -> Bool,如果一系列Bool中任何一個為True,則返回True,否則返回False。
  2. 使用foldl1foldr1定義以下函式
    • maximum :: Ord a => [a] -> a,它返回列表中的最大元素(提示:max :: Ord a => a -> a -> a返回兩個值的較大值)。
    • minimum :: Ord a => [a] -> a,它返回列表中的最小元素(提示:min :: Ord a => a -> a -> a返回兩個值的較小值)。
  3. 使用fold(哪一個?)定義reverse :: [a] -> [a],它返回一個元素順序顛倒的列表。
注意,所有這些都是Prelude函式,所以當你需要它們時,它們將始終觸手可及。(另外,這意味著你必須使用略微不同的名稱,以便在GHCi中測試你的答案。)

“掃描”就像map和fold的混合。摺疊一個列表累積一個單獨的返回值,而對映將每個專案透過一個函式,為每個專案返回一個單獨的結果。掃描同時執行這兩項操作:它像fold一樣累積一個值,但它不是隻返回一個最終值,而是返回所有中間值的列表。

Prelude包含四個掃描函式

scanl :: (a -> b -> a) -> a -> [b] -> [a]

scanl從左側累積列表,第二個引數成為結果列表中的第一個專案。因此,scanl (+) 0 [1,2,3] = [0,1,3,6].

scanl1 :: (a -> a -> a) -> [a] -> [a]

scanl1使用列表的第一個專案作為零引數。如果你希望輸入項和輸出項型別相同,你會經常使用它。請注意scanlscanl1的型別簽名之間的區別。scanl1 (+) [1,2,3] = [1,3,6].

scanr :: (a -> b -> b) -> b -> [a] -> [b]
scanr (+) 0 [1,2,3] = [6,5,3,0]

scanr1 :: (a -> a -> a) -> [a] -> [a]
scanr1 (+) [1,2,3] = [6,5,3]

這兩個函式是scanlscanl1的對應函式,它們從右側累積總數。

練習
  1. 首先使用遞迴編寫scanr的定義,然後使用foldr編寫。對scanl做同樣的事情,首先使用遞迴,然後使用foldl
  2. 定義以下函式
    • factList :: Integer -> [Integer],它返回從1到它的引數的階乘列表。例如,factList 4 = [1,2,6,24]
更多內容待新增

過濾器

[編輯 | 編輯原始碼]

對列表執行的常見操作是過濾——生成一個新列表,該列表僅包含滿足特定條件的第一個列表中的元素。一個簡單的例子:從整數列表中生成僅包含偶數的列表。

retainEven :: [Int] -> [Int]
retainEven [] = []
retainEven (n:ns) =
-- mod n 2 computes the remainder for the integer division of n by 2.
  if (mod n 2) == 0
    then n : (retainEven ns)
    else retainEven ns

此定義有點冗長且特定。Prelude提供了一個簡潔通用的filter函式,型別簽名如下

filter :: (a -> Bool) -> [a] -> [a]

因此,(a -> Bool)函式測試元素以滿足某個條件,然後我們將要過濾的列表輸入,並返回過濾後的列表。

要使用filter編寫retainEven,我們需要將條件宣告為輔助(a -> Bool)函式,如下所示

isEven :: Int -> Bool 
isEven n = (mod n 2) == 0

然後retainEven就簡單地變成

retainEven ns = filter isEven ns

我們使用ns而不是xs來表明我們知道這些是數字,而不僅僅是任何東西,但我們可以忽略這一點,並使用更簡潔的無點定義

retainEven = filter isEven

這類似於我們之前對map和fold所做的演示。與filter一樣,它們接受另一個函式作為引數;使用它們無點強調了這種“函式的函式”的方面。

列表推導

[編輯 | 編輯原始碼]

列表推導是某些常見列表操作(例如過濾)的語法糖。例如,我們可以這樣編寫retainEven,而不是使用Prelude filter

retainEven es = [n | n <- es, isEven n]

這種簡潔的語法可能看起來令人生畏,但它很容易分解。一種解釋是

  • (從中間開始) 取列表es,並繪製(“<-”)它的每個元素作為值n
  • (在逗號之後) 對於每個繪製的n,測試布林條件isEven n
  • (在豎線之前) 如果(且僅當)布林條件滿足,將n附加到正在建立的新列表中(注意整個表示式周圍的方括號)。

因此,如果es是[1,2,3,4],那麼我們將得到列表[2,4]。1和3沒有被繪製,因為(isEven n) == False

列表推導的強大之處在於它易於擴充套件。首先,我們可以根據需要使用任意數量的測試(即使是零!)。多個條件被寫成逗號分隔的表示式列表(當然,這些表示式應該計算為布林值)。舉個簡單的例子,假設我們想修改retainEven,只保留大於100的數字

retainLargeEvens :: [Int] -> [Int]
retainLargeEvens es = [n | n <- es, isEven n, n > 100]

此外,我們不侷限於使用n作為生成新列表時要附加的元素。相反,我們可以在豎線之前放置任何表示式(當然,它必須與列表的型別相容)。例如,如果我們想要從每個偶數中減去一,只需要

evensMinusOne es = [n - 1 | n <- es, isEven n]

實際上,這意味著列表推導語法包含了mapfilter的功能。

為了使事情更簡單,列表推導中的左箭頭符號可以與模式匹配結合使用。例如,假設我們有一個(Int, Int)元組的列表,並且我們想構造一個列表,其中包含每個元組中第二個元素為偶數的第一個元素。使用列表推導,我們可以這樣寫

firstForEvenSeconds :: [(Int, Int)] -> [Int]
firstForEvenSeconds ps = [fst p | p <- ps, isEven (snd p)] -- here, p is for pairs.

模式可以使其更具可讀性

firstForEvenSeconds ps = [x | (x, y) <- ps, isEven y]

與其他情況一樣,可以在|之前使用任意表達式。如果我們想要一個包含這些第一個元素的兩倍的列表

doubleOfFirstForEvenSeconds :: [(Int, Int)] -> [Int]
doubleOfFirstForEvenSeconds ps = [2 * x | (x, y) <- ps, isEven y]

不計算空格,該函式程式碼比它的描述性名稱更短!

還有更多可能的技巧

allPairs :: [(Int, Int)]
allPairs = [(x, y) | x <- [1..4], y <- [5..8]]

此推導來自兩個列表,並生成所有可能的(x, y)對,其中第一個元素從[1..4]中繪製,第二個元素從[5..8]中繪製。在最終的對列表中,第一個元素將是使用第一個列表的第一個元素(這裡為1)生成的元素,然後是使用第一個列表的第二個元素生成的元素,等等。在這個例子中,完整列表是(為了清晰起見添加了換行符)

Prelude> [(x, y) | x <- [1..4], y <- [5..8]]
[(1,5),(1,6),(1,7),(1,8),
 (2,5),(2,6),(2,7),(2,8),
 (3,5),(3,6),(3,7),(3,8),
 (4,5),(4,6),(4,7),(4,8)]

我們可以輕鬆新增一個條件來限制進入最終列表的組合

somePairs = [(x, y) | x <- [1..4], y <- [5..8], x + y > 8]

此列表只包含元素總和大於8的對;從(1,8)開始,然後是(2,7),依此類推。

練習
  1. 編寫一個returnDivisible :: Int -> [Int] -> [Int]函式,該函式過濾一個整數列表,只保留可以被作為第一個引數傳遞的整數整除的數字。對於整數x和n,如果(mod x n) == 0,則x可以被n整除(注意,偶數測試是這種情況的特殊情況)。
  2. 使用列表推導語法和適當的“保護(過濾器)”編寫一個函式choosingTails :: [[Int]] -> [[Int]],對於空列表,返回一個包含在頭部大於5之後的尾部的列表
    choosingTails  [[7,6,3],[],[6,4,2],[9,4,3],[5,5,5]]
    -- [[6,3],[4,2],[4,3]]
    
  3. 保護的順序重要嗎?你可以透過使用上一個練習中的函式來找出答案。
  4. 在本節中,我們已經瞭解到列表推導本質上是filtermap的語法糖。現在反過來,使用列表推導語法定義filtermap的替代版本。
  5. 使用filtermap而不是列表推導重寫doubleOfFirstForEvenSeconds

筆記

  1. reducereduceRight函式取自JavaScript,它們以這種方式執行。


華夏公益教科書