Lambda表達式-02.Streams API

目錄

Streams API(I)

forEach()

filter()

distinct()

sorted()

map()

flatMap()

Streams API(II)

多面手reduce()

終極武器collect()

接口的靜態方法和默認方法

方法引用

收集器

使用collect()生成Collection

使用collect()生成Map

使用collect()做字符串join

collect()還可以做更多

Stream Pipelines

一種直白的實現方式

Stream流水線解決方案


Streams API(I)

你可能沒意識到Java對函數式編程的重視程度,看看Java 8加入函數式編程擴充多少功能就清楚了。Java 8之所以費這麼大功夫引入函數式編程,原因有二:

  1. 代碼簡潔函數式編程寫出的代碼簡潔且意圖明確,使用stream接口讓你從此告別for循環。
  2. 多核友好,Java函數式編程使得編寫並行程序從未如此簡單,你需要的全部就是調用一下parallel()方法。

這一節我們學習stream,也就是Java函數式編程的主角。對於Java 7來說stream完全是個陌生東西,stream並不是某種數據結構,它只是數據源的一種視圖。這裏的數據源可以是一個數組,Java容器或I/O channel等。正因如此要得到一個stream通常不會手動創建,而是調用對應的工具方法,比如:

  • 調用Collection.stream()或者Collection.parallelStream()方法
  • 調用Arrays.stream(T[] array)方法

常見的stream接口繼承關係如圖:

圖中4種stream接口繼承自BaseStream,其中IntStream, LongStream, DoubleStream對應三種基本類型(int, long, double,注意不是包裝類型),Stream對應所有剩餘類型的stream視圖。爲不同數據類型設置不同stream接口,可以1.提高性能,2.增加特定接口函數。

你可能會奇怪爲什麼不把IntStream等設計成Stream的子接口?畢竟這接口中的方法名大部分是一樣的。答案是這些方法的名字雖然相同,但是返回類型不同,如果設計成父子接口關係,這些方法將不能共存,因爲Java不允許只有返回類型不同的方法重載。

雖然大部分情況下stream是容器調用Collection.stream()方法得到的,但streamcollections有以下不同:

  • 無存儲stream不是一種數據結構,它只是某種數據源的一個視圖,數據源可以是一個數組,Java容器或I/O channel等。
  • 爲函數式編程而生。對stream的任何修改都不會修改背後的數據源,比如對stream執行過濾操作並不會刪除被過濾的元素,而是會產生一個不包含被過濾元素的新stream
  • 惰式執行stream上的操作並不會立即執行,只有等到用戶真正需要結果的時候纔會執行。
  • 可消費性stream只能被“消費”一次,一旦遍歷過就會失效,就像容器的迭代器那樣,想要再次遍歷必須重新生成。

stream的操作分爲爲兩類,中間操作(intermediate operations)和結束操作(terminal operations),二者特點是:

  1. 中間操作總是會惰式執行,調用中間操作只會生成一個標記了該操作的新stream,僅此而已。
  2. 結束操作會觸發實際計算,計算髮生時會把所有中間操作積攢的操作以pipeline的方式執行,這樣可以減少迭代次數。計算完成之後stream就會失效。

如果你熟悉Apache Spark RDD,對stream的這個特點應該不陌生。

下表彙總了Stream接口的部分常見方法:

操作類型 接口方法
中間操作 concat() distinct() filter() flatMap() limit() map() peek()
skip() sorted() parallel() sequential() unordered()
結束操作 allMatch() anyMatch() collect() count() findAny() findFirst()
forEach() forEachOrdered() max() min() noneMatch() reduce() toArray()

區分中間操作和結束操作最簡單的方法,就是看方法的返回值,返回值爲stream的大都是中間操作,否則是結束操作。

stream方法使用

stream 跟函數接口關係非常緊密,沒有函數接口 stream 就無法工作。回顧一下:函數接口是指內部只有一個抽象方法的接口。通常函數接口出現的地方都可以使用Lambda表達式,所以不必記憶函數接口的名字。

forEach()

我們對forEach()方法並不陌生,在Collection中我們已經見過。方法簽名爲void forEach(Consumer<? super E> action),作用是對容器中的每個元素執行action指定的動作,也就是對元素進行遍歷。

// 使用Stream.forEach()迭代
Stream<String> stream = Stream.of("I", "love", "you", "too");
stream.forEach(str -> System.out.println(str));

由於forEach()是結束方法,上述代碼會立即執行,輸出所有字符串。

filter()

函數原型爲Stream<T> filter(Predicate<? super T> predicate),作用是返回一個只包含滿足predicate條件元素的Stream

// 保留長度等於3的字符串
Stream<String> stream= Stream.of("I", "love", "you", "too");
stream.filter(str -> str.length()==3)
    .forEach(str -> System.out.println(str));

上述代碼將輸出爲長度等於3的字符串youtoo。注意,由於filter()是個中間操作,如果只調用filter()不會有實際計算,因此也不會輸出任何信息。

