《Java8實戰》-第六章讀書筆記(用流收集資料-02)

雷俠發表於2018-09-23

使用流收集資料

分割槽

分割槽是分組的特殊情況:由一個謂詞(返回一個布林值的函式)作為分類函式,它稱分割槽函式。分割槽函式返回一個布林值,這意味著得到的分組 Map 的鍵型別是 Boolean ,於是它最多可以分為兩組—— true 是一組, false 是一組。例如,如果你是素食者或是請了一位素食的朋友來共進晚餐,可能會想要把選單按照素食和非素食分開:

Map<Boolean, List<Dish>> partitionedMenu =
                // 分割槽函式
                menu.stream().collect(partitioningBy(Dish::isVegetarian));
複製程式碼

這會返回下面的 Map :

{false=[Dish{name='pork'}, Dish{name='beef'}, Dish{name='chicken'}, Dish{name='prawns'}, Dish{name='salmon'}], 
true=[Dish{name='french fries'}, Dish{name='rice'}, Dish{name='season fruit'}, Dish{name='pizza'}]}
複製程式碼

那麼通過 Map 中鍵為 true 的值,就可以找出所有的素食菜餚了:

List<Dish> vegetarianDishes = partitionedMenu.get(true);
複製程式碼

請注意,用同樣的分割槽謂詞,對選單 List 建立的流作篩選,然後把結果收集到另外一個 List中也可以獲得相同的結果:

List<Dish> vegetarianDishes =
                        menu.stream().filter(Dish::isVegetarian).collect(toList());
複製程式碼

分割槽的優勢

分割槽的好處在於保留了分割槽函式返回 true 或 false 的兩套流元素列表。在上一個例子中,要得到非素食 Dish 的 List ,你可以使用兩個篩選操作來訪問 partitionedMenu 這個 Map 中 false鍵的值:一個利用謂詞,一個利用該謂詞的非。而且就像你在分組中看到的, partitioningBy工廠方法有一個過載版本,可以像下面這樣傳遞第二個收集器:

Map<Boolean, Map<Dish.Type, List<Dish>>> vegetarianDishesByType =
                menu.stream().collect(
                        // 分割槽函式
                        partitioningBy(Dish::isVegetarian,
                                // 第二個收集器
                                groupingBy(Dish::getType)));
複製程式碼

這將產生一個二級 Map :

{false={MEAT=[Dish{name='pork'}, Dish{name='beef'}, Dish{name='chicken'}], FISH=[Dish{name='prawns'}, Dish{name='salmon'}]}, 
true={OTHER=[Dish{name='french fries'}, Dish{name='rice'}, Dish{name='season fruit'}, Dish{name='pizza'}]}}
複製程式碼

這裡,對於分割槽產生的素食和非素食子流,分別按型別對菜餚分組,得到了一個二級 Map,和上面的類似。再舉一個例子,你可以重用前面的程式碼來找到素食和非素食中熱量最高的菜:

Map<Boolean, Dish> mostCaloricPartitionedByVegetarian = menu.stream().collect(
                partitioningBy(Dish::isVegetarian, collectingAndThen(
                        maxBy(comparingInt(Dish::getCalories)),
                        Optional::get
                )));
複製程式碼

這將產生以下結果:

{false=Dish{name='pork'}, true=Dish{name='pizza'}}
複製程式碼

你可以把分割槽看作分組一種特殊情況。 groupingBy 和partitioningBy 收集器之間的相似之處並不止於此。

將數字按質數和非質數分割槽

假設你要寫一個方法,它接受引數 int n,並將前n個自然數分為質數和非質數。但首先,找出能夠測試某一個待測數字是否是質數的謂詞會很有幫助:

private static boolean isPrime(int candidate) {
    // 產生一個自然數範圍,從2開始,直至但不包括待測數
    return IntStream.range(2, candidate)
            // 如果待測數字不能被流中任何數字整除則返回 true
            .noneMatch(i -> candidate % i == 0);
}
複製程式碼

一個簡單的優化是僅測試小於等於待測數平方根的因子:

