1. 程式人生 > >Effective Java 第三版讀書筆記——條款 45:明智而謹慎地使用Stream

Effective Java 第三版讀書筆記——條款 45:明智而謹慎地使用Stream

pip resource 如果 ast 批量 統一 發現 lines 控制

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

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

管道延遲(lazily)計算求值:計算直到終結操作被調用後才開始,並且為了完成終結操作而不需要的數據元素永遠不會被計算出來。這種延遲計算求值的方式使得流可以應用於無限流。請註意,沒有終結操作的流管道是靜默無操作的,所以不要忘記包含一個。

Stream API 是流式的(fluent):它被設計為允許所有組成管道的調用被鏈接到一個表達式中。事實上,多個管道可以鏈接在一起形成一個表達式。

默認情況下,流管道按順序(sequentially)運行。使管道並行執行只需要在管道中的任何流上調用 parallel 方法,但這樣做很少合適(條款 48)。

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),
                    (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 對象中,按照字母排列的形式對單詞進行分組。這與之前兩個版本的程序構造的 map 完全相同。然後在 map 的 values() 視圖上打開一個新的流 <List<String>>。當然,這個流中的元素是變位詞組。對流進行過濾,以便忽略大小小於 minGroupSize 的所有組,最後由終結操作 forEach 打印剩下的變位詞組。

請註意,lambda 參數名稱都是仔細選擇的。在沒有顯式類型的情況下,仔細命名 lambda 參數對於流管道的可讀性至關重要

另請註意,單詞字母化是在單獨的 alphabetize 方法中完成的。這通過提供操作名稱並將實現細節保留在主程序之外來增強可讀性。使用輔助方法對於流管道中的可讀性比在叠代代碼中更為重要,因為管道缺少顯式類型信息和有名字的臨時變量。

alphabetize 方法可以使用流重新實現,但基於流的 alphabetize 方法會更不清晰,更難以正確編寫,並且可能更慢。這些缺陷是由於 Java 缺乏對原始 char 流的支持(這並不意味著 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 中,只能讀取最終變量,並且無法修改任何局部變量。
  • 從代碼塊中,可以從封閉方法返回,breakcontinue 一個封閉循環,或拋出聲明此方法的任何已檢查異常;從一個 lambda 中你不能做這些事情。

如果一個計算過程需要使用上面這些技術,那麽它可能不適用於流。相反,流可以很容易地做一些事情:

  • 統一轉換元素序列
  • 過濾元素序列
  • 使用單個操作組合元素序列(例如求和、連接或計算最小值)
  • 將元素序列累積到一個集合中,可能通過一些公共屬性將它們分組
  • 在元素序列中搜索滿足某些條件的元素

如果一個計算過程需要使用上面這些技術,那麽使用流是這些場景很好的選擇。

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

例如,讓我們編寫一個程序來打印前 20 個梅森素數(Mersenne primes)。梅森數是一個形如 \(2^p ? 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 控制概率素性測試),將得到的流限制為 20 個元素, 並打印出來。

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

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

有很多任務不清楚是使用流還是叠代。例如,考慮初始化一副新牌的任務。假設 Card 是一個不可變的值類,它封裝了 RankSuit,它們都是枚舉類型。這個任務代表任何需要計算可以從兩個集合中選擇的所有元素對。數學家們稱它為兩個集合的笛卡爾積(Cartesian product)。下面是一個叠代實現,它有一個嵌套的 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 程序員將能夠理解和維護它,但是一些程序員會對第二個(基於流的)版本感覺更舒服。

總之,有些任務最好使用流來完成,有些任務最好使用叠代來完成。將這兩種方法結合起來,可以最好地完成許多任務。對於選擇使用哪種方法進行任務,沒有硬性規定,但是有一些有用的啟發式方法。在許多情況下,使用哪種方法將是清楚的;在某些情況下,則不會很清楚。如果不確定一個任務是通過流還是叠代來更好地完成,那麽嘗試這兩種方法,看看哪一種效果更好。

Effective Java 第三版讀書筆記——條款 45:明智而謹慎地使用Stream