ITEM 45: USE STREAMS JUDICIOUSLY
??streams API 是在 Java 8 中添加的砌们,它簡化了按順序或并行執(zhí)行批量操作的任務(wù)。該 API 提供了兩個關(guān)鍵的抽象: stream (表示有限或無限的數(shù)據(jù)元素序列)和 stream pipeline (表示這些元素上的多級計算)。流中的元素可以來自任何地方辉川。常見的源包括集合、數(shù)組、文件硫眯、正則表達式模式匹配器、偽隨機數(shù)生成器和其他流择同。流中的數(shù)據(jù)元素可以是對象引用或原語值两入。支持三種基本類型: int、long和double敲才。
??流管道由源流裹纳、零或多個中間操作和一個終端操作組成。每個中間操作以某種方式轉(zhuǎn)換流紧武,例如將每個元素映射到該元素的一個函數(shù)剃氧,或者過濾掉不滿足某個條件的所有元素。中間操作都將一個流轉(zhuǎn)換為另一個流阻星,其元素類型可能與輸入流相同朋鞍,也可能與輸入流不同。終端操作對最后一個中間操作產(chǎn)生的流執(zhí)行最后一次計算,例如將其元素存儲到一個集合中番舆、返回某個元素或打印其所有元素酝碳。
??流管道的計算是延遲的:直到調(diào)用終端操作才開始計算,并且永遠不會計算完成終端操作所需的數(shù)據(jù)元素恨狈。這種惰性的計算使處理無限流成為可能疏哗。請注意,沒有終端操作的流管道是靜默的no-op禾怠,因此不要忘記包含一個返奉。
??streams API是連貫的:它被設(shè)計成允許組成管道的所有調(diào)用都鏈接到一個表達式中。實際上吗氏,可以將多個管道鏈接到一個表達式中芽偏。默認情況下,流管道按順序運行弦讽。使管道并行執(zhí)行與在管道中的任何流上調(diào)用并行方法一樣簡單污尉,但很少適合這樣做(item 48)。
??streams API 具有足夠的通用性往产,實際上任何計算都可以使用 streams 執(zhí)行被碗,但這并不意味著應(yīng)該這樣做。如果使用得當(dāng)仿村,流可以使程序更短锐朴、更清晰得哆;如果使用不當(dāng)饿幅,它們會使程序難于閱讀和維護斤葱。對于何時使用流沒有嚴格的規(guī)則其爵,但是有一些啟發(fā)剔宪。
??考慮下面的程序嘿悬,它從字典文件中讀取單詞并打印大小滿足用戶指定的最小值的所有字謎組齿兔。記住腐泻,如果兩個單詞由相同的字母以不同的順序組成滴肿,它們就是字謎岳悟。該程序從用戶指定的字典文件中讀取每個單詞,并將這些單詞放入地圖中泼差。地圖鍵是字母按字母順序排列的單詞贵少,因此 "staple" 的 key 是 “aelpst”,“petals” 的鍵也是 “aelpst”:這兩個單詞是字謎堆缘,所有的字謎都有相同的字母順序形式(有時也稱為字母組合)滔灶。map 的 value 是一個包含所有共享字母格式的單詞的列表。處理完字典之后吼肥,每個列表都是一個完整的字謎組录平。然后麻车,程序遍歷 map 的 values() 視圖,并打印大小滿足閾值的每個列表:
// Prints all large anagram groups in a dictionary iteratively
public class Anagrams {
public static void main(String[] args) throws IOException {
File dictionary = new File(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
Map<String, Set<String>> groups = new HashMap<>();
try (Scanner s = new Scanner(dictionary)) {
while (s.hasNext()) {
String word = s.next();
groups.computeIfAbsent(alphabetize(word), (unused) -> new TreeSet<>()).add(word);
}
}
for (Set<String> group : groups.values())
if (group.size() >= minGroupSize)
System.out.println(group.size() + ": " + group);
}
private static String alphabetize(String s) {
char[] a = s.toCharArray(); Arrays.sort(a);
return new String(a);
}
}
??注意代碼中的這個步驟:將每個單詞插入到映射中斗这,使用的是 computeIfAbsent 方法动猬,該方法是在 Java 8 中添加的。此方法在映射中查找鍵:如果鍵存在表箭,則該方法僅返回與之關(guān)聯(lián)的值赁咙。如果不是,則該方法通過將給定的函數(shù)對象應(yīng)用于鍵來計算一個值免钻,將該值與鍵關(guān)聯(lián)起來彼水,并返回計算后的值。computeIfAbsent 方法簡化了將多個值與每個鍵關(guān)聯(lián)的映射的實現(xiàn)极舔。
??現(xiàn)在考慮下面的程序凤覆,它解決了相同的問題,但是大量使用了流拆魏。注意盯桦,除打開字典文件的代碼外,整個程序都包含在一個表達式中稽揭。字典以單獨的表達式打開的唯一原因是允許使用 try-with-resources 語句俺附,這確保了字典文件是關(guān)閉的:
// Overuse of streams - don't do this!
public class Anagrams {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(groupingBy(word -> word.chars().sorted()
.collect(StringBuilder::new,(sb, c) -> sb.append((char) c), StringBuilder::append).toString()))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.map(group -> group.size() + ": " + group)
.forEach(System.out::println);
}
}
}
??如果您覺得這段代碼很難讀,不要擔(dān)心溪掀;你不是一個人。它的確更短步鉴,但是可讀性也更差揪胃,特別是對于那些不擅長使用流的程序員來說。過度使用流使得程序難以閱讀和維護氛琢。
??幸運的是喊递,有一個折中的辦法。下面的程序解決了相同的問題阳似,使用流而不過度使用它們骚勘。結(jié)果是一個程序比原來的更短,更清晰:
// Tasteful use of streams enhances clarity and conciseness
public class Anagrams {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(groupingBy(word -> alphabetize(word)))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.forEach(g -> System.out.println(g.size() + ": " + g));
}
}
// alphabetize method is the same as in original version
}
??即使您以前很少接觸流撮奏,這個程序也不難理解俏讹。它在一個 try-with-resources 塊中打開字典文件,獲得一個由文件中的所有行組成的流畜吊。流變量被命名為 words泽疆,表示流中的每個元素都是一個單詞。該流上的管道沒有中間操作;它的終端操作將所有單詞收集到一個地圖中玲献,然后按字母順序?qū)卧~分組(item 46)殉疼。這與在程序的前兩個版本中構(gòu)造的映射完全相同梯浪。然后在 ma p的 values() 視圖上打開一個新的流。當(dāng)然瓢娜,這個流中的元素是字謎組挂洛。對流進行過濾,以便忽略大小小于 minGroupSize 的所有組眠砾,最后抹锄,通過終端操作 forEach 打印剩余的組。
??注意荠藤,lambda 參數(shù)名是經(jīng)過仔細選擇的伙单。參數(shù) g 實際上應(yīng)該命名為 group,但是結(jié)果代碼行對于本書來說太寬了哈肖。在缺乏顯式類型的情況下吻育,小心命名lambda參數(shù)對于流管道的可讀性至關(guān)重要。
??還要注意淤井,單詞的字母化是在單獨的字母化方法中完成的布疼。這通過為操作提供一個名稱并將實現(xiàn)細節(jié)排除在主程序之外來增強可讀性。對于流管道中的可讀性來說币狠,使用輔助方法甚至比在迭代代碼中更重要游两,因為管道缺乏顯式的類型信息和命名的臨時變量。
可以重新實 現(xiàn)alphabetize 方法來使用 streams漩绵,但是基于 streams 的 alphabetize 方法不太清晰贱案,難于正確書寫,速度可能更慢止吐。這些缺陷是由于 Java 缺乏對原始 char 流的支持(這并不意味著 Java 應(yīng)該支持 char 流;這樣做是不可行的宝踪。為了演示使用流處理 char 值的危害,請考慮以下代碼:
"Hello world!".chars().forEach(System.out::print);
??您可能希望它打印Hello world!碍扔,但是如果運行它瘩燥,您會發(fā)現(xiàn)它打印 721011081081113211911111410810033。這是因為 “Hello world!”.chars()返回的流的元素不是 char 值不同,而是 int 值厉膀,所以會調(diào)用 print 的 int 重載。不可否認二拐,一個名為 chars 的方法返回一個 int 值流是令人困惑的服鹅。你可以修復(fù)程序使用強制調(diào)用正確的重載:
"Hello world!".chars().forEach(x -> System.out.print((char) x));
??但是理想情況下,您應(yīng)該避免使用流來處理char值卓鹿。
??當(dāng)你開始使用流的時候菱魔,你可能會有把所有的循環(huán)都轉(zhuǎn)換成流的沖動,但是要抵制這種沖動吟孙。雖然這是可能的澜倦,但它可能會損害代碼庫的可讀性和可維護性聚蝶。一般來說,即使是中等復(fù)雜的任務(wù)藻治,也最好使用流和迭代的組合來完成碘勉,如上面的 Anagrams 程序所示。因此桩卵,重構(gòu)現(xiàn)有??代碼以使用流验靡,并僅在有意義的地方在新代碼中使用它們。
如本項目中的程序所示雏节,流管道使用函數(shù)對象(通常是 lambdas 或方法引用)表示重復(fù)計算胜嗓,而迭代代碼使用代碼塊表示重復(fù)計算。有些事情??你可以從代碼塊做钩乍,但你不能從函數(shù)對象做:
?從代碼塊中辞州,您可以讀取或修改范圍內(nèi)的任何局部變量;對于 lambda寥粹,您只能讀取 final 或有效的 final 變量[JLS 4.12.4]变过,并且不能修改任何局部變量。
?從代碼塊中涝涤,您可以從封閉方法返回媚狰,中斷或繼續(xù)封閉循環(huán),或拋出聲明該方法要拋出的任何已檢查的異常阔拳;從一個 lambda 你不能做這些事情崭孤。
??以上場景最好使用函數(shù)對象。相反衫生,流使做某些事情變得非常容易:
? 變換元素序列
? 過濾元素序列
? 使用單個操作組合元素序列(例如添加裳瘪、連接或計算它們的最小值)
? 將元素序列累積到一個集合中,可能根據(jù)某個公共屬性將它們分組
? 在元素序列中搜索滿足某些條件的元素
??以上場景中罪针,流的一個很好的候選方法。
??流很難同時訪問來自管道的多個階段的對應(yīng)元素:一旦將一個值映射到其他值黄伊,原始值就會丟失泪酱。一種解決方法是將每個值映射到包含原始值和新值的 pair 對象,但這不是一個令人滿意的解決方案还最,尤其是在管道的多個階段都需要 pair 對象的情況下墓阀。產(chǎn)生的代碼混亂而冗長,這破壞了流的主要目的拓轻。當(dāng)它適用時斯撮,更好的解決方法是在需要訪問早期值時反轉(zhuǎn)映射。
例如扶叉,讓我們編寫一個程序來打印前20個梅森素數(shù)勿锅。提醒你一下帕膜,梅森數(shù)是2p - 1的形式。如果p是素數(shù)溢十,相應(yīng)的梅森數(shù)可能是素數(shù)垮刹;如果它是素數(shù),那就是梅森素數(shù)张弛。作為管道中的初始流荒典,我們需要所有的質(zhì)數(shù)。這里有一個方法來返回那個(無限)流吞鸭。我們假設(shè)使用了一個靜態(tài)導(dǎo)入來方便地訪問 BigInteger 的靜態(tài)成員:
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
??方法的名稱(素數(shù))是一個復(fù)數(shù)名詞寺董,用于描述流的元素。強烈建議對所有返回流的方法使用這種命名約定刻剥,因為它增強了流管道的可讀性遮咖。該方法使用 Stream.iterate,它接受兩個參數(shù):流中的第一個元素透敌,以及一個用于從前一個元素生成流中的下一個元素的函數(shù)盯滚。下面是打印前20個梅森素數(shù)的程序:
public static void main(String[] args) {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(System.out::println);
}
??這個程序?qū)ι厦娴拿枋鲞M行了簡單的編碼:它從素數(shù)開始,計算相應(yīng)的梅森數(shù)酗电,過濾掉除素數(shù)以外的所有數(shù)(魔數(shù)50控制著概率素數(shù)測試)魄藕,將結(jié)果流限制為20個元素,并將它們打印出來撵术。
??現(xiàn)在假設(shè)我們要在每個Mersenne 之前加上它的指數(shù)(p)背率。這個值只出現(xiàn)在初始流中,所以在輸出結(jié)果的終端操作中是不可訪問的嫩与。幸運的是寝姿,通過反轉(zhuǎn)第一個中間操作中發(fā)生的映射,很容易計算出梅森數(shù)的指數(shù)划滋。指數(shù)只是二進制表示中的位數(shù)饵筑,所以這個終端操作產(chǎn)生了想要的結(jié)果:
.forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));
??在許多任務(wù)中,使用流還是迭代并不明顯处坪。例如根资,考慮初始化一副新紙牌的任務(wù)。假設(shè) Card 是一個不可變的值類同窘,它封裝了 Rank 和 Suit玄帕,它們都是 enum 類型。此任務(wù)代表需要計算可從兩個集合中選擇的所有元素對的任何任務(wù)想邦。數(shù)學(xué)家稱之為兩個集合的笛卡爾積裤纹。這是一個迭代實現(xiàn)嵌套的 for-each 循環(huán),你應(yīng)該很熟悉:
// Iterative Cartesian product computation
private static List<Card> newDeck() {
List<Card> result = new ArrayList<>();
for (Suit suit : Suit.values())
for (Rank rank : Rank.values())
result.add(new Card(suit, rank));
return result;
}
??這里是一個基于流的實現(xiàn)丧没,它利用了中間操作平面映射鹰椒。此操作將流中的每個元素映射到一個流锡移,然后將所有這些新流連接到單個流中(或?qū)⑺鼈儔罕?。注意這個實現(xiàn)包含一個嵌套的 lambda:
// Stream-based Cartesian product computation
private static List<Card> newDeck() {
return Stream.of(Suit.values())
.flatMap(suit -> Stream.of(Rank.values())
.map(rank -> new Card(suit, rank)))
.collect(toList());
}
??兩個版本的 newDeck 哪個更好吹零?這可以歸結(jié)為個人偏好和編程環(huán)境罩抗。第一個版本更簡單,可能感覺更自然灿椅。大部分 Java 程序員將能夠理解并維護它套蒂,但是有些程序員對第二個(基于流的)版本會感到更舒服。如果您相當(dāng)精通流和函數(shù)式編程茫蛹,那么它會更簡明一些操刀,也不會太難理解。如果您不確定您更喜歡哪個版本婴洼,迭代版本可能是更安全的選擇骨坑。如果您更喜歡流版本,并且相信其他使用該代碼的程序員也會與您有相同的偏好柬采,那么您應(yīng)該使用它欢唾。
??總之,有些任務(wù)最好通過流來完成粉捻,而有些任務(wù)則通過迭代來完成礁遣。許多任務(wù)最好通過結(jié)合這兩種方法來完成。對于選擇任務(wù)使用哪種方法沒有嚴格的規(guī)則肩刃,但是有一些有用的啟發(fā)方法祟霍。在許多情況下,使用哪種方法是很清楚的盈包;在某些情況下沸呐,它不會。如果您不確定一個任務(wù)是使用流更好還是使用迭代更好呢燥,請同時嘗試這兩種方法崭添,看看哪種效果更好。