Effective Java 第三版——45. 明智審慎地使用Stream

Tips 《Effective Java, Third Edition》一書英文版已經出版,這本書的第二版想必不少人都讀過,號稱Java四大名著之一,不過第二版2009年出版,到如今已經將近8年的時間,但隨着Java 6,7,8,甚至9的發佈,Java語言發生了深入的變化。 在這裏第一時間翻譯成中文版。供你們學習分享之用。程序員

Effective Java, Third Edition

45. 明智審慎地使用Stream

在Java 8中添加了Stream API,以簡化順序或並行執行批量操做的任務。 該API提供了兩個關鍵的抽象:流(Stream),表示有限或無限的數據元素序列,以及流管道(stream pipeline),表示對這些元素的多級計算。 Stream中的元素能夠來自任何地方。 常見的源包括集合,數組,文件,正則表達式模式匹配器,僞隨機數生成器和其餘流。 流中的數據元素能夠是對象引用或基本類型。 支持三種基本類型:int,long和double。正則表達式

流管道由源流(source stream)的零或多箇中間操做和一個終結操做組成。每一箇中間操做都以某種方式轉換流,例如將每一個元素映射到該元素的函數或過濾掉全部不知足某些條件的元素。中間操做都將一個流轉換爲另外一個流,其元素類型可能與輸入流相同或不一樣。終結操做對流執行最後一次中間操做產生的最終計算,例如將其元素存儲到集合中、返回某個元素或打印其全部元素。編程

管道延遲(lazily)計算求值:計算直到終結操做被調用後纔開始,而爲了完成終結操做而不須要的數據元素永遠不會被計算出來。 這種延遲計算求值的方式使得可使用無限流。 請注意,沒有終結操做的流管道是靜默無操做的,因此不要忘記包含一個。數組

Stream API流式的(fluent)::它設計容許全部組成管道的調用被連接到一個表達式中。事實上,多個管道能夠連接在一塊兒造成一個表達式。安全

默認狀況下,流管道按順序(sequentially)運行。 使管道並行執行就像在管道中的任何流上調用並行方法同樣簡單,但不多這樣作(第48個條目)。app

Stream API具備足夠的通用性,實際上任何計算均可以使用Stream執行,但僅僅由於能夠,並不意味着應該這樣作。若是使用得當,流可使程序更短更清晰;若是使用不當,它們會使程序難以閱讀和維護。對於什麼時候使用流沒有硬性的規則,可是有一些啓發。函數式編程

考慮如下程序,該程序從字典文件中讀取單詞並打印其大小符合用戶指定的最小值的全部變位詞(anagram)組。若是兩個單詞由長度相通,不一樣順序的相同字母組成,則它們是變位詞。程序從用戶指定的字典文件中讀取每一個單詞並將單詞放入map對象中。map對象的鍵是按照字母排序的單詞,所以『staple』的鍵是『aelpst』,『petals』的鍵也是『aelpst』:這兩個單詞就是同位詞,全部的同位詞共享相同的依字母順序排列的形式(或稱之爲alphagram)。map對象的值是包含共享字母順序形式的全部單詞的列表。 處理完字典文件後,每一個列表都是一個完整的同位詞組。而後程序遍歷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](http://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);

    }

}

這個程序中的一個步驟值得注意。將每一個單詞插入到map中(以粗體顯示)中使用了computeIfAbsent方法,該方法是在Java 8中添加的。這個方法在map中查找一個鍵:若是鍵存在,該方法只返回與其關聯的值。若是沒有,該方法經過將給定的函數對象應用於鍵來計算值,將該值與鍵關聯,並返回計算值。computeIfAbsent方法簡化了將多個值與每一個鍵關聯的map的實現。學習

如今考慮如下程序,它解決了一樣的問題,但大量過分使用了流。 請注意,整個程序(打開字典文件的代碼除外)包含在單個表達式中。 在單獨的表達式中打開字典文件的惟一緣由是容許使用try-with-resources語句,該語句確保關閉字典文件:測試