private static boolean isPrime(int candidate) {
    int candidateRoot = (int) Math.sqrt((double) candidate);
    return IntStream.rangeClosed(2, candidateRoot)
            .noneMatch(i -> candidate % i == 0);
}
複製程式碼

現在最主要的一部分工作已經做好了。為了把前n個數字分為質數和非質數,只要建立一個包含這n個數的流,用剛剛寫的 isPrime 方法作為謂詞,再給 partitioningBy 收集器歸約就好了:

private static Map<Boolean, List<Integer>> partitionPrimes(int n) {
    return IntStream.rangeClosed(2, n).boxed()
            .collect(
                    partitioningBy(candidate -> isPrime(candidate)));
}
複製程式碼

現在我們已經討論過了 Collectors 類的靜態工廠方法能夠建立的所有收集器,並介紹了使用它們的實際例子。

收集器介面

Collector 介面包含了一系列方法,為實現具體的歸約操作(即收集器)提供了範本。我們已經看過了 Collector 介面中實現的許多收集器,例如 toList 或 groupingBy 。這也意味著,你可以為 Collector 介面提供自己的實現,從而自由地建立自定義歸約操作。

要開始使用 Collector 介面,我們先看看本章開始時講到的一個收集器—— toList 工廠方法,它會把流中的所有元素收整合一個 List 。我們當時說在日常工作中經常會用到這個收集器,而且它也是寫起來比較直觀的一個,至少理論上如此。通過仔細研究這個收集器是怎麼實現的,我們可以很好地瞭解 Collector 介面是怎麼定義的,以及它的方法所返回的函式在內部是如何為collect 方法所用的。

首先讓我們在下面的列表中看看 Collector 介面的定義,它列出了介面的簽名以及宣告的五個方法。

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

本列表適用以下定義。

  1. T 是流中要收集的專案的泛型。
  2. A 是累加器的型別,累加器是在收集過程中用於累積部分結果的物件。
  3. R 是收集操作得到的物件(通常但並不一定是集合)的型別。

例如,你可以實現一個 ToListCollector 類,將 Stream 中的所有元素收集到一個List 裡,它的簽名如下:

public class ToListCollector<T> implements Collector<T, List<T>, List<T>>
複製程式碼

我們很快就會澄清,這裡用於累積的物件也將是收集過程的最終結果。

理解 Collector 介面宣告的方法

現在我們可以一個個來分析 Collector 介面宣告的五個方法了。通過分析,你會注意到,前四個方法都會返回一個會被 collect 方法呼叫的函式,而第五個方法 characteristics 則提供了一系列特徵,也就是一個提示列表,告訴 collect 方法在執行歸約操作的時候可以應用哪些優化(比如並行化)。

1. 建立新的結果容器: supplier 方法

supplier 方法必須返回一個結果為空的 Supplier ,也就是一個無引數函式,在呼叫時它會建立一個空的累加器例項,供資料收集過程使用。很明顯,對於將累加器本身作為結果返回的收集器,比如我們的 ToListCollector ,在對空流執行操作的時候,這個空的累加器也代表了收集過程的結果。在我們的 ToListCollector 中, supplier 返回一個空的 List ,如下所示:

@Override
public Supplier<List<T>> supplier() {
    return () -> new ArrayList<>();
}
複製程式碼

請注意你也可以只傳遞一個建構函式引用:

@Override
public Supplier<List<T>> supplier() {
    return ArrayList::new;
}
複製程式碼

2. 將元素新增到結果容器: accumulator 方法

accumulator 方法會返回執行歸約操作的函式。當遍歷到流中第n個元素時,這個函式執行時會有兩個引數:儲存歸約結果的累加器(已收集了流中的前 n-1 個專案),還有第n個元素本身。該函式將返回void ,因為累加器是原位更新,即函式的執行改變了它的內部狀態以體現遍歷的元素的效果。對於ToListCollector ,這個函式僅僅會把當前專案新增至已經遍歷過的專案的列表:

@Override
public BiConsumer<List<T>, T> accumulator() {
    return (list, item) -> list.add(item);
}
複製程式碼

