惰性求值

February 2, 2022

在〈map/filter/fold〉看過 mapfilter 等函式的實現概念,接下來要談 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());

表面看來,mapfilter 似乎得兩次走訪元素,實際上透過了 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 是 1100 的整數值,在 []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 執行。

有時會需要循環計數,例如從 1100,然後又回到 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 來達到!

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