java 用流收集數據

前言

我們已經在前面兩篇文章中用過 collect 終端操作了,當時主要是用來把 Stream 中所有的 元素結合成一個 List 。在本章中,你會發現 collect 是一個歸約操作,就像 reduce 一樣可以接 受各種做法作爲參數,將流中的元素累積成一個彙總結果。具體的做法是通過定義新的 Collector 接口來定義的,因此區分 Collection 、 Collector 和 collect 是很重要的。

下面是一個交易列表的例子,看看你用collect和收集器能做什麼

  • 對一個交易列表按貨幣分組,獲得該貨幣的所有交易額總和(返回一個 Map<Currency,Integer> )。

  • 將交易列表分成兩組:貴的和不貴的(返回一個 Map<Boolean, List> )。

  • 創建多級分組,比如按城市對交易分組,然後進一步按照貴或不貴分組(返回一個Map<Boolean, List> )。

現在我們有一個 由Transtraction 構成的列表 transactions

  Trader raoul = new Trader("Raoul", "Cambridge");
        Trader mario = new Trader("Mario", "Milan");
        Trader alan = new Trader("Alan", "Cambridge");
        Trader brian = new Trader("Brian", "Cambridge");

   List<Transaction> transactions = Arrays.asList(
                new Transaction(Currency.EUR, 1500.0,brian),
                new Transaction(Currency.USD, 2300.0,raoul),
                new Transaction(Currency.GBP, 9900.0,mario),
                new Transaction(Currency.EUR, 1100.0,alan),
                new Transaction(Currency.JPY, 7800.0,brian),
                new Transaction(Currency.CHF, 6700.0,brian),
                new Transaction(Currency.EUR, 5600.0,mario),
                new Transaction(Currency.USD, 4500.0,mario),
                new Transaction(Currency.CHF, 3400.0,brian),
                new Transaction(Currency.GBP, 3200.0,alan),
                new Transaction(Currency.USD, 4600.0,alan),
                new Transaction(Currency.JPY, 5700.0,alan),
                new Transaction(Currency.EUR, 6800.0,raoul) );

    }

我們先看一個例子:把列表中的交易按貨幣進行分組.

在沒有Lambda的Java裏,哪怕像這種簡單的用例實現起來都很囉嗦,就像下面這樣。

//用指令式風格對交易按照貨幣分組
    @Test
    public void test2() {
        Map<Currency, List<Transaction>> transactionsByCurrencies =
                new HashMap<>();
        List<Transaction> transactionsForCurrency = null;
        for (Transaction transaction : transactions) {
            Currency currency = transaction.getCurrency();
            transactionsForCurrency = transactionsByCurrencies.get(currency);
            if (transactionsForCurrency == null) {
                transactionsForCurrency = new ArrayList<>();
                transactionsByCurrencies
                        .put(currency, transactionsForCurrency);
            }
            transactionsForCurrency.add(transaction);
        }
        //遍歷map
        for (Currency currency : transactionsByCurrencies.keySet()) {
            List<Transaction> transactions = transactionsByCurrencies.get(currency);
            System.out.println(transactions);

        }


    }

結果:

[Transaction{currency=EUR, value=1500.0, trader=Trader{name='Brian', city='Cambridge'}}, Transaction{currency=EUR, value=1100.0, trader=Trader{name='Alan', city='Cambridge'}}, Transaction{currency=EUR, value=5600.0, trader=Trader{name='Mario', city='Milan'}}, Transaction{currency=EUR, value=6800.0, trader=Trader{name='Raoul', city='Cambridge'}}]
[Transaction{currency=USD, value=2300.0, trader=Trader{name='Raoul', city='Cambridge'}}, Transaction{currency=USD, value=4500.0, trader=Trader{name='Mario', city='Milan'}}, Transaction{currency=USD, value=4600.0, trader=Trader{name='Alan', city='Cambridge'}}]
[Transaction{currency=CHF, value=6700.0, trader=Trader{name='Brian', city='Cambridge'}}, Transaction{currency=CHF, value=3400.0, trader=Trader{name='Brian', city='Cambridge'}}]
[Transaction{currency=GBP, value=9900.0, trader=Trader{name='Mario', city='Milan'}}, Transaction{currency=GBP, value=3200.0, trader=Trader{name='Alan', city='Cambridge'}}]
[Transaction{currency=JPY, value=7800.0, trader=Trader{name='Brian', city='Cambridge'}}, Transaction{currency=JPY, value=5700.0, trader=Trader{name='Alan', city='Cambridge'}}]