distinct()

函數原型爲Stream<T> distinct(),作用是返回一個去除重複元素之後的Stream

Stream<String> stream= Stream.of("I", "love", "you", "too", "too");
stream.distinct()
    .forEach(str -> System.out.println(str));

上述代碼會輸出去掉一個too之後的其餘字符串。

sorted()

排序函數有兩個,一個是用自然順序排序,一個是使用自定義比較器排序,函數原型分別爲Stream<T> sorted()Stream<T> sorted(Comparator<? super T> comparator)

Stream<String> stream= Stream.of("I", "love", "you", "too");
stream.sorted((str1, str2) -> str1.length()-str2.length())
    .forEach(str -> System.out.println(str));

上述代碼將輸出按照長度升序排序後的字符串,結果完全在預料之中。

map()

函數原型爲<R> Stream<R> map(Function<? super T,? extends R> mapper),作用是返回一個對當前所有元素執行執行mapper之後的結果組成的Stream。直觀的說,就是對每個元素按照某種操作進行轉換,轉換前後Stream中元素的個數不會改變,但元素的類型取決於轉換之後的類型。

Stream<String> stream = Stream.of("I", "love", "you", "too");
stream.map(str -> str.toUpperCase())
    .forEach(str -> System.out.println(str));

上述代碼將輸出原字符串的大寫形式。

flatMap()

函數原型爲<R> Stream<R> flatMap(Function<? super T,? extends Stream<? extends R>> mapper),作用是對每個元素執行mapper指定的操作,並用所有mapper返回的Stream中的元素組成一個新的Stream作爲最終返回結果。說起來太拗口,通俗的講flatMap()的作用就相當於把原stream中的所有元素都”攤平”之後組成的Stream,轉換前後元素的個數和類型都可能會改變。

Stream<List<Integer>> stream = Stream.of(Arrays.asList(1,2), Arrays.asList(3, 4, 5));
stream.flatMap(list -> list.stream())
    .forEach(i -> System.out.println(i));

上述代碼中,原來的stream中有兩個元素,分別是兩個List<Integer>,執行flatMap()之後,將每個List都“攤平”成了一個個的數字,所以會新產生一個由5個數字組成的Stream。所以最終將輸出1~5這5個數字。

截止到目前我們感覺良好,已介紹Stream接口函數理解起來並不費勁兒。如果你就此以爲函數式編程不過如此,恐怕是高興地太早了。下一節對Stream規約操作的介紹將刷新你現在的認識。

Streams API(II)

上一節介紹了部分Stream常見接口方法,理解起來並不困難,但Stream的用法不止於此,本節我們將仍然以Stream爲例,介紹流的規約操作。

規約操作(reduction operation)又被稱作摺疊操作(fold),是通過某個連接動作將所有元素彙總成一個彙總結果的過程。元素求和、求最大值或最小值、求出元素總個數、將所有元素轉換成一個列表或集合,都屬於規約操作。Stream類庫有兩個通用的規約操作reduce()collect(),也有一些爲簡化書寫而設計的專用規約操作,比如sum()max()min()count()等。

最大或最小值這類規約操作很好理解(至少方法語義上是這樣),我們着重介紹reduce()collect(),這是比較有魔法的地方。

多面手reduce()

reduce操作可以實現從一組元素中生成一個值,sum()max()min()count()等都是reduce操作,將他們單獨設爲函數只是因爲常用。reduce()的方法定義有三種重寫形式:

  • Optional<T> reduce(BinaryOperator<T> accumulator)
  • T reduce(T identity, BinaryOperator<T> accumulator)
  • <U> U reduce(U identity, BiFunction<U,? super T,U> accumulator, BinaryOperator<U> combiner)

雖然函數定義越來越長,但語義不曾改變,多的參數只是爲了指明初始值(參數identity),或者是指定並行執行時多個部分結果的合併方式(參數combiner)。reduce()最常用的場景就是從一堆值中生成一個值。用這麼複雜的函數去求一個最大或最小值,你是不是覺得設計者有病。其實不然,因爲“大”和“小”或者“求和”有時會有不同的語義。

需求:從一組單詞中找出最長的單詞。這裏“大”的含義就是“長”。

// 找出最長的單詞
Stream<String> stream = Stream.of("I", "love", "you", "too");
Optional<String> longest = stream.reduce((s1, s2) -> s1.length()>=s2.length() ? s1 : s2);
//Optional<String> longest = stream.max((s1, s2) -> s1.length()-s2.length());
System.out.println(longest.get());

上述代碼會選出最長的單詞love,其中Optional是(一個)值的容器,使用它可以避免null值的麻煩。當然可以使用Stream.max(Comparator<? super T> comparator)方法來達到同等效果,但reduce()自有其存在的理由。

需求:求出一組單詞的長度之和。這是個“求和”操作,操作對象輸入類型是String,而結果類型是Integer

