譯註:map(映射)和reduce(歸約,化簡)是數學上兩個很基礎的概念,它們很早就出現在各類的函數程式語言裡了,直到2003年Google將其發揚光大,運用到分散式系統中進行平行計算後,這個組合的名字才開始在電腦界大放異彩(那些函數式粉可能不這麼認為)。本文我們會看到Java 8在搖身一變支援函數式程式設計後,map和reduce組合的首次亮相(這裡只是初步介紹,後續還會有針對它們的專題)。
對集合進行歸約
現在為止我們已經介紹了幾個操作集合的新技巧了:尋找匹配元素,尋找單一元素,集合轉換。這些操作有一個共同點,它們都是對集合中的單一元素進行操作。不需要對元素進行比較,或對兩個元素進行運算。本節我們來看看如何比較元素,以及在遍歷集合過程中動態維護一個運算結果。
我們先從簡單的例子開始,然後再循序漸進。在第一個例子中,我們先來遍歷一下friends集合,計算出所有名字的總字元數。
複製代碼代碼如下:
System.out.println("Total number of characters in all names: " + friends.stream()
.mapToInt(name -> name.length())
.sum());
要算出所有字元的總數我們得知道每個名字的長度。透過mapToInt()方法可以輕鬆的完成這個。當我們已經把名字轉化成了對應的長度之後,最後只需要把它們加到一塊就行了。我們有一個內建的sum()方法來完成這個。下面是最後的輸出:
複製代碼代碼如下:
Total number of characters in all names: 26
我們使用了map運算的一個變種,mapToInt()方法(這種的有mapToInt, mapToDouble等,會對應到產生特定類型的流,例如IntStream,DoubleStream),然後根據傳回的長度計算出總的字元數。
除了使用sum法,還有很多類似的方法可以使用,例如用max()可以求出最大的長度,用min()是最小長度,sorted()對長度進行排序,average()求平均長度,等等。
上述這個例子還有一個吸引人的地方就是現在越來越流行的MapReduce模式,map()方法進行映射,而sum()方法是一個比較常用的reduce操作。事實上,JDK中sum()方法的實作用的就是reduce()方法。我們來看下reduce作業比較常用的一些形式。
比方說,我們遍歷所有的名字,然後印出名字最長的那個。如果最長的名字有好幾個,我們就印出最開始找到的那個。一種方法是,我們計算出最大的長度,然後選出符合這個長度的第一個元素。不過這樣做需要遍歷兩次列表――效率太低了。這正是reduce操作上場的時候了。
我們可以用reduce運算來比較兩個元素的長度,然後回到最長的那個,再和剩下的元素做進一步比較。跟我們之前看到的別的高階函數一樣,reduce()方法同樣也是遍歷了整個集合。除此之外,它還記錄了lambda表達式傳回的計算結果。有個例子的話可以幫助我們更好的理解這一點,那我們先來看一段程式碼吧。
複製代碼代碼如下:
final Optional<String> aLongName = friends.stream()
.reduce((name1, name2) ->
name1.length() >= name2.length() ? name1 : name2);
aLongName.ifPresent(name ->
System.out.println(String.format("A longest name: %s", name)));
傳給reduce()方法的lambda表達式接收兩個參數,name1和name2,它會比較它們的長度,傳回最長的那個。 reduce()方法根本不知道我們要做什麼。這個邏輯被剝離到我們傳遞進去的lambda表達式裡面了――這是策略模式的一個輕量級的實作。
這個lambda表達式正好能適配成JDK中一個BinaryOperator的函數式介面的apply方法。這正是reduce方法要接受的參數型別。讓我們來運行下這個reduce方法,看看它能否正確地在兩個最長的名字中選出第一個。
複製代碼代碼如下:
A longest name: Brian
在reduce()方法遍歷集合的過程中,它先對集合的前兩個元素呼叫了lambda表達式,呼叫傳回的結果繼續用於下一次呼叫。在第二次呼叫中,name1的值被綁定成上次呼叫的結果,name2的值則是集合的第三個元素。剩餘的元素也這樣依序調用下去。最後一次lambda表達式呼叫的結果,就是整個reduce()方法回傳的結果。
reduce()方法傳回的是一個Optional值,因為傳遞給它的集合可能是空的。那樣的話,也不存在什麼最長的名字了。如果列表只有一個元素,reduce方法直接傳回那個元素,不會對lambda表達式進行呼叫。
從這個例子我們可以推論出,reduce的結果最多只可能是集合中的一個元素。如果我們希望能回傳一個預設值或基礎值的話,我們可以使用reduce()方法的一個變種,它可以接收一個額外的參數。例如,如果最短的名字是Steve,我們可以把它傳給reduce()方法,像這樣:
複製代碼代碼如下:
final String steveOrLonger = friends.stream()
.reduce("Steve", (name1, name2) ->
name1.length() >= name2.length() ? name1 : name2);
如果有名字比它長的,那麼這個名字會被選中;否則的話就回傳這個基礎值Steve。這個版本的reduce()方法不會傳回Optional對象,因為如果集合是空的,會回傳一個預設值;不用考慮沒有回傳值的情況。
在我們結束這一章之前,我們再來看一下集合操作裡面一個很基礎的卻又不是那麼容易的操作:合併元素。
合併元素
我們已經學習如何進行元素的查找,遍歷,以及集合的轉換。不過還有一個常見的操作――將集合元素進行拼接――如果沒有這個新加入的join()函數的話,之前說的簡潔和優雅的程式碼只能成為泡影了。這個簡單的方法非常實用以至於它成為JDK裡最常用的函數之一。我們來看下如何用它來列印清單中的元素,並用逗號進行分隔。
我們還是用這個friends列表。如果用JDK庫裡的舊方法的話,想要打印出所有名字並用逗號隔開的話,要做哪些工作?
我們得遍歷列表並且挨個列印元素。 Java 5中的for迴圈比之前的有所改進,我們就用它吧。
複製代碼代碼如下:
for(String name : friends) {
System.out.print(name + ", ");
}
System.out.println();
程式碼很簡單,我們看下它的輸出是什麼。
複製代碼代碼如下:
Brian, Nate, Neal, Raju, Sara, Scott,
該死,最後多出了一個討厭的逗號(我們難道要怪最後的Scott?)。怎麼能讓Java別放一個逗號在這裡呢?不幸的是,循環會按步就班的執行,想讓它在最後特殊處理一下並不容易。為了解決這個問題,我們可以用回原來的那種循環方式。
複製代碼代碼如下:
for(int i = 0; i < friends.size() - 1; i++) {
System.out.print(friends.get(i) + ", ");
}
if(friends.size() > 0)
System.out.println(friends.get(friends.size() - 1));
我們來看下這個版本的輸出是不是OK。
複製代碼代碼如下:
Brian, Nate, Neal, Raju, Sara, Scott
結果還是不錯的,不過這個程式碼就不敢恭維了。救救我們吧,Java。
我們不用再忍受這種痛苦了。 Java 8裡的StringJoiner類別幫我們搞定了這些難題,不只如此,String類別還增加了一個join方法方便我們可以用一行程式碼來取代上面那坨東西。
複製代碼代碼如下:
System.out.println(String.join(", ", friends));
快來看吧,結果跟程式碼一樣令人滿意。
複製代碼代碼如下:
Brian, Nate, Neal, Raju, Sara, Scott
結果還是不錯的,不過這個程式碼就不敢恭維了。救救我們吧,Java。
我們不用再忍受這種痛苦了。 Java 8裡的StringJoiner類別幫我們搞定了這些難題,不只如此,String類別還增加了一個join方法方便我們可以用一行程式碼來取代上面那坨東西。
複製代碼代碼如下:
System.out.println(String.join(", ", friends));
快來看吧,結果跟程式碼一樣令人滿意。
複製代碼代碼如下:
Brian, Nate, Neal, Raju, Sara, Scott
在底層實作中,String.join()方法呼叫了StringJoiner類別來將第二個參數傳進來的值(這是個變長參數)拼接成一個長的字串,並用第一個參數作為分隔符號。這個方法當然不只能拼接逗號這麼簡單了。比方說,我們可以傳入一堆路徑,然後很容易的拼出一個類別路徑(classpath),這真是多虧了這些新增加的方法和類別。
我們已經知道如何去連接列表元素了,在進行列表連接之前,我們還可以先將元素轉化,當然我們也知道如何使用map方法來進行列表轉換了。接下來也可以用filter()方法過濾出我們想要的那些元素。最後一步的連接列表元素,用逗號還是什麼分隔符,不過就是一個簡單的reduce操作而已了。
我們可以用reduce()方法將元素拼接成一個字串,不過這需要我們費點工夫。 JDK有一個十分方便的collect()方法,它也是reduce()的一個變種,我們可以用它來把元素合併成一個想要的值。
collect()方法來執行歸約操作,不過它把具體的操作委託給一個collector來執行。我們可以把轉換後的元素合併成一個ArrayList。繼續剛才那個例子,我們可以將轉換後的元素,拼接成一個用逗號分隔的字串。
複製代碼代碼如下:
System.out.println(
friends.stream()
.map(String::toUpperCase)
.collect(joining(", ")));
我們在轉換後的列表上呼叫了collect()方法,給它傳入了一個joining()方法回傳的collector,joining是Collectors工具類別裡的一個靜態方法。 collector就像是個接收器,它接收collect傳進來的對象,並把它們儲存成你想要的格式:ArrayList, String等。我們會在52頁的collect方法及Collectors類別中進一步探索這個方法。
這是輸出的名字,現在它們是大寫的,並用逗號隔開。
複製代碼代碼如下:
BRIAN, NATE, NEAL, RAJU, SARA, SCOTT
總結
集合在程式設計中十分常見,有了lambda表達式後,Java的集合運算變得更加簡單容易了。那些拖沓的集合操作的舊程式碼都可以換成這種優雅簡潔的新方式。內部迭代器使得集合遍歷,轉換都變得更加方便,遠離了可變性的煩惱,查找集合元素也變得異常輕鬆。使用這些新方法可以少寫不少程式碼。這使得程式碼更容易維護,更聚焦於業務邏輯,程式設計中的那些基本操作也變得更少了。
下一章我們會看到lambda表達式如何簡化程式開發中的另一個基本操作:字串運算以及物件比較。