就上面的代碼而言,不要說寫,就是看懂也需要很長時間,不得不承認,我們爲了一個簡單的功能,竟然寫了這麼多且又不宜讀的代碼.如果沒有註釋,代碼的目的一時半會兒很難看出來.

那麼對於 把列表中的交易按貨幣分組 這個簡單的功能,用Stream的collect方法怎麼實現呢?先看一個例子,後面會詳解:

 @Test
    public  void test3(){
        Map<Currency, List<Transaction>> listMap = transactions.stream()
                .collect(Collectors.groupingBy(Transaction::getCurrency));

        //遍歷map
        for(Currency currency:listMap.keySet()){

            System.out.println(listMap.get(currency));
        }
    }

1 收集器(Collector)簡介

上面的例子清晰的說明了函數式編程相對於指令式編程的優勢:你只需要指出希望的結果(做什麼),而不用操心執行的步驟(怎麼做).

一般來說,Collector會對每一個元素應用一個轉換函數(如:toList()),將結果累積在一個數據結構中,從而產生這一過程的最終輸出.

例如:在上面交易分組的例子中:轉換函數提取了每筆交易的貨幣,隨後使用貨幣作爲鍵,將交易本身累積在生成的Map中.

Collector提供了很多靜態工廠方法,可以方便的創建常見的收集器實例,直接拿來用就可以了.
在這裏插入圖片描述
最直接最常用的Collector就是toList()了,它會把流中所有元素收集到一個List中.

2 預定義收集器

所謂預定義收集器,就是那些可以從 Collectors類中直接拿來用的靜態工廠方法,他們主要提供了三大功能:

  • 將流元素歸約或彙總爲一個值

  • 元素分組

  • 元素分區

2.1 歸約和彙總

爲了說明從 Collectors 工廠類中能創建出多少種收集器實例,我們重用一下前面文章的例 子:包含一張佳餚列表的菜單!

就像我們之前看到的,在就需要將流項目重組爲集合時:一般會使用收集器(Stream的collect參數).

我們先來舉一個簡單的例子,利用 counting 工廠方法返回的收集器,數一數菜單裏有多少 種菜

兩種方法:

@Test
    public  void test4(){
        Long aLong = menu.stream()
                .collect(Collectors.counting());
        System.out.println(aLong);


        long count = menu.stream()
                .count();

        System.out.println(count);

    }

另外如果我們導入了

import static java.util.stream.Collectors.*;

注意是 static

那麼我們的代碼就可以不用寫 Collectors了,可以直接使用counting()

 Long aLong = menu.stream()
                .collect(counting());

2.1.1 找出流中的最大值和最小值

你可以使用兩個收集器, Collectors.maxBy 和 Collectors.minBy ,來計算流中的最大或最小值. 這兩個收集器都接受一個Comparator 參數來根據熱量對菜單進行比較.

//找出菜單裏卡路里的最大值和最小值
    @Test
    public  void test6(){

        //使用收集器的方法
        Optional<Dish> max = menu.stream()
                .collect(maxBy(Comparator.comparing(Dish::getCalories)));

        System.out.println(max);

        Optional<Dish> min = menu.stream()
                .collect(minBy(Comparator.comparing(Dish::getCalories)));

        System.out.println(min);

        //使用流的方法
        Optional<Dish> max1 = menu.stream()
                .max(Comparator.comparing(Dish::getCalories));
        System.out.println(max1);

        Optional<Dish> min1 = menu.stream()
                .min(Comparator.comparing(Dish::getCalories));
        System.out.println(min1);

    }