// 求單詞長度之和
Stream<String> stream = Stream.of("I", "love", "you", "too");
Integer lengthSum = stream.reduce(0, // 初始值 // (1)
        (sum, str) -> sum+str.length(), // 累加器 // (2)
        (a, b) -> a+b); // 部分和拼接器,並行執行時纔會用到 // (3)
// int lengthSum = stream.mapToInt(str -> str.length()).sum();
System.out.println(lengthSum);

上述代碼標號(2)處將i. 字符串映射成長度,ii. 並和當前累加和相加。這顯然是兩步操作,使用reduce()函數將這兩步合二爲一,更有助於提升性能。如果想要使用map()sum()組合來達到上述目的,也是可以的。

reduce()擅長的是生成一個值,如果想要從Stream生成一個集合或者Map等複雜的對象該怎麼辦呢?終極武器collect()橫空出世!

終極武器collect()

不誇張的講,如果你發現某個功能在Stream接口中沒找到,十有八九可以通過collect()方法實現。collect()Stream接口方法中最靈活的一個,學會它纔算真正入門Java函數式編程。先看幾個熱身的小例子:

// 將Stream轉換成容器或Map
Stream<String> stream = Stream.of("I", "love", "you", "too");
List<String> list = stream.collect(Collectors.toList()); // (1)
// Set<String> set = stream.collect(Collectors.toSet()); // (2)
// Map<String, Integer> map = stream.collect(Collectors.toMap(Function.identity(), String::length)); // (3)

上述代碼分別列舉了如何將Stream轉換成ListSetMap。雖然代碼語義很明確,可是我們仍然會有幾個疑問:

  1. Function.identity()是幹什麼的?
  2. String::length是什麼意思?
  3. Collectors是個什麼東西?

接口的靜態方法和默認方法

Function是一個接口,那麼Function.identity()是什麼意思呢?這要從兩方面解釋:

  1. Java 8允許在接口中加入具體方法。接口中的具體方法有兩種,default方法和static方法,identity()就是Function接口的一個靜態方法。
  2. Function.identity()返回一個輸出跟輸入一樣的Lambda表達式對象,等價於形如t -> t形式的Lambda表達式。

上面的解釋是不是讓你疑問更多?不要問我爲什麼接口中可以有具體方法,也不要告訴我你覺得t -> tidentity()方法更直觀。我會告訴你接口中的default方法是一個無奈之舉,在Java 7及之前要想在定義好的接口中加入新的抽象方法是很困難甚至不可能的,因爲所有實現了該接口的類都要重新實現。試想在Collection接口中加入一個stream()抽象方法會怎樣?default方法就是用來解決這個尷尬問題的,直接在接口中實現新加入的方法。既然已經引入了default方法,爲何不再加入static方法來避免專門的工具類呢!

方法引用

諸如String::length的語法形式叫做方法引用(method references),這種語法用來替代某些特定形式Lambda表達式。如果Lambda表達式的全部內容就是調用一個已有的方法,那麼可以用方法引用來替代Lambda表達式。方法引用可以細分爲四類:

方法引用類別 舉例
引用靜態方法 Integer::sum
引用某個對象的方法 list::add
引用某個類的方法 String::length
引用構造方法 HashMap::new

我們會在後面的例子中使用方法引用。

收集器

相信前面繁瑣的內容已徹底打消了你學習Java函數式編程的熱情,不過很遺憾,下面的內容更繁瑣。但這不能怪Stream類庫,因爲要實現的功能本身很複雜。

收集器(Collector)是爲Stream.collect()方法量身打造的工具接口(類)。考慮一下將一個Stream轉換成一個容器(或者Map)需要做哪些工作?我們至少需要兩樣東西:

  1. 目標容器是什麼?是ArrayList還是HashSet,或者是個TreeMap
  2. 新元素如何添加到容器中?是List.add()還是Map.put()

如果並行的進行規約,還需要告訴collect() 3. 多個部分結果如何合併成一個。

結合以上分析,collect()方法定義爲<R> R collect(Supplier<R> supplier, BiConsumer<R,? super T> accumulator, BiConsumer<R,R> combiner),三個參數依次對應上述三條分析。不過每次調用collect()都要傳入這三個參數太麻煩,收集器Collector就是對這三個參數的簡單封裝,所以collect()的另一定義爲<R,A> R collect(Collector<? super T,A,R> collector)Collectors工具類可通過靜態方法生成各種常用的Collector。舉例來說,如果要將Stream規約成List可以通過如下兩種方式實現:

// 將Stream規約成List
Stream<String> stream = Stream.of("I", "love", "you", "too");
List<String> list = stream.collect(ArrayList::new, ArrayList::add, ArrayList::addAll);// 方式1
//List<String> list = stream.collect(Collectors.toList());// 方式2
System.out.println(list);

通常情況下我們不需要手動指定collect()的三個參數,而是調用collect(Collector<? super T,A,R> collector)方法,並且參數中的Collector對象大都是直接通過Collectors工具類獲得。實際上傳入的收集器的行爲決定了collect()的行爲