// 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);

        }

    }

}

若是你發現這段代碼難以閱讀,不要擔憂;你不是一我的。它更短,可是可讀性也更差,尤爲是對於那些不擅長使用流的程序員來講。過分使用流使程序難於閱讀和維護

幸運的是,有一個折中的辦法。下面的程序解決了一樣的問題,使用流而不過分使用它們。其結果是一個比原來更短更清晰的程序:

// 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,表示流中的每一個元素都是一個單詞。此流上的管道沒有中間操做;它的終結操做將全部單詞收集到個map對象中,按照字母排列的形式對單詞進行分組(第46項)。這與以前兩個版本的程序構造的map徹底相同。而後在map的values()視圖上打開一個新的流<List<String>>。固然,這個流中的元素是同位詞組。對流進行過濾,以便忽略大小小於minGroupSize的全部組,最後由終結操做forEach打印剩下的同位詞組。

請注意,仔細選擇lambda參數名稱。 上面程序中參數g應該真正命名爲group,可是生成的代碼行對於本書來講太寬了。 在沒有顯式類型的狀況下,仔細命名lambda參數對於流管道的可讀性相當重要

另請注意,單詞字母化是在單獨的alphabetize方法中完成的。 這經過提供操做名稱並將實現細節保留在主程序以外來加強可讀性。 使用輔助方法對於流管道中的可讀性比在迭代代碼中更爲重要,由於管道缺乏顯式類型信息和命名臨時變量。

字母順序方法可使用流從新實現,但基於流的字母順序方法原本不太清楚,更難以正確編寫,而且可能更慢。 這些缺陷是因爲Java缺少對原始字符流的支持(這並不意味着Java應該支持char流;這樣作是不可行的)。 要演示使用流處理char值的危害,請考慮如下代碼:

"Hello world!".chars().forEach(System.out::print);

你可能但願它打印Hello world!,但若是運行它,發現它打印721011081081113211911111410810033。這是由於「Hello world!」.chars()返回的流的元素不是char值,而是int值,所以調用了print的int重載。無能否認,一個名爲chars的方法返回一個int值流是使人困惑的。能夠經過強制調用正確的重載來修復該程序:

"Hello world!".chars().forEach(x -> System.out.print((char) x));

但理想狀況下,應該避免使用流來處理char值

當開始使用流時,你可能會感到想要將全部循環語句轉換爲流方式的衝動,但請抵制這種衝動。儘管這是可能的,但可能會損害代碼庫的可讀性和可維護性。 一般,使用流和迭代的某種組合能夠最好地完成中等複雜的任務,如上面的Anagrams程序所示。 所以,重構現有代碼以使用流,並僅在有意義的狀況下在新代碼中使用它們

如本項目中的程序所示,流管道使用函數對象(一般爲lambdas或方法引用)表示重複計算,而迭代代碼使用代碼塊表示重複計算。從代碼塊中能夠作一些從函數對象中不能作的事情:

•從代碼塊中,能夠讀取或修改範圍內的任何局部變量; 從lambda中,只能讀取最終或有效的最終變量[JLS 4.12.4],而且沒法修改任何局部變量。 •從代碼塊中,能夠從封閉方法返回,中斷或繼續封閉循環,或拋出聲明此方法的任何已檢查異常; 從一個lambda你不能作這些事情。

若是使用這些技術最好地表達計算,那麼它可能不是流的良好匹配。 相反,流能夠很容易地作一些事情: •統一轉換元素序列 •過濾元素序列 •使用單個操做組合元素序列(例如添加、鏈接或計算最小值) •將元素序列累積到一個集合中,可能經過一些公共屬性將它們分組 •在元素序列中搜索知足某些條件的元素

若是使用這些技術最好地表達計算,那麼使用流是這些場景很好的候選者。