2.1.2 彙總

Collectors 類專門爲彙總提供了一個工廠方法: Collectors.summingInt 。它可接受一 個把對象映射爲求和所需 int 的函數,並返回一個收集器;該收集器在傳遞給普通的 collect 方 法後即執行我們需要的彙總操作。舉個例子來說,你可以這樣求出菜單列表的總熱量:

//彙總
    @Test
    public void test7(){

        //使用收集器彙總
        Integer sum = menu.stream()
                .collect(summingInt(Dish::getCalories));
        System.out.println(sum);


        //使用流彙總
        Integer sum2 = menu.stream()
                .map(Dish::getCalories)
                .reduce(0, Integer::sum);
        System.out.println(sum2);

    }

不過,有時候我們想得到菜單卡路里的最大值,最小值,平均值,和菜單裏菜餚數量,是不是要寫好多次流操作呢?

幸運的是,Collectors已經爲我們提供了這樣一個靜態工廠方法 summarizingxxx ,

例如,通過一次 summarizing 操作你可以就數出菜單中元素的個數,並得 到菜餚熱量總和、平均值、最大值和最小值:

//彙總
    @Test
    public void test8(){
        IntSummaryStatistics summaryStatistics = menu.stream()
                .collect(summarizingInt(Dish::getCalories));
        System.out.println(summaryStatistics);
        System.out.println(summaryStatistics.getAverage());
    }

這個收集器會把所有這些信息收集到一個叫作 IntSummaryStatistics 的類裏,它提供了 方便的取值(getter)方法來訪問結果。

結果:

IntSummaryStatistics{count=9, sum=4300, min=120, average=477.777778, max=800}
477.77777777777777

2.1.3 連接字符串 joining

joniing工廠方法返回的Collector 會把對流中的每一個對象應用 toString() 方法得到的字符串連接成一個字符串,

你可以把菜單中所有菜餚的名稱連接起來.

//連接字符串 joining
@Test
public void test9(){

    String names = menu.stream()
            .map(Dish::getName)
            .collect(joining());
    System.out.println(names);

    String names2 = menu.stream()
            .map(Dish::getName)
            .collect(joining(", "));
    System.out.println(names2);


}

joining 工廠方法有一個重載版本可以接受元素之間的 分界符,這樣你就可以得到一個逗號分隔的菜餚名稱列表:

結果:

porkbeefchickenfrench friesriceseason fruitpizzaprawnssalmon
pork, beef, chicken, french fries, rice, season fruit, pizza, prawns, salmon

2.1.4 廣義的歸約彙總

事實上,上面我們討論過的所有收集器,都是一個可以用 reducing 工廠方法定義的歸約過程的特殊情況而已.

Collectors.reducing 工廠方法是所有這些特殊情況的一般化.(注意和Stream.reduce()方法的區分).

可以說,先前討論過的方法只是爲了方便程序員開發而已,但是,請記住,方便程序員開發可程序可讀性恰恰是頭等大事. 先看一個 以不同的方法執行同樣的操作的例子

 //使用reducing求和,以不同的方法執行同樣的操作
    @Test
    public void test10(){

        //使用收集器彙總
        Integer sum = menu.stream()
                .collect(summingInt(Dish::getCalories));
        System.out.println(sum);


        //使用流彙總
        Integer sum2 = menu.stream()
                .map(Dish::getCalories)
                .reduce(0, Integer::sum);
        System.out.println(sum2);

        //把流映射到一個 IntStream ,然後調用 sum 方法
        int sum1 = menu.stream()
                .mapToInt(Dish::getCalories)
                .sum();
        System.out.println(sum1);

        Integer sum3 = menu.stream()
                .collect(reducing(0,//初始值
                        Dish::getCalories,//轉換函數
                        Integer::sum));//累積函數
        System.out.println(sum3);


    }