使用collect()生成Collection

前面已經提到通過collect()方法將Stream轉換成容器的方法,這裏再彙總一下。將Stream轉換成ListSet是比較常見的操作,所以Collectors工具已經爲我們提供了對應的收集器,通過如下代碼即可完成:

// 將Stream轉換成List或Set
Stream<String> stream = Stream.of("I", "love", "you", "too");
List<String> list = stream.collect(Collectors.toList()); // (1)
Set<String> set = stream.collect(Collectors.toSet()); // (2)

上述代碼能夠滿足大部分需求,但由於返回結果是接口類型,我們並不知道類庫實際選擇的容器類型是什麼,有時候我們可能會想要人爲指定容器的實際類型,這個需求可通過Collectors.toCollection(Supplier<C> collectionFactory)方法完成。

// 使用toCollection()指定規約容器的類型
ArrayList<String> arrayList = stream.collect(Collectors.toCollection(ArrayList::new));// (3)
HashSet<String> hashSet = stream.collect(Collectors.toCollection(HashSet::new));// (4)

上述代碼(3)處指定規約結果是ArrayList,而(4)處指定規約結果爲HashSet。一切如你所願。

使用collect()生成Map

前面已經說過Stream背後依賴於某種數據源,數據源可以是數組、容器等,但不能是Map。反過來從Stream生成Map是可以的,但我們要想清楚Mapkeyvalue分別代表什麼,根本原因是我們要想清楚要幹什麼。通常在三種情況下collect()的結果會是Map

  1. 使用Collectors.toMap()生成的收集器,用戶需要指定如何生成Mapkeyvalue
  2. 使用Collectors.partitioningBy()生成的收集器,對元素進行二分區操作時用到。
  3. 使用Collectors.groupingBy()生成的收集器,對元素做group操作時用到。

情況1:使用toMap()生成的收集器,這種情況是最直接的,前面例子中已提到,這是和Collectors.toCollection()並列的方法。如下代碼展示將學生列表轉換成由<學生,GPA>組成的Map。非常直觀,無需多言。

// 使用toMap()統計學生GPA
Map<Student, Double> studentToGPA =
     students.stream().collect(Collectors.toMap(Function.identity(),// 如何生成key
                                     student -> computeGPA(student)));// 如何生成value

情況2:使用partitioningBy()生成的收集器,這種情況適用於將Stream中的元素依據某個二值邏輯(滿足條件,或不滿足)分成互補相交的兩部分,比如男女性別、成績及格與否等。下列代碼展示將學生分成成績及格或不及格的兩部分。

// Partition students into passing and failing
Map<Boolean, List<Student>> passingFailing = students.stream()
         .collect(Collectors.partitioningBy(s -> s.getGrade() >= PASS_THRESHOLD));

情況3:使用groupingBy()生成的收集器,這是比較靈活的一種情況。跟SQL中的group by語句類似,這裏的groupingBy()也是按照某個屬性對數據進行分組,屬性相同的元素會被對應到Map的同一個key上。下列代碼展示將員工按照部門進行分組:

// Group employees by department
Map<Department, List<Employee>> byDept = employees.stream()
            .collect(Collectors.groupingBy(Employee::getDepartment));

以上只是分組的最基本用法,有些時候僅僅分組是不夠的。在SQL中使用group by是爲了協助其他查詢,比如1. 先將員工按照部門分組,2. 然後統計每個部門員工的人數。Java類庫設計者也考慮到了這種情況,增強版的groupingBy()能夠滿足這種需求。增強版的groupingBy()允許我們對元素分組之後再執行某種運算,比如求和、計數、平均值、類型轉換等。這種先將元素分組的收集器叫做上游收集器,之後執行其他運算的收集器叫做下游收集器(downstream Collector)。

// 使用下游收集器統計每個部門的人數
Map<Department, Integer> totalByDept = employees.stream()
                    .collect(Collectors.groupingBy(Employee::getDepartment,
                                                   Collectors.counting()));// 下游收集器

上面代碼的邏輯是不是越看越像SQL?高度非結構化。還有更狠的,下游收集器還可以包含更下游的收集器,這絕不是爲了炫技而增加的把戲,而是實際場景需要。考慮將員工按照部門分組的場景,如果我們想得到每個員工的名字(字符串),而不是一個個Employee對象,可通過如下方式做到:

// 按照部門對員工分佈組,並只保留員工的名字
Map<Department, List<String>> byDept = employees.stream()
                .collect(Collectors.groupingBy(Employee::getDepartment,
                        Collectors.mapping(Employee::getName,// 下游收集器
                                Collectors.toList())));// 更下游的收集器

如果看到這裏你還沒有對Java函數式編程失去信心,恭喜你,你已經順利成爲Java函數式編程大師了。

使用collect()做字符串join