對於流來講,很難作到的一件事是同時訪問管道的多個階段中的相應元素:一旦將值映射到其餘值,原始值就會丟失。一種解決方案是將每一個值映射到一個包含原始值和新值的pair對象,但這不是一個使人滿意的解決方案,尤爲是在管道的多個階段須要一對對象時更是如此。生成的代碼既混亂又冗長,破壞了流的主要用途。當它適用時,一個更好的解決方案是在須要訪問早期階段值時轉換映射。

例如,讓咱們編寫一個程序來打印前20個梅森素數(Mersenne primes)。 梅森素數是一個2p − 1形式的數字。若是p是素數,相應的梅森數多是素數; 若是是這樣的話,那就是梅森素數。 做爲咱們管道中的初始流,咱們須要全部素數。 這裏有一個返回該(無限)流的方法。 咱們假設使用靜態導入來輕鬆訪問BigInteger的靜態成員:

static Stream<BigInteger> primes() {

    return Stream.iterate(TWO, BigInteger::nextProbablePrime);

}

方法的名稱(primes)是一個複數名詞,描述了流的元素。 強烈建議全部返回流的方法使用此命名約定,由於它加強了流管道的可讀性。 該方法使用靜態工廠Stream.iterate,它接受兩個參數:流中的第一個元素,以及從前一個元素生成流中的下一個元素的函數。 這是打印前20個梅森素數的程序:

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);

}

這個程序是上面的梅森描述的直接編碼:它從素數開始,計算相應的梅森數,過濾掉除素數以外的全部數字(幻數50控制機率素性測試the magic number 50 controls the probabilistic primality test),將獲得的流限制爲20個元素, 並打印出來。

如今假設咱們想在每一個梅森素數前面加上它的指數(p),這個值只出如今初始流中,所以在終結操做中不可訪問,而終結操做將輸出結果。幸運的是經過反轉第一個中間操做中發生的映射,能夠很容易地計算出Mersenne數的指數。 指數是二進制表示中的位數,所以該終結操做會生成所需的結果:

.forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));

有不少任務不清楚是使用流仍是迭代。例如,考慮初始化一副新牌的任務。假設Card是一個不可變的值類,它封裝了RankSuit,它們都是枚舉類型。這個任務表明任何須要計算能夠從兩個集合中選擇的全部元素對。數學家們稱它爲兩個集合的笛卡爾積。下面是一個迭代實現,它有一個嵌套的for-each循環,你應該很是熟悉:

// 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;

}

下面是一個基於流的實現,它使用了中間操做flatMap方法。這個操做將一個流中的每一個元素映射到一個流,而後將全部這些新流鏈接到一個流(或展平它們)。注意,這個實現包含一個嵌套的lambda表達式(rank -> new Card(suit, rank))):

// 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的兩個版本中哪個更好? 它歸結爲我的偏好和你的編程的環境。 第一個版本更簡單,也許感受更天然。 大部分Java程序員將可以理解和維護它,可是一些程序員會對第二個(基於流的)版本感受更舒服。 若是對流和函數式編程有至關的精通,那麼它會更簡潔,也不會太難理解。 若是不肯定本身喜歡哪一個版本,則迭代版本多是更安全的選擇。 若是你更喜歡流的版本,而且相信其餘使用該代碼的程序員會與你共享你的偏好,那麼應該使用它。

總之,有些任務最好使用流來完成,有些任務最好使用迭代來完成。將這兩種方法結合起來,能夠最好地完成許多任務。對於選擇使用哪一種方法進行任務,沒有硬性規定,可是有一些有用的啓發式方法。在許多狀況下,使用哪一種方法將是清楚的;在某些狀況下,則不會很清楚。若是不肯定一個任務是經過流仍是迭代更好地完成,那麼嘗試這兩種方法,看看哪種效果更好。

相關文章
相關標籤/搜索