你也可以使用方法引用,這會更為簡潔:

@Override
public BiConsumer<List<T>, T> accumulator() {
    return List::add;
}
複製程式碼

3. 對結果容器應用最終轉換: finisher 方法

在遍歷完流後, finisher 方法必須返回在累積過程的最後要呼叫的一個函式,以便將累加器物件轉換為整個集合操作的最終結果。通常,就像 ToListCollector 的情況一樣,累加器物件恰好符合預期的最終結果,因此無需進行轉換。所以 finisher 方法只需返回 identity 函式:

@Override
public Function<List<T>, List<T>> finisher() {
    return Function.identity();
}
複製程式碼

這三個方法已經足以對流進行循序規約。實踐中的實現細節可能還要複雜一點,一方面是應為流的延遲性質,可能在collect操作之前還需完成其他中間操作的流水線,另一方面則是理論上可能要進行並行規約。

4. 合併兩個結果容器: combiner 方法

四個方法中的最後一個————combiner方法會返回一個供歸約操作的使用函式,它定義了對流的各個子部分進行並行處理時,各個子部分歸約所得的累加器要如何合併。對於toList而言,這個方法的實現非常簡單,只要把從流的第二個部分收集到的專案列表加到遍歷第一部分時得到的列表後面就行了:

@Override
public BinaryOperator<List<T>> combiner() {
    return (list1, list2) -> {
        list1.addAll(list2);
        return list1;
    };
}
複製程式碼

有了這第四個方法,就可以對流進行並行歸約了。它會用到Java7中引入的分支/合併框架和Spliterator抽象。

5. characteristics 方法

最後一個方法—— characteristics 會返回一個不可變的 Characteristics 集合,它定義了收集器的行為——尤其是關於流是否可以並行歸約,以及可以使用哪些優化的提示。Characteristics 是一個包含三個專案的列舉。

  1. UNORDERED ——歸約結果不受流中專案的遍歷和累積順序的影響。
  2. CONCURRENT —— accumulator 函式可以從多個執行緒同時呼叫,且該收集器可以並行歸約流。如果收集器沒有標為 UNORDERED ,那它僅在用於無序資料來源時才可以並行歸約。
  3. IDENTITY_FINISH ——這表明完成器方法返回的函式是一個恆等函式,可以跳過。這種情況下,累加器物件將會直接用作歸約過程的最終結果。這也意味著,將累加器 A 不加檢查地轉換為結果 R 是安全的。

我們迄今開發的 ToListCollector 是 IDENTITY_FINISH 的,因為用來累積流中元素的List 已經是我們要的最終結果,用不著進一步轉換了,但它並不是 UNORDERED ,因為用在有序流上的時候,我們還是希望順序能夠保留在得到的 List 中。最後,它是 CONCURRENT 的,但我們剛才說過了,僅僅在背後的資料來源無序時才會並行處理。

全部融合到一起

前一小節中談到的五個方法足夠我們開發自己的 ToListCollector 了。你可以把它們都融合起來,如下面的程式碼清單所示。

public class ToListCollector<T> implements Collector<T, List<T>, List<T>> {
    @Override
    public Supplier<List<T>> supplier() {
        return ArrayList::new;
    }

    @Override
    public BiConsumer<List<T>, T> accumulator() {
        return List::add;
    }

    @Override
    public BinaryOperator<List<T>> combiner() {
        return (list1, list2) -> {
            list1.addAll(list2);
            return list1;
        };
    }

    @Override
    public Function<List<T>, List<T>> finisher() {
        return Function.identity();
    }

    @Override
    public Set<Characteristics> characteristics() {
        return Collections.unmodifiableSet(EnumSet.of(Characteristics.IDENTITY_FINISH, Characteristics.CONCURRENT));
    }
}
複製程式碼

請注意,這個是實現與Collections.toList()方法並不完全相同,但區別僅僅是一些小的優化。這些優化的一個主要方面是Java API所提供的收集器在需要返回空列表時使用了 Collections.emptyList() 這個單例(singleton)。這意味著它可安全地替代原生Java,來收集選單流中的所有 Dish 的列表:

List<Dish> dishes = menuStream.collect(new ToListCollector<>());
複製程式碼

這個實現和標準的

List<Dish> dishes = menuStream.collect(toList());
複製程式碼

構造之間的其他差異在於 toList 是一個工廠,而 ToListCollector 必須用 new 來例項化。

進行自定義收集而不去實現 Collector

對於 IDENTITY_FINISH 的收集操作,還有一種方法可以得到同樣的結果而無需從頭實現新的 Collectors 介面。 Stream 有一個過載的 collect 方法可以接受另外三個函式—— supplier 、accumulator 和 combiner ,其語義和 Collector 介面的相應方法返回的函式完全相同。所以比如說,我們可以像下面這樣把菜餚流中的專案收集到一個 List 中:

List<Dish> dishes = menuStream.collect(
                ArrayList::new,
                List::add,
                List::addAll);
複製程式碼

我們認為,這第二種形式雖然比前一個寫法更為緊湊和簡潔,卻不那麼易讀。此外,以恰當的類來實現自己的自定義收集器有助於重用並可避免程式碼重複。另外值得注意的是,這第二個collect 方法不能傳遞任何 Characteristics ,所以它永遠都是一個 IDENTITY_FINISH 和CONCURRENT 但並非 UNORDERED 的收集器。

在下一節中,我們一起來實現一個收集器的,讓我們對收集器的新知識更上一層樓。你將會為一個更為複雜,但更為具體、更有說服力的用例開發自己的自定義收集器。

開發你自己的收集器以獲得更好的效能

我們用 Collectors 類提供的一個方便的工廠方法建立了一個收集器,它將前n個自然數劃分為質數和非質數,如下所示。

將前n個自然數按質數和非質數分割槽:

private static Map<Boolean, List<Integer>> partitionPrimes(int n) {
    return IntStream.rangeClosed(2, n).boxed()
            .collect(
                    partitioningBy(candidate -> isPrime(candidate)));
}
複製程式碼

當時,通過限制除數不超過被測試數的平方根,我們對最初的 isPrime 方法做了一些改進:

private static boolean isPrime(int candidate) {
    int candidateRoot = (int) Math.sqrt((double) candidate);
    return IntStream.rangeClosed(2, candidateRoot)
            .noneMatch(i -> candidate % i == 0);
}
複製程式碼

還有沒有辦法來獲得更好的效能呢?答案是“有”,但為此你必須開發一個自定義收集器。

僅用質數做除數

一個可能的優化是僅僅看看被測試數是不是能夠被質數整除。要是除數本身都不是質數就用不著測了。所以我們可以僅僅用被測試數之前的質數來測試。然而我們目前所見的預定義收集器的問題,也就是必須自己開發一個收集器的原因在於,在收集過程中是沒有辦法訪問部分結果的。這意味著,當測試某一個數字是否是質數的時候,你沒法訪問目前已經找到的其他質數的列表。

假設你有這個列表,那就可以把它傳給 isPrime 方法,將方法重寫如下:

private static boolean isPrime(List<Integer> primes, int candidate) {
    return primes.stream().noneMatch(i -> candidate % i == 0);
}
複製程式碼

而且還應該應用先前的優化,僅僅用小於被測數平方根的質數來測試。因此,你需要想辦法在下一個質數大於被測數平方根時立即停止測試。不幸的是,Stream API中沒有這樣一種方法。你可以使用 filter(p -> p <= candidateRoot) 來篩選出小於被測數平方根的質數。但 filter要處理整個流才能返回恰當的結果。如果質數和非質數的列表都非常大,這就是個問題了。你用不著這樣做;你只需在質數大於被測數平方根的時候停下來就可以了。因此,我們會建立一個名為 takeWhile 的方法,給定一個排序列表和一個謂詞,它會返回元素滿足謂詞的最長字首:

public static <A> List<A> takeWhile(List<A> list, Predicate<A> p) {
    int i = 0;
    for (A item : list) {
        if (!p.test(item)) {
            return list.subList(0, i);
        }
        i++;
    }
    return list;
}
複製程式碼