這個肯定是大家喜聞樂見的功能,字符串拼接時使用Collectors.joining()生成的收集器,從此告別for循環。Collectors.joining()方法有三種重寫形式,分別對應三種不同的拼接方式。無需多言,代碼過目難忘。

// 使用Collectors.joining()拼接字符串
Stream<String> stream = Stream.of("I", "love", "you");
//String joined = stream.collect(Collectors.joining());// "Iloveyou"
//String joined = stream.collect(Collectors.joining(","));// "I,love,you"
String joined = stream.collect(Collectors.joining(",", "{", "}"));// "{I,love,you}"

collect()還可以做更多

除了可以使用Collectors工具類已經封裝好的收集器,我們還可以自定義收集器,或者直接調用collect(Supplier<R> supplier, BiConsumer<R,? super T> accumulator, BiConsumer<R,R> combiner)方法,收集任何形式你想要的信息。不過Collectors工具類應該能滿足我們的絕大部分需求,手動實現之間請先看看文檔。

Stream Pipelines

前面我們已經學會如何使用Stream API,用起來真的很爽,但簡潔的方法下面似乎隱藏着無盡的祕密,如此強大的API是如何實現的呢?比如Pipeline是怎麼執行的,每次方法調用都會導致一次迭代嗎?自動並行又是怎麼做到的,線程個數是多少?本節我們學習Stream流水線的原理,這是Stream實現的關鍵所在。

首先回顧一下容器執行Lambda表達式的方式,以ArrayList.forEach()方法爲例,具體代碼如下:

// ArrayList.forEach()
public void forEach(Consumer<? super E> action) {
    ...
    for (int i=0; modCount == expectedModCount && i < size; i++) {
        action.accept(elementData[i]);// 回調方法
    }
    ...
}

我們看到ArrayList.forEach()方法的主要邏輯就是一個for循環,在該for循環裏不斷調用action.accept()回調方法完成對元素的遍歷。這完全沒有什麼新奇之處,回調方法在Java GUI的監聽器中廣泛使用。Lambda表達式的作用就是相當於一個回調方法,這很好理解。

Stream API中大量使用Lambda表達式作爲回調方法,但這並不是關鍵。理解Stream我們更關心的是另外兩個問題:流水線和自動並行。使用Stream或許很容易寫入如下形式的代碼:

int longestStringLengthStartingWithA
        = strings.stream()
              .filter(s -> s.startsWith("A"))
              .mapToInt(String::length)
              .max();

上述代碼求出以字母A開頭的字符串的最大長度,一種直白的方式是爲每一次函數調用都執一次迭代,這樣做能夠實現功能,但效率上肯定是無法接受的。類庫的實現着使用流水線(Pipeline)的方式巧妙的避免了多次迭代,其基本思想是在一次迭代中儘可能多的執行用戶指定的操作。爲講解方便我們彙總了Stream的所有操作。

Stream操作分類
中間操作(Intermediate operations) 無狀態(Stateless) unordered() filter() map() mapToInt() mapToLong() mapToDouble() flatMap() flatMapToInt() flatMapToLong() flatMapToDouble() peek()
有狀態(Stateful) distinct() sorted() sorted() limit() skip()
結束操作(Terminal operations) 非短路操作 forEach() forEachOrdered() toArray() reduce() collect() max() min() count()
短路操作(short-circuiting) anyMatch() allMatch() noneMatch() findFirst() findAny()

Stream上的所有操作分爲兩類:中間操作和結束操作,中間操作只是一種標記,只有結束操作纔會觸發實際計算。中間操作又可以分爲無狀態的(Stateless)和有狀態的(Stateful),無狀態中間操作是指元素的處理不受前面元素的影響,而有狀態的中間操作必須等到所有元素處理之後才知道最終結果,比如排序是有狀態操作,在讀取所有元素之前並不能確定排序結果;結束操作又可以分爲短路操作和非短路操作,短路操作是指不用處理全部元素就可以返回結果,比如找到第一個滿足條件的元素。之所以要進行如此精細的劃分,是因爲底層對每一種情況的處理方式不同。

一種直白的實現方式

仍然考慮上述求最長字符串的程序,一種直白的流水線實現方式是爲每一次函數調用都執一次迭代,並將處理中間結果放到某種數據結構中(比如數組,容器等)。具體說來,就是調用filter()方法後立即執行,選出所有以A開頭的字符串並放到一個列表list1中,之後讓list1傳遞給mapToInt()方法並立即執行,生成的結果放到list2中,最後遍歷list2找出最大的數字作爲最終結果。程序的執行流程如如所示:

這樣做實現起來非常簡單直觀,但有兩個明顯的弊端:

  1. 迭代次數多。迭代次數跟函數調用的次數相等。
  2. 頻繁產生中間結果。每次函數調用都產生一次中間結果,存儲開銷無法接受。

這些弊端使得效率底下,根本無法接受。如果不使用Stream API我們都知道上述代碼該如何在一次迭代中完成,大致是如下形式:

int longest = 0;
for(String str : strings){
    if(str.startsWith("A")){// 1. filter(), 保留以A開頭的字符串
        int len = str.length();// 2. mapToInt(), 轉換成長度
        longest = Math.max(len, longest);// 3. max(), 保留最長的長度
    }
}

採用這種方式我們不但減少了迭代次數,也避免了存儲中間結果,顯然這就是流水線,因爲我們把三個操作放在了一次迭代當中。只要我們事先知道用戶意圖,總是能夠採用上述方式實現跟Stream API等價的功能,但問題是Stream類庫的設計者並不知道用戶的意圖是什麼。如何在無法假設用戶行爲的前提下實現流水線,是類庫的設計者要考慮的問題。

Stream流水線解決方案

我們大致能夠想到,應該採用某種方式記錄用戶每一步的操作,當用戶調用結束操作時將之前記錄的操作疊加到一起在一次迭代中全部執行掉。沿着這個思路,有幾個問題需要解決:

  1. 用戶的操作如何記錄?
  2. 操作如何疊加?
  3. 疊加之後的操作如何執行?
  4. 執行後的結果(如果有)在哪裏?

操作如何記錄?

注意這裏使用的是“操作(operation)”一詞,指的是“Stream中間操作”的操作,很多Stream操作會需要一個回調函數(Lambda表達式),因此一個完整的操作是<數據來源,操作,回調函數>構成的三元組。Stream中使用Stage的概念來描述一個完整的操作,並用某種實例化後的PipelineHelper來代表Stage,將具有先後順序的各個Stage連到一起,就構成了整個流水線。跟Stream相關類和接口的繼承關係圖示。

還有IntPipeline, LongPipeline, DoublePipeline沒在圖中畫出,這三個類專門爲三種基本類型(不是包裝類型)而定製的,跟ReferencePipeline是並列關係。圖中Head用於表示第一個Stage,即調用調用諸如Collection.stream()方法產生的Stage,很顯然這個Stage裏不包含任何操作;StatelessOpStatefulOp分別表示無狀態和有狀態的Stage,對應於無狀態和有狀態的中間操作。

Stream流水線組織結構示意圖如下:

圖中通過Collection.stream()方法得到Head也就是stage0,緊接着調用一系列的中間操作,不斷產生新的Stream。這些Stream對象以雙向鏈表的形式組織在一起,構成整個流水線,由於每個Stage都記錄了前一個Stage和本次的操作以及回調函數,依靠這種結構就能建立起對數據源的所有操作。這就是Stream記錄操作的方式。

操作如何疊加?

以上只是解決了操作記錄的問題,要想讓流水線起到應有的作用我們需要一種將所有操作疊加到一起的方案。你可能會覺得這很簡單,只需要從流水線的head開始依次執行每一步的操作(包括回調函數)就行了。這聽起來似乎是可行的,但是你忽略了前面的Stage並不知道後面Stage到底執行了哪種操作,以及回調函數是哪種形式。換句話說,只有當前Stage本身才知道該如何執行自己包含的動作。這就需要有某種協議來協調相鄰Stage之間的調用關係。

這種協議由Sink接口完成,Sink接口包含的方法如下表所示:

方法名 作用
void begin(long size) 開始遍歷元素之前調用該方法,通知Sink做好準備。
void end() 所有元素遍歷完成之後調用,通知Sink沒有更多的元素了。
boolean cancellationRequested() 是否可以結束操作,可以讓短路操作儘早結束。
void accept(T t) 遍歷元素時調用,接受一個待處理元素,並對元素進行處理。Stage把自己包含的操作和回調方法封裝到該方法裏,前一個Stage只需要調用當前Stage.accept(T t)方法就行了。

有了上面的協議,相鄰Stage之間調用就很方便了,每個Stage都會將自己的操作封裝到一個Sink裏,前一個Stage只需調用後一個Stage的accept()方法即可,並不需要知道其內部是如何處理的。當然對於有狀態的操作,Sink的begin()end()方法也是必須實現的。比如Stream.sorted()是一個有狀態的中間操作,其對應的Sink.begin()方法可能創建一個乘放結果的容器,而accept()方法負責將元素添加到該容器,最後end()負責對容器進行排序。對於短路操作,Sink.cancellationRequested()也是必須實現的,比如Stream.findFirst()是短路操作,只要找到一個元素,cancellationRequested()就應該返回true,以便調用者儘快結束查找。Sink的四個接口方法常常相互協作,共同完成計算任務。實際上Stream API內部實現的的本質,就是如何重載Sink的這四個接口方法

有了Sink對操作的包裝,Stage之間的調用問題就解決了,執行時只需要從流水線的head開始對數據源依次調用每個Stage對應的Sink.{begin(), accept(), cancellationRequested(), end()}方法就可以了。一種可能的Sink.accept()方法流程是這樣的:

void accept(U u){
    1. 使用當前Sink包裝的回調函數處理u
    2. 將處理結果傳遞給流水線下游的Sink
}

