Java Stream API進階篇
本節我們將仍然以 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函數式編程的熱情,不過很遺憾,下面的內容更繁瑣。
收集器( 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