我們主要分析第三種方法:

  • 第一個參數是歸約操作的起始值,也是流沒有元素的返回值.

  • 第二個參數,將菜餚轉換成一個表示其所含熱量的int

  • 第三個參數是一個二進制操作(BinaryOperator),將兩個項目積累成一個同類型的值.這裏就是對兩個int求和.

同樣,你可以使用下面這樣單參數形式的 reducing 來找到熱量最高的菜,如下所示:

//使用reducing求最大值,以不同的方法執行同樣的操作
    @Test
    public void test11(){
        Optional<Dish> dishOptional = menu.stream()
                .collect(reducing((d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));
        System.out.println(dishOptional);
    }

還有我們提到的counting也是收集器也是利用三參數reducing工廠方法實現的.它把流中的每個元素都轉換成值爲1的Long型對象.然後再把它們相加.

public static <T> Collector<T, ?, Long> counting() {
       return reducing(0L, e -> 1L, Long::sum);
}

你可能已經注意到了 ? 通配符,它用作 counting 工廠方法返 回的收集器簽名中的第二個泛型類型。對這種記法你應該已經很熟悉了,特別是如果你經常使 用Java的集合框架的話。在這裏,它僅僅意味着收集器的累加器類型未知,換句話說,累加器 本身可以是任何類型。

根據情況選擇最佳解決方案
上面的內容說明了,函數式編程通常提供了多種方法來進行統一個操作.收集器在某種程度 上比 Stream 接口上直接提供的方法用起來更復雜,但好處在於它們能提供更高水平的抽象和概括,也更容易重用和自定義。

我們需要爲手頭的問題探索不同的解決方案,在通用的解決方案裏,始終選擇最專門化的一個.

例如,要計菜單的總熱量,我們更傾向於 IntStream,因爲 IntStream 可以讓我們避免自動拆箱操作,也就是從 Integer到 int 的隱式轉換,它在這裏毫無用處。

 //把流映射到一個 IntStream ,然後調用 sum 方法
        int sum1 = menu.stream()
                .mapToInt(Dish::getCalories)
                .sum();
        System.out.println(sum1);

2.2 分組

一個常見的數據庫操作是根據一個或多個屬性對集合中的項目進行分組,如果用指令式編程的話,這個操作可能很麻煩,就想開頭的例子一樣. 但是如果用java8 所推崇的函數式風格來寫的話,就很容易轉化爲一個可以看懂的語句.

假設你要把菜單中的菜按照類型進行分類, 有肉的放一組,有魚的放一組,其他的都放另一組。用 Collectors.groupingBy 工廠方法返回 的收集器就可以輕鬆地完成這項任務,如下所示:

//分組操作
    @Test
    public void test12(){
        Map<Dish.Type, List<Dish>> typeListMap = menu.stream()
                .collect(groupingBy(Dish::getType));

        System.out.println(typeListMap);

        for (Dish.Type key:typeListMap.keySet()){
            System.out.println(typeListMap.get(key));
        }

    }

分組操作的結果是一個Map,把分組函數返回的值作爲映射的鍵(Dish.Type類型),把流中所有與這個鍵對應的項目列表作爲見對應的值(List類型).

現在,你已經看到了如何對菜單中的菜餚按照類型和熱量進行分組,但要是想同時按照這兩 個標準分類怎麼辦呢?分組的強大之處就在於它可以有效地組合。讓我們來看看怎麼做。

2.2.1 多級分組

要實現多級分組,我們可以使用一個由雙參數版本的 Collectors.groupingBy 工廠方法創 建的收集器,它除了普通的分類函數之外,還可以接受 collector 類型的第二個參數。

//多級分組操作
    @Test
    public void test13() {


        Map<Dish.Type, Map<CaloricLevel, List<Dish>>> map = menu.stream().collect(
                groupingBy(Dish::getType,
                        groupingBy(dish -> {
                            if (dish.getCalories() <= 400) return CaloricLevel.DIET;
                            else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
                            else return CaloricLevel.FAT;
                        })
                )
        );

        System.out.println(map);
        for (Dish.Type key:map.keySet()){
            System.out.println(map.get(key));
            Map<CaloricLevel, List<Dish>> map2= map.get(key);
            for (CaloricLevel key2:map2.keySet()){
                System.out.println(map2.get(key2));
            }
        }


    }

這裏的外層Map的鍵就是第一級分類函數( groupingBy(Dish::getType))的值:FISH,MEAT,OTHER. 而這個Map的值又是一個Map(Map<CaloricLevel, List>類型),接着以此類推,應用第二級分類函數…

這種多級分類操作可以擴展至任意層級,n級分組就會得到一個代表n級樹形結構的n級Map 。

2.2.2 按子組收集數據

事實上,我們傳遞給第一個groupingBy 的第二個收集器可以是任意類型,而不必一定是另一個groupingBy,從源碼可看出,可以是任意Collector類型.

 public static <T, K, A, D>
    Collector<T, ?, Map<K, D>> groupingBy(Function<? super T, ? extends K> classifier,
                                          Collector<? super T, A, D> downstream) {
        return groupingBy(classifier, HashMap::new, downstream);
    }

例如,要數一數菜單中每類菜有多少個,可以傳遞 counting 收集器作爲groupingBy 收集器的第二個參數:

  //按子組收集數據
    @Test
    public void test14(){
        Map<Dish.Type, Long> map = menu.stream()
                .collect(groupingBy(Dish::getType, counting()));
        System.out.println(map);//{MEAT=3, FISH=2, OTHER=4}

    }

需要注意的是:普通的單參數 groupingBy(f)(其中f是分類函數),實際上是groupingBy(f,toList())的簡便寫法

2.3 分區

分區是分組的特殊情況,有一個謂詞作爲分區函數.分區函數返回一個boolean值,這意味着得到的分區map類型的鍵是boolean型, 所以,它最多可以分爲兩組,true和false.

例如: 把菜單按照素食和非素食分開

   //分區
    @Test
    public void test15(){
        Map<Boolean, List<Dish>> map = menu.stream()
                .collect(partitioningBy(Dish::isVegetarian));
        System.out.println(map);

        for (Boolean key:map.keySet()){
            System.out.println(map.get(key));
        }

    }

分區的好處在於保留了分區函數返回 true 或 false 的兩套流元素列表。

和groupingBy()一樣,partitioningBy()也有一個重載版本,可以產生多級Map

//多級分區,把葷素分開並分類
    @Test
    public void test16(){
        Map<Boolean, Map<Dish.Type, List<Dish>>> map = menu.stream()
                .collect(partitioningBy(Dish::isVegetarian, groupingBy(Dish::getType)));

        for (Boolean key:map.keySet()){
            System.out.println(map.get(key));
        }

    }

Collectors類的靜態工廠方法總結

在這裏插入圖片描述

所有這些收集器都是對Collectors接口的實現

收集器 Collectors 接口

public interface Collector<T, A, R> {
        Supplier<A> supplier();
        BiConsumer<A, T> accumulator();
        Function<A, R> finisher();
        BinaryOperator<A> combiner();
        Set<Characteristics> characteristics();
}

小結:

  • collect 是一個終端操作,它接收的參數是將流中的元素累積到彙總結果的各種方式(稱爲收集器).

  • 預定義收集器可以用 groupingBy 對流中元素進行分組,或用 partitioningBy 進行分區。

  • 收集器可以高效地複合起來,進行多級分組、分區和歸約。

  • 你可以實現 Collector 接口中定義的方法來開發你自己的收集器。

引用《java8實戰》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章