使用Java 8 Stream像操作SQL一樣處理資料(下)

liuyatao發表於2018-01-22

上一篇文章中,我們介紹了Stream可以像運算元據庫一樣來操作集合,但是我們沒有介紹flatMapcollect操作。這兩種操作對實現複雜的查詢是非常有用的。比如你可以結果flatMapcollect計算stream中的單詞的字元數,像下面程式碼那樣。

import static java.util.function.Function.identity;
import static java.util.stream.Collectors.*;

Stream<String> words = Stream.of("Java", "Magazine", "is", "the", "best");

Map<String, Long> letterToCount =words.map(w -> w.split(""))
                .flatMap(Arrays::stream)
                .collect(groupingBy(identity(), counting()));
複製程式碼

上述程式碼的執行結果是:

 [a:4, b:1, e:3, g:1, h:1, i:2, ..]
複製程式碼

這篇文章將會介紹flatMap和collect這兩種操作的更多細節。

flatMap操作

假設你在一個文章中查詢一個單詞,你會怎麼做?

我們可以使用Files.lines()方法,因為它可以返回一個文章一行一行資訊組成的stream。我們可以使用map()把文章的每行分割是很多單詞,最後,使用`distinct()``移除重複的。我們將想法轉化為程式碼:

Files.lines(Paths.get("stuff.txt"))
              .map(line -> line.split("\\s+")) 
              .distinct() // Stream<String[]>
              .forEach(System.out::println);
複製程式碼

很不幸,這樣並不正確。如果你執行得到這樣的結果:

[Ljava.lang.String;@7cca494b
[Ljava.lang.String;@7ba4f24f
…
複製程式碼

到底發生了什麼事呢?問題出在使用的lambda表示式將會把檔案的每行轉化成一個字串陣列(String[])。這就導致map返回的是一個Stream<String[]>型別的結果,我們實際上需要的是一個Stream型別的結果。

我們需要一串的單詞,而不是一串的陣列。對於陣列可以使用Arrays.stream()將陣列變成一個stream。看下面的實現:

String[] arrayOfWords = {"Java", "Magazine"};
Stream<String> streamOfwords = Arrays.stream(arrayOfWords);
複製程式碼

如果我們使用下面方式的話其實還有不起作用的,這是因為使用map(Arrays::stream)後返回的其實是Stream<Stream>型別。

Files.lines(Paths.get("stuff.txt"))
            .map(line -> line.split("\\s+")) // Stream<String[]>
            .map(Arrays::stream) // Stream<Stream<String>>
            .distinct() // Stream<Stream<String>>
            .forEach(System.out::println);
複製程式碼

我們可以使用flatMap來解決這種問題,像下面這樣。使用flatMap方法的作用是返回的是stream中的內容而不是一個stream。

Files.lines(Paths.get("stuff.txt"))
            .map(line -> line.split("\\s+")) // Stream<String[]>
            .flatMap(Arrays::stream) // Stream<String>
            .distinct() // Stream<String>
            .forEach(System.out::println);
複製程式碼

collect 操作

我們來具體看一下collect操作。上面文章中看到了返回stream的操作(說明該操作是一箇中間操作)和返回一個值、boolean型值、int型值和Optional型值的操作(說明該操作是終結操作) 。

將Stream中的元素轉化到集合中.

使用toSet()你可以把一個stream轉化成一個不包含重複項的集合。下面的程式碼展示了怎麼生成高消費(單筆交易>1000$)城市的集合。

Set<String> cities = transactions.stream()
                   .filter(t -> t.getValue() > 1000)
                   .map(Transaction::getCity)
                   .collect(toSet());
複製程式碼

注意這樣你不能保證返回什麼型別的Set,你可以使用toCollection()來提高可控性。比如你可以像下面程式碼這樣將一個HashSet的構造方法作為引數。

Set<String> cities = transactions.stream()
                    .filter(t -> t.getValue() > 1000)
                    .map(Transaction::getCity)
                    .collect(toCollection(HashSet::new));

複製程式碼

collect操作方法不止這些,上面介紹的只是很小一部分,還可以實現這些功能:

  • 通過貨幣型別進行分組,計算各種獲取型別的交易總金額(將會返回一個 Map<Currency, Integer>)

  • 將所有交易分類兩組:大金額的和非大金額的(將會返回一個Map<Boolean, List>)

  • 建立多級分組,比如先根據城市分組,然後再根據是否為大金額交易分組( 將會返回一個Map<String, Map<Boolean, List>>)

讓我們看一下Stream API和集合器怎麼實現這些查詢,我們先對一個stream中的資料進行計算平均值,最大值和最小值。接下來我們再看如果實現簡單的分組,最後我們我們將多個集合器放在一起實現強大的查詢功能,比如多級分組。

Summarizing

有很多預定義的集合器和是很方便的使用,比如使用counting() 計算個數:

long howManyTransactions = transactions.stream().collect(counting());
複製程式碼

你可以對Double, Int, 或者Long屬性的元素進行 summing Double(), summingInt(), and summingLong() 操作,像下面這樣:

int totalValue = transactions.stream().collect(summingInt(Transaction::getValue));
複製程式碼

類似的你還可以使用averagingDouble(), averagingInt(), and averagingLong() 計算平均值,像下面這樣:

double average = transactions.stream().collect(averagingInt(Transaction::getValue));
複製程式碼

還可以通過使用maxBy()和minBy()計算元素中的最大值和最小值,不過你需要定義一個做比較的 比較器,所以maxBy和minBy需要一個Comparator物件最為引數:

下面的例子中我們使用了靜態方法comparing(),它將根據傳遞進去的引數生成一個Comparator物件。這個方法根據提取stream中元素的可以做比較的key來做判斷。在這個例子中是通過銀行交易的金額大小來做比較的。

Optional<Transaction> highestTransaction = transactions.stream()
                .collect(maxBy(comparing(Transaction::getValue)));
複製程式碼

還有一個叫reducing()的集合器,它可以通過重複地對stream中的所有元素進行一種操作指導產生一個結果。它和reduce()有點類似。比如下面的程式碼使用reducing()方法計算交易的總金額。

int totalValue = transactions.stream().collect(reducing(0, Transaction::getValue, Integer::sum));
複製程式碼

reducing() 有三個引數:

  • 初始值(如果stream是空也將返回該值):這裡是0
  • 一個會被應用到各個元素的方法
  • 結合兩個提取出來的值,這是是將兩個值加起來

Grouping

一個常規的資料庫操作就是根據一個屬性對資料進行分組。比如根據貨幣對交易進行分組,如果使用迭代那簡直太複雜了:

Map<Currency, List<Transaction>> transactionsByCurrencies = new HashMap<>();

for(Transaction transaction : transactions) { 
        Currency currency = transaction.getCurrency();
        List<Transaction> transactionsForCurrency = transactionsByCurrencies.get(currency);
        if (transactionsForCurrency == null) {
            transactionsForCurrency = new ArrayList<>();
            transactionsByCurrencies.put(currency, transactionsForCurrency);
        }
        transactionsForCurrency.add(transaction);
}
複製程式碼

Java 8 中有一個叫groupingBy()的集合器,我們可以像這樣做查詢:

Map<Currency, List<Transaction>> transactionsByCurrencies =
    transactions.stream().collect(groupingBy(Transaction::getCurrency));
複製程式碼

groupingBy() 方法有一個提取分類key的函式做引數,我們可以叫它為分類函式。在這個例子中我們使用的是Transaction::getCurrency來實現根據貨幣分組。

Partitioning

還有一個叫做partitioningBy()的函式,這個可以看做是groupingBy()的特例。它需要一個predicate(返回一個boolean的函式)作為引數,將會對stream中的元素根據是否滿足predicate進行分類。partitioning可以將stream變成一個 Map<Boolean, List>。使用程式碼如下:

Map<Boolean, List<Transaction>> partitionedTransactions =transactions.stream().collect(partitioningBy( t -> t.getValue() > 1000));
複製程式碼

如果要要對不同貨幣的金額進行求和操作在SQL中可以結合使用SUM和GROUP BY。那我們使用Stream API也能這麼做嗎?當然可以了,像下面這樣使用:

Map<String, Integer> cityToSum = transactions.stream()
                                .collect(groupingBy(Transaction::getCity, summingInt(Transaction::getValue)));
複製程式碼

之前使用的groupingBy (Transaction::getCity)其實是groupingBy (Transaction::getCity, toList())的速寫方式。

再看一個例子,如果你要統計每個城市的交易最大值,可以做這樣實現:

Map<String, Optional<Transaction>> cityToHighestTransaction = 
           transactions.stream().collect(groupingBy(
             Transaction::getCity, maxBy(comparing(Transaction::getValue))));
複製程式碼

再看一個更加複雜的例子,在剛才的例子中我們給groupingBy傳遞了另外一個集合器作為引數來進一步對元素進行分組。由於groupingBy本身是一個集合器,我們可以通過傳遞其他groupingBy集合器來建立多級分組,被傳遞進來的這個groupingBy定義了一個二級標準可以對stream中的元素進行再分組。

下面程式碼中我們先對城市進行分組,然後我們再根據每個城市的交易不同貨幣的平均值進行分組

Map<String, Map<Currency, Double>> cityByCurrencyToAverage = 
        transactions.stream().collect(groupingBy(Transaction::getCity,groupingBy(Transaction::getCurrency, averagingInt(Transaction::getValue))));

複製程式碼

自定義集合器

我們看到的這些集合器都實現了java.util.stream .Collector介面。這就意味著你可以自定義集合器。

總結

這篇文章中,我們探索了兩個Stream API的高階操作:flatMap和colelct。通過這兩個操作你可以建立更加複雜的資料處理查詢。

我們還通過collect方法實現了summarizing, grouping, 和 partitioning 操作。這些操作還可以被結合起來建立更加複雜的查詢。

最後

感謝閱讀,有興趣可以關注微信公眾賬號獲取最新推送文章。

歡迎關注微信公眾號
歡迎關注微信公眾號

相關文章