Sink接口的其他幾個方法也是按照這種[處理->轉發]的模型實現。下面我們結合具體例子看看Stream的中間操作是如何將自身的操作包裝成Sink以及Sink是如何將處理結果轉發給下一個Sink的。先看Stream.map()方法:

// Stream.map(),調用該方法將產生一個新的Stream
public final <R> Stream<R> map(Function<? super P_OUT, ? extends R> mapper) {
    ...
    return new StatelessOp<P_OUT, R>(this, StreamShape.REFERENCE,
                                 StreamOpFlag.NOT_SORTED | StreamOpFlag.NOT_DISTINCT) {
        @Override /*opWripSink()方法返回由回調函數包裝而成Sink*/
        Sink<P_OUT> opWrapSink(int flags, Sink<R> downstream) {
            return new Sink.ChainedReference<P_OUT, R>(downstream) {
                @Override
                public void accept(P_OUT u) {
                    R r = mapper.apply(u);// 1. 使用當前Sink包裝的回調函數mapper處理u
                    downstream.accept(r);// 2. 將處理結果傳遞給流水線下游的Sink
                }
            };
        }
    };
}

上述代碼看似複雜,其實邏輯很簡單,就是將回調函數mapper包裝到一個Sink當中。由於Stream.map()是一個無狀態的中間操作,所以map()方法返回了一個StatelessOp內部類對象(一個新的Stream),調用這個新Stream的opWripSink()方法將得到一個包裝了當前回調函數的Sink。

再來看一個複雜一點的例子。Stream.sorted()方法將對Stream中的元素進行排序,顯然這是一個有狀態的中間操作,因爲讀取所有元素之前是沒法得到最終順序的。拋開模板代碼直接進入問題本質,sorted()方法是如何將操作封裝成Sink的呢?sorted()一種可能封裝的Sink代碼如下:

// Stream.sort()方法用到的Sink實現
class RefSortingSink<T> extends AbstractRefSortingSink<T> {
    private ArrayList<T> list;// 存放用於排序的元素
    RefSortingSink(Sink<? super T> downstream, Comparator<? super T> comparator) {
        super(downstream, comparator);
    }
    @Override
    public void begin(long size) {
        ...
        // 創建一個存放排序元素的列表
        list = (size >= 0) ? new ArrayList<T>((int) size) : new ArrayList<T>();
    }
    @Override
    public void end() {
        list.sort(comparator);// 只有元素全部接收之後才能開始排序
        downstream.begin(list.size());
        if (!cancellationWasRequested) {// 下游Sink不包含短路操作
            list.forEach(downstream::accept);// 2. 將處理結果傳遞給流水線下游的Sink
        }
        else {// 下游Sink包含短路操作
            for (T t : list) {// 每次都調用cancellationRequested()詢問是否可以結束處理。
                if (downstream.cancellationRequested()) break;
                downstream.accept(t);// 2. 將處理結果傳遞給流水線下游的Sink
            }
        }
        downstream.end();
        list = null;
    }
    @Override
    public void accept(T t) {
        list.add(t);// 1. 使用當前Sink包裝動作處理t,只是簡單的將元素添加到中間列表當中
    }
}

上述代碼完美的展現了Sink的四個接口方法是如何協同工作的:

  1. 首先beging()方法告訴Sink參與排序的元素個數,方便確定中間結果容器的的大小;
  2. 之後通過accept()方法將元素添加到中間結果當中,最終執行時調用者會不斷調用該方法,直到遍歷所有元素;
  3. 最後end()方法告訴Sink所有元素遍歷完畢,啓動排序步驟,排序完成後將結果傳遞給下游的Sink;
  4. 如果下游的Sink是短路操作,將結果傳遞給下游時不斷詢問下游cancellationRequested()是否可以結束處理。

疊加之後的操作如何執行?

Sink完美封裝了Stream每一步操作,並給出了[處理->轉發]的模式來疊加操作。這一連串的齒輪已經咬合,就差最後一步撥動齒輪啓動執行。是什麼啓動這一連串的操作呢?也許你已經想到了啓動的原始動力就是結束操作(Terminal Operation),一旦調用某個結束操作,就會觸發整個流水線的執行。

結束操作之後不能再有別的操作,所以結束操作不會創建新的流水線階段(Stage),直觀的說就是流水線的鏈表不會在往後延伸了。結束操作會創建一個包裝了自己操作的Sink,這也是流水線中最後一個Sink,這個Sink只需要處理數據而不需要將結果傳遞給下游的Sink(因爲沒有下游)。對於Sink的[處理->轉發]模型,結束操作的Sink就是調用鏈的出口。

