Java 開發者的函數式程式設計(6)惰性


English

嗯...先前的文章忽略了函數式程式設計中重要的一個特性 - 惰性(Laziness)。讓我們再次舉 Haskell 為例,為了簡化,令 addOne = map (+1),如果執行 addOne $ addOne $ addOne [1, 2, 3, 4, 5] 的話,會有什麼結果呢?你會先得到一個清單 [2, 3, 4, 5, 6] 後,傳給 addOne 函式再得到一個最後的結果清單 [4, 5, 6, 7, 8] 嗎?不!Haskell 在你真正要取得結果之前,並不會執行函式。
在執行 addOne $ addOne $ addOne [1, 2, 3, 4, 5] 時,最右邊的 addOne 函式並不會立刻執行,它只會說:「嘿!我知道我該做些什麼,不過待會有需要再做!」第二個 addOne 也是如此,當最左邊的 addOne 函式必須對清單第一個元素加 1 時,第二個 addOne 函式就會要求最右邊的 addOne 函式傳回一個計算後的元素,當最左邊的 addOne 函式要對下個元素加 1 時,相同的過程又會再發生一次。Haskell 是惰性的(Lazy),所以最終只會走訪一次清單,而不是走訪三個清單,藉此改進效能。
回到 Java 開發者的函數式程式設計(3),當中撰寫的 mapfilter 方法會立即地(Eagerly)執行對應與過濾。如果你如下定義一個 addOne 方法:
public static List<Integer> addOne(List<Integer> lt) {
    return map(lt, x -> x + 1);
}
執行 addOne(addOne(addOne(list(1, 2, 3, 4, 5)))) 的話,過程中將會產生三個清單,也就是說,對於先前定義的 mapfilter,每次執行過後,都會得到完整對應或過濾後的清單。
來想一個問題,你可能要將某個清單對應為另一個清單,例如有 1000 個整數,在某些情況下,也許實際上只需要對應後清單的第一個元素,目前定義的 map 在這種情況下顯然沒有效率,此時如果可以惰性地執行過濾或對應操作,就會有明顯的效率改進。可以從 Python 中舉個實際的例子,例如,你可能從資料庫中取得一些東西,如下執行對應與過濾操作:
...
for person in map(lambda id : get_person_from_database(id) , ids):
    if(person.luckyNumber == generatedLuckyNumber):
        return person
...
如果使用的是 Python 3,map 函式並不會傳回完整對應過後的清單,實際上在傳回的 map 物件上 _next_ 方法被呼叫時(也就是 for in 迴圈實際上在做的事),get_person_from_database 函式才會被呼叫,如果第一個 personluckyNumber 就與 generatedLuckyNumber 相等,當時的 person 物件就會被傳回,如此就不用再使用 ids 餘下的 id 來呼叫 get_person_from_database 函式,因而可省下不必要的對應操作。
大多數情況下,對應與過濾等操作其實會是取得最終結果前的中介步驟。如果語言本身沒有直接支援惰性,可以設計一個中介物件來做為對應或過濾操作的結果,像是 Python 中的 map 物件。如果想在 Java 中實作這個特性的話,就得討論一些問題,傳回的中介物件該是什麼型態?這些物件從何處產生?
State of the Lambda: Libraries Edition (April 2012) 這篇文章中曾談到,惰性操作的中介物件型態會是 Iterable,而這個物件可從 Iterable 物件上定義的方法產生,如果是這樣的話,就會有以下的撰碼風格:
List<String> names = ...;
names.filter(s -> s.length() < 3)
     .forEach(s -> out.println(s));
不過,Iterable 有循序(Sequentially)迭代其實作物件的意涵,在上頭定義一堆像是 filtermap 的方法,也會令 Iterable 肩負過多職責,還有個問題是,如上的撰碼風格也會令人有疑問,filter 方法會立即求值嗎?還是隋性?或甚至是平行化(Parallel)處理?如果不察看 API 文件的話,單看程式碼並不會知曉。
隱含(Implicit)並不是 Java 的風格。在 State of the Lambda: Libraries Edition (November 2012) 這篇文章中提到,JDK8 定義了 Stream 介面,其中定義了許多中介操作的行為,對於資料處理問題,Collection 上定義的 stream 方法可用來產生 Stream 實例,所以在 JDK8 中,必須如下撰寫程式:
List<String> names = ...;
names.stream()
     .filter(s -> s.length() < 3)
     .forEach(s -> out.println(s));
這邊的 stream 方法會傳回循序處理的 Stream 物件,這個物件會將原 Collection 做為來源,Stream 物件上定義了 mapfilter 之類的方法。Collection 上也定義了一個 parallelStream 方法,這個方法傳回的 Stream 會以原 Collection 做為來源進行(可能的)平行處理。循序的 Stream 實例也可以使用 parallel 方法傳回可進行平行處理的物件,平行的 Stream 實例也可以使用 sequential 方法傳回可進行循序處理的物件。Java 想要的,就是讓操作可以明確。
終於來到「Java 開發者的函數式程式設計」系列尾聲了,在不瞭解函數式程式設計的情況下,是否有能力運用 JDK8 的 Lambda 等特性呢?當然可以!只不過,如果瞭解函數式程式設計的話,就可以更加明瞭如何善用 JDK8 的 Lambda 等特性。
這一路下來,其實有許多觀念是可以同時套用在命令式及函數式的程式設計上,實際上,許多語言現在都支援多典範(Multi-paradigm)程式設計,即使 Java 是命令式語言、支援抽象資料型態、提供可變的變數及物件也不例外。唯一的問題就是,你是否有能力掌控這些特性?或者說,你可否瞭解這些來自於函數式程式設計的概念之真正意涵呢?
所以了...為何該重視函數式程式設計?!