惰性求值
February 2, 2022在〈map/filter/fold〉看過 map
、filter
等函式的實現概念,接下來要談 Haskell 中重要的特性 - 惰性(Laziness)。先來個簡單的問題,令 plusOne = map (+1)
,如果執行 plusOne $ plusOne $ plusOne [1, 2, 3, 4, 5]
的話,會有什麼結果呢?會得到一個清單 [2, 3, 4, 5, 6]
後,傳給 plusOne
函式再得到最後的結果清單 [4, 5, 6, 7, 8]
嗎?
惰性求值
方才問題的答案是「不!Haskell 在真正要取得結果前,不會執行函式。」
在執行 plusOne $ plusOne $ plusOne [1, 2, 3, 4, 5]
時,最右邊的 plusOne
函式並不會立刻執行,它只會說:「嘿!我知道我該做些什麼,不過待會有需要再做!」第二個 plusOne
也是如此,當最左邊的 plusOne
函式必須對清單第一個元素加 1
時,第二個 plusOne
函式就會要求最右邊的 plusOne
函式傳回一個計算後的元素,當最左邊的 plusOne
函式要對下個元素加 1
時,相同的過程又會再發生一次。
Haskell 是惰性的(Lazy),最終只會走訪一次清單,而不是走訪三個清單,藉此增進效能。
在一些主流語言中,開發者撰寫程式時,遇到迴圈走訪 list 處理的問題時,通常會為了自我想像的效能增進,可以在一次迴圈中處理的動作,就全部塞到一個迴圈,因而經常造成迴圈中的程式碼異常難懂,一次迴圈做了很多事情。
難道不是這樣的嗎?如果要對一個 list 的元素全部加 5
,然後再過濾出大於 100
的值,走一次迴圈就完成,總比先全部加 5
後得到一個 list,再從這 list 中過濾一個 list 出來快吧?
表面上看來是如此,不過實際上,一次處理一件事,之後若需要改進效能,因為程式碼易於閱讀與理解,要從中透過評測與設計改進效能,就能有許多的機會,這比斤斤計較少用幾次迴圈來得好!例如,Java 的 Stream
API,可以這麼做:
List<Integer> result = list.stream()
.map(elem -> elem + 5)
.filter(elem -> elem > 100)
.collect(tolist());
表面看來,map
、filter
似乎得兩次走訪元素,實際上透過了 Stream
的惰性設計,只會走訪一次元素,如果 list 很大,而你的電腦擁有多核 CPU,可以試著將 stream
改為 parallelStream
,嘗試平行處理改進效能的可能性。
Haskell 的惰性是與生俱來的,來看看以下這個範例:
ghci> let lt = [100, 98, 93, 94, 95]
ghci> filter (> 100) $ map (+ 5) lt
[105,103]
ghci>
filter (> 100) $ map (+ 5) lt
不會馬上求值,只有在 ghci
要求要取得 list 以顯示時,filter
才會被要求執行,而此時 filter
才會進一步要求 map
執行。
Haskell 中到處都是函式,因此,就算只是寫個 1 + 2
,也不會馬上執行,例如有個函式 doSome
接受一個值,而你以 addSome (1 + 2)
呼叫,實際上不會馬上計算 3
,只是將運算式 1 + 2
傳入,當 addSome
內部真的需要引數值時,才會執行運算式求得 3
。
無上限的範圍
在〈初探 list 操作〉中,你知道可以使用 [1, 2, 3]
建立 list,也知道其實這不過是 1:2:3:[]
的語法蜜糖,現在如果要有個 list 是 1
到 100
的整數值,在 []
從 1
打到 100
太傻了,也許你想到了,可以寫個 range
函式:
range :: (Ord a, Num a) => a -> a -> [a]
range first last =
if first > last then []
else first : range (first + 1) last
這樣就可以用 range 1 100
來產生你要的 list,好吧!這不過是讓你再練習一次函數式的思考罷了,Haskell 不用自己寫這個函式,只要寫 [1 .. 100]
就可以了,而且可以指定間隔,倒著指定也可以,例如:
ghci> [1 .. 100]
[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100]
ghci> [2, 4 .. 100]
[2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50,52,54,56,58,60,62,64,66,68,70,72,74,76,78,80,82,84,86,88,90,92,94,96,98,100]
ghci> [1, 3 .. 99]
[1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31,33,35,37,39,41,43,45,47,49,51,53,55,57,59,61,63,65,67,69,71,73,75,77,79,81,83,85,87,89,91,93,95,97,99]
ghci> [100, 99 .. 1]
[100,99,98,97,96,95,94,93,92,91,90,89,88,87,86,85,84,83,82,81,80,79,78,77,76,75,74,73,72,71,70,69,68,67,66,65,64,63,62,61,60,59,58,57,56,55,54,53,52,51,50,49,48,47,46,45,44,43,42,41,40,39,38,37,36,35,34,33,32,31,30,29,28,27,26,25,24,23,22,21,20,19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3,2,1]
ghci>
若要產生 'A'
到 'Z'
的字母,也只要寫 ['A' .. 'B']
就可以了,
ghci> ['A' .. 'Z']
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
ghci> ['A', 'C' .. 'Z']
"ACEGIKMOQSUWY"
ghci>
只要 data
型態的值,有實現 Enum
的行為,就可以使用此方式產生 list。有趣的是,如果不指定上限,可以產生一個無限範圍(range)的 list,例如:
ghci> let naturals = [1 ..]
ghci> head naturals
1
ghci> head $ tail naturals
2
ghci> head $ tail $ tail naturals
3
ghci>
沒問題!因為惰性的特性,在真正需要值之前,並不會真正產生內容。你可以試著如上在 ghci
顯示 naturals
的值,可以一直求值下去,沒完沒了!
take/cycle/repeat
沒有上限的範圍可以做什麼?也許你想求得一些數值,像是〈阿姆斯壯數〉:
toDigits :: Integral t => t -> [t]
toDigits num =
if num == 0 then [] else (num `mod` 10) : toDigits (num `div` 10)
isNarcissistic :: Integral a => a -> Bool
isNarcissistic number =
number == (foldl (\sum d -> sum + d ^ (length digits)) 0 digits)
where digits = toDigits number
你想取得 10
個阿姆斯壯數,只是不知道這些數會在哪出現,那麼就可以這麼寫:
ghci> take 10 $ filter isNarcissistic [1 ..]
[1,2,3,4,5,6,7,8,9,153]
ghci> take 10 $ filter isNarcissistic [100 ..]
[153,370,371,407,1634,8208,9474,54748,92727,93084]
ghci> take 10 [1 ..]
[1,2,3,4,5,6,7,8,9,10]
ghci>
take
函式會從 list 取得指定的前幾個元素,因為惰性的關係,如果寫 take 10 [1 ..]
,就算 [1 ..]
代表無限範圍,也只會取得 [1,2,3,4,5,6,7,8,9,10]
。
類似地,take 10 $ filter isNarcissistic [100 ..]
不會馬上對 [1 ..]
執行 filter
,只有 take
函式要求下個數時,才會要求 filter
執行。
有時會需要循環計數,例如從 1
到 100
,然後又回到 1
重新計數至 100
,一直重複下去,這類需求在 Haskell 可以 cycle
函式,給個 list,它會將這個 list 的元素循環地產生,因為惰性的關係,不會馬上求值,搭配 take
的話,像是 take 10 $ cycle [1, 2, 3]
就會產生 [1,2,3,1,2,3,1,2,3,1]
。
如果只是要重複一個數,可以使用 repeat
,例如 repeat 10
會產生無限的 10
,因為惰性,可以搭配其他函式來設定終止條件,像是 take 100 $ repeat 10
,就會產生 100
個元素的 list,每個元素都是 10。
實際上也有個 replicate
函式,take 100 $ repeat 10
這需求,可以使用 replicate 100 10
來達到!