我們再來考察一下上游的Sink是如何找到下游Sink的。一種可選的方案是在PipelineHelper中設置一個Sink字段,在流水線中找到下游Stage並訪問Sink字段即可。但Stream類庫的設計者沒有這麼做,而是設置了一個Sink AbstractPipeline.opWrapSink(int flags, Sink downstream)方法來得到Sink,該方法的作用是返回一個新的包含了當前Stage代表的操作以及能夠將結果傳遞給downstream的Sink對象。爲什麼要產生一個新對象而不是返回一個Sink字段?這是因爲使用opWrapSink()可以將當前操作與下游Sink(上文中的downstream參數)結合成新Sink。試想只要從流水線的最後一個Stage開始,不斷調用上一個Stage的opWrapSink()方法直到最開始(不包括stage0,因爲stage0代表數據源,不包含操作),就可以得到一個代表了流水線上所有操作的Sink,用代碼表示就是這樣:

// AbstractPipeline.wrapSink()
// 從下游向上遊不斷包裝Sink。如果最初傳入的sink代表結束操作,
// 函數返回時就可以得到一個代表了流水線上所有操作的Sink。
final <P_IN> Sink<P_IN> wrapSink(Sink<E_OUT> sink) {
    ...
    for (AbstractPipeline p=AbstractPipeline.this; p.depth > 0; p=p.previousStage) {
        sink = p.opWrapSink(p.previousStage.combinedFlags, sink);
    }
    return (Sink<P_IN>) sink;
}

現在流水線上從開始到結束的所有的操作都被包裝到了一個Sink裏,執行這個Sink就相當於執行整個流水線,執行Sink的代碼如下:

// AbstractPipeline.copyInto(), 對spliterator代表的數據執行wrappedSink代表的操作。
final <P_IN> void copyInto(Sink<P_IN> wrappedSink, Spliterator<P_IN> spliterator) {
    ...
    if (!StreamOpFlag.SHORT_CIRCUIT.isKnown(getStreamAndOpFlags())) {
        wrappedSink.begin(spliterator.getExactSizeIfKnown());// 通知開始遍歷
        spliterator.forEachRemaining(wrappedSink);// 迭代
        wrappedSink.end();// 通知遍歷結束
    }
    ...
}

上述代碼首先調用wrappedSink.begin()方法告訴Sink數據即將到來,然後調用spliterator.forEachRemaining()方法對數據進行迭代(Spliterator是容器的一種迭代器,參閱),最後調用wrappedSink.end()方法通知Sink數據處理結束。邏輯如此清晰。

執行後的結果在哪裏?

最後一個問題是流水線上所有操作都執行後,用戶所需要的結果(如果有)在哪裏?首先要說明的是不是所有的Stream結束操作都需要返回結果,有些操作只是爲了使用其副作用(Side-effects),比如使用Stream.forEach()方法將結果打印出來就是常見的使用副作用的場景(事實上,除了打印之外其他場景都應避免使用副作用),對於真正需要返回結果的結束操作結果存在哪裏呢?

特別說明:副作用不應該被濫用,也許你會覺得在Stream.forEach()裏進行元素收集是個不錯的選擇,就像下面代碼中那樣,但遺憾的是這樣使用的正確性和效率都無法保證,因爲Stream可能會並行執行。大多數使用副作用的地方都可以使用歸約操作更安全和有效的完成。

// 錯誤的收集方式
ArrayList<String> results = new ArrayList<>();
stream.filter(s -> pattern.matcher(s).matches())
      .forEach(s -> results.add(s));  // Unnecessary use of side-effects!
// 正確的收集方式
List<String>results =
     stream.filter(s -> pattern.matcher(s).matches())
             .collect(Collectors.toList());  // No side-effects!

回到流水線執行結果的問題上來,需要返回結果的流水線結果存在哪裏呢?這要分不同的情況討論,下表給出了各種有返回結果的Stream結束操作。

返回類型 對應的結束操作
boolean anyMatch() allMatch() noneMatch()
Optional findFirst() findAny()
歸約結果 reduce() collect()
數組 toArray()
  1. 對於表中返回boolean或者Optional的操作(Optional是存放 一個 值的容器)的操作,由於值返回一個值,只需要在對應的Sink中記錄這個值,等到執行結束時返回就可以了。
  2. 對於歸約操作,最終結果放在用戶調用時指定的容器中(容器類型通過收集器指定)。collect(), reduce(), max(), min()都是歸約操作,雖然max()和min()也是返回一個Optional,但事實上底層是通過調用reduce()方法實現的。
  3. 對於返回是數組的情況,毫無疑問的結果會放在數組當中。這麼說當然是對的,但在最終返回數組之前,結果其實是存儲在一種叫做Node的數據結構中的。Node是一種多叉樹結構,元素存儲在樹的葉子當中,並且一個葉子節點可以存放多個元素。這樣做是爲了並行執行方便。關於Node的具體結構,我們會在下一節探究Stream如何並行執行時給出詳細說明。

本文詳細介紹了Stream流水線的組織方式和執行過程,學習本文將有助於理解原理並寫出正確的Stream代碼,同時打消你對Stream API效率方面的顧慮。如你所見,Stream API實現如此巧妙,即使我們使用外部迭代手動編寫等價代碼,也未必更加高效。

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