上一節介紹了部分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轉換成List、Set和Map。雖然代碼語義很明確,可是我們仍然會有幾個疑問:
Function.identity()
是干什么的?String::length
是什么意思?- Collectors是個什么東西?
接口的靜態方法和默認方法
Function是一個接口,那么Function.identity()
是什么意思呢?這要從兩方面解釋:
- Java 8允許在接口中加入具體方法。接口中的具體方法有兩種,default方法和static方法,
identity()
就是Function接口的一個靜態方法。 Function.identity()
返回一個輸出跟輸入一樣的Lambda表達式對象,等價於形如t -> t
形式的Lambda表達式。
上面的解釋是不是讓你疑問更多?不要問我為什么接口中可以有具體方法,也不要告訴我你覺得t -> t
比identity()
方法更直觀。我會告訴你接口中的default方法是一個無奈之舉,在Java 7及之前要想在定義好的接口中加入新的抽象方法是很困難甚至不可能的,因為所有實現了該接口的類都要重新實現。試想在Collection接口中加入一個stream()
抽象方法會怎樣?default方法就是用來解決這個尷尬問題的,直接在接口中實現新加入的方法。既然已經引入了default方法,為何不再加入static方法來避免專門的工具類呢!
方法引用
諸如String::length
的語法形式叫做方法引用(method references),這種語法用來替代某些特定形式Lambda表達式。如果Lambda表達式的全部內容就是調用一個已有的方法,那么可以用方法引用來替代Lambda表達式。方法引用可以細分為四類:
方法引用類別 | 舉例 |
---|---|
引用靜態方法 | Integer::sum |
引用某個對象的方法 | list::add |
引用某個類的方法 | String::length |
引用構造方法 | HashMap::new |
我們會在后面的例子中使用方法引用。
收集器
相信前面繁瑣的內容已徹底打消了你學習Java函數式編程的熱情,不過很遺憾,下面的內容更繁瑣。
<img src="http://images2015.cnblogs.com/blog/939998/201703/939998-20170314192733276-1662918719.png" width="500px", align="right" alt="Stream.collect_parameter" />
收集器(Collector)是為Stream.collect()
方法量身打造的工具接口(類)。考慮一下將一個Stream轉換成一個容器(或者Map)需要做哪些工作?我們至少需要兩樣東西:
- 目標容器是什么?是ArrayList還是HashSet,或者是個TreeMap。
- 新元素如何添加到容器中?是
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轉換成List或Set是比較常見的操作,所以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是可以的,但我們要想清楚Map的key和value分別代表什么,根本原因是我們要想清楚要干什么。通常在三種情況下collect()
的結果會是Map:
- 使用
Collectors.toMap()
生成的收集器,用戶需要指定如何生成Map的key和value。 - 使用
Collectors.partitioningBy()
生成的收集器,對元素進行二分區操作時用到。 - 使用
Collectors.groupingBy()
生成的收集器,對元素做group操作時用到。
情況1:使用toMap()
生成的收集器,這種情況是最直接的,前面例子中已提到,這是和Collectors.toCollection()
並列的方法。如下代碼展示將學生列表轉換成由<學生,GPA>組成的Map。非常直觀,無需多言。
// 使用toMap()統計學生GPA
Map<Student, Double> studentToGPA =
students.stream().collect(Collectors.toMap(Functions.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工具類應該能滿足我們的絕大部分需求,手動實現之間請先看看文檔。
參考文獻
- https://docs.oracle.com/javase/8/docs/api/java/util/stream/package-summary.html#package.description
- https://docs.oracle.com/javase/tutorial/java/javaOO/methodreferences.html
- https://docs.oracle.com/javase/8/docs/api/java/util/stream/Collector.html
- https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html
- https://docs.oracle.com/javase/8/docs/api/java/util/stream/Collectors.html