利用這個方法,你就可以優化 isPrime 方法,只用不大於被測數平方根的質數去測試了:

private static boolean isPrime(List<Integer> primes, int candidate){
    int candidateRoot = (int) Math.sqrt((double) candidate);
    return takeWhile(primes, i -> i <= candidateRoot)
            .stream()
            .noneMatch(p -> candidate % p == 0);
}
複製程式碼

請注意,這個 takeWhile 實現是即時的。理想情況下,我們會想要一個延遲求值的takeWhile ,這樣就可以和 noneMatch 操作合併。不幸的是,這樣的實現超出了本章的範圍,你需要了解Stream API的實現才行。

有了這個新的 isPrime 方法在手,你就可以實現自己的自定義收集器了。首先要宣告一個實現 Collector 介面的新類,然後要開發 Collector 介面所需的五個方法。

1. 第一步:定義 Collector 類的簽名

讓我們從類簽名開始吧,記得 Collector 介面的定義是:

public interface Collector<T, A, R>
複製程式碼

其中 T 、 A 和 R 分別是流中元素的型別、用於累積部分結果的物件型別,以及 collect 操作最終結果的型別。這裡應該收集 Integer 流,而累加器和結果型別則都是 Map<Boolean,List>,鍵是 true 和 false ,值則分別是質數和非質數的 List :

public class PrimeNumbersCollector implements Collector<Integer, Map<Boolean, List<Integer>>,
        Map<Boolean, List<Integer>>>
複製程式碼

2. 第二步:實現歸約過程

接下來,你需要實現 Collector 介面中宣告的五個方法。 supplier 方法會返回一個在呼叫時建立累加器的函式:

@Override
public Supplier<Map<Boolean, List<Integer>>> supplier() {
    return () -> new HashMap<Boolean, List<Integer>>(2) {
        {
            put(true, new ArrayList<>());
            put(false, new ArrayList<>());
        }
    };
}
複製程式碼

這裡不但建立了累積器的Map,還為true和false兩個鍵下面出實話了對應的空列表。在收集過程中會把質數和非指數分別新增到這裡。收集器重要的方法是accumulator,因為它定義瞭如何收集流中元素的邏輯。這裡它也是實現了前面所講的優化的關鍵。現在在任何一次迭代中,都可以訪問收集過程的部分結果,也就是包含迄今找到的質數的累加器:

@Override
public BiConsumer<Map<Boolean, List<Integer>>, Integer> accumulator() {
    return ((Map<Boolean, List<Integer>> acc, Integer candidate) -> acc.get(isPrime(acc.get(true), candidate)).add(candidate));
}
複製程式碼

在這個個方法中,你呼叫了isPrime方法,將待測試是否為質數的數以及迄今為止找到的質數列表(也就是累積Map中true鍵對應的值)傳遞給它。這次呼叫的結果隨後被用作獲取質數或非質數列表的鍵,這樣就可以把新的被測數新增到恰當的列表中。

3.第三步:讓收集器並行工作(如果可能)

下一個方法要在並行收集時把兩個部分累加器合併起來,這裡,它只需要合併兩個Map,即將第二個Map中質數和非質數列表中的所有數字合併到第一個Map的對應列表中就行了:

@Override
public BinaryOperator<Map<Boolean, List<Integer>>> combiner() {
    return (Map<Boolean, List<Integer>> map1, Map<Boolean, List<Integer>> map2) -> {
        map1.get(true).addAll(map2.get(true));
        map1.get(false).addAll(map2.get(false));
        return map1;
    };
}
複製程式碼

請注意,實際上這個收集器是不能並行的,因為該演算法本身是順序的。這意味著永遠都不會呼叫combiner方法,你可以把它的實現留空。為了讓這個例子完整,我們還是決定實現它。

4.第四步:finisher方法和收集器的characteristics方法

最後兩個方法實現都很簡單。前面說過,accumulator正好就是收集器的結果,也用不著進一步轉換,那麼finisher方法就返回identity函式:

@Override
public Function<Map<Boolean, List<Integer>>, Map<Boolean, List<Integer>>> finisher() {
    return Function.identity();
}
複製程式碼

就characteristics方法而言,我們已經說過,它既不是CONCURRENT也不是UNOREDERED,但卻是IDENTITY_FINISH的:

@Override
public Set<Characteristics> characteristics() {
    return Collections.unmodifiableSet(EnumSet.of(Characteristics.IDENTITY_FINISH));
}
複製程式碼

現在,你可以用這個新的自定義收集器來替代partitioningBy工廠方法建立的那個,並獲得完全相同的結果了:

private static Map<Boolean, List<Integer>> partitionPrimesWithCustomCollector(int n) {
    return IntStream.rangeClosed(2, n).boxed()
            .collect(new PrimeNumbersCollector());
}
Map<Boolean, List<Integer>> primes = partitionPrimesWithCustomCollector(10);
// {false=[4, 6, 8, 9, 10], true=[2, 3, 5, 7]}
System.out.println(primes);
複製程式碼

收集器效能比較

用partitioningBy工廠方法穿件的收集器和你剛剛開發的自定義收集器在功能上是一樣的,但是我們沒有實現用自定義收集器超越partitioningBy收集器效能的目標呢?現在讓我們寫個小程式測試一下吧:

public class CollectorHarness {
    public static void main(String[] args) {
        long fastest = Long.MAX_VALUE;
        // 執行十次
        for (int i = 0; i < 10; i++) {
            long start = System.nanoTime();
            // 將前100萬個自然數按指數和非質數區分
            partitionPrimes(1_000_000);
            long duration = (System.nanoTime() - start) / 1_000_000;
            // 檢查這個執行是否是最快的一個
            if (duration < fastest) {
                fastest = duration;
            }
            System.out.println("done in " + duration);
        }
        System.out.println("Fastest execution done in " + fastest + " msecs");
    }
}
複製程式碼

在因特爾I5 6200U 2.4HGz的筆記上執行得到以下的結果:

done in 976
done in 1091
done in 866
done in 867
done in 760
done in 759
done in 777
done in 894
done in 765
done in 763
Fastest execution done in 759 msecs
複製程式碼

現在把測試框架的 partitionPrimes 換成 partitionPrimesWithCustomCollector ,以便測試我們開發的自定義收集器的效能。

public class CollectorHarness {
    public static void main(String[] args) {
        excute(PrimeNumbersCollectorExample::partitionPrimesWithCustomCollector);
    }

    private static void excute(Consumer<Integer> primePartitioner) {
        long fastest = Long.MAX_VALUE;
        // 執行十次
        for (int i = 0; i < 10; i++) {
            long start = System.nanoTime();
            // 將前100萬個自然數按指數和非質數區分
            // partitionPrimes(1_000_000);
            primePartitioner.accept(1_000_000);
            long duration = (System.nanoTime() - start) / 1_000_000;
            // 檢查這個執行是否是最快的一個
            if (duration < fastest) {
                fastest = duration;
            }
            System.out.println("done in " + duration);
        }
        System.out.println("Fastest execution done in " + fastest + " msecs");
    }
}
複製程式碼

現在,程式列印:

done in 703
done in 649
done in 715
done in 434
done in 386
done in 403
done in 449
done in 416
done in 353
done in 405
Fastest execution done in 353 msecs
複製程式碼

還不錯!看來我們沒有白費功夫開發這個自定義收集器。

總結

  1. collect 是一個終端操作,它接受的引數是將流中元素累積到彙總結果的各種方式(稱為收集器)。
  2. 預定義收集器包括將流元素歸約和彙總到一個值,例如計算最小值、最大值或平均值。
  3. 預定義收集器可以用 groupingBy 對流中元素進行分組,或用 partitioningBy 進行分割槽。
  4. 收集器可以高效地複合起來,進行多級分組、分割槽和歸約。
  5. 你可以實現 Collector 介面中定義的方法來開發你自己的收集器。

程式碼

Github:chap6

Gitee:chap6

相關文章