寫在前面
如果說函數式接口和lambda表達式是Java中函數式編程的基石,那么stream就是在基石上的最富麗堂皇的大廈。
只有熟悉了stream,你才能說熟悉了Java 的函數式編程。
本文主要介紹Stream的基礎概念和基本操作,讓大家對Stream有一個初步的理解。
本文的示例代碼可從gitee上獲取:https://gitee.com/cnmemset/javafp
stream的概念
首先,看一個典型的stream例子:
public static void simpleStream() {
List<String> words = Arrays.asList("hello", "world", "I", "love", "you");
int letterCount = words.stream()
.filter(s -> s.length() > 3) // 過濾掉長度小於等於3的單詞
.mapToInt(String::length) // 將每個單詞映射為單詞長度
.sum(); // 計算總長度 5(hello) + 5(world) + 4(love) = 14
// 輸出為 14
System.out.println(letterCount);
}
在上述例子中,我們將字符串列表 words 作為stream的數據源,然后執行了 filter-map-reduce 的系列操作(sum方法屬於 reduce 操作),后面會詳細介紹map和reduce 操作。如果你有大數據的編程經驗,會更容易理解map和reduce的含義。
stream的定義比較晦澀,大致可以理解為是一個支持串行或並行操作的數據元素序列。它具備以下幾個特點:
- 首先,stream不是一種數據結構,它並不存儲數據。stream是某個數據源之上的數據視圖。數據源可以是一個數組,或者是一個Collection類,甚至還可以是I/O channel。它通過一個計算管道(a pipeline of computational operations),對數據源的數據進行filter-map-reduce的操作。
- 其次,stream天生支持函數式編程。函數式編程的一個重要特點就是不會修改變量的值(沒有“副作用”)。而對stream的任何操作,都不會修改數據源中的數據。例如,對一個數據源為Collection的stream進行filter操作,只會生成一個新的stream對象,而不會真的刪除底層數據源中的元素。
- 第三,stream的許多操作都是惰性求值的(laziness-seeking)。惰性求值是指該操作只是對stream的一個描述,並不會馬上執行。這類惰性的操作在stream中被稱為中間操作(intermediate operations)。
- 第四,stream呈現的數據可以是無限的。例如Stream.generate可以生成一個無限的流。我們可以通過 limit(n) 方法來將一個無限流轉換為有限流,或者通過 findFirst() 方法終止一個無限流。
- 最后,stream中的元素只能被消費1次。和迭代器 Iterator 相似,當需要重復訪問某個元素時,需要重新生成一個新的stream。
stream的操作可以分成兩類,中間操作(intermediate operations)和終止操作(terminal operations)。一個stream管道(stream pipeline)是由一個數據源 + 0個或多個中間操作 + 1個終止操作組成的。
中間操作:
中間操作(intermediate operations)指的是將一個stream轉換為另一個stream的操作,譬如filter和map操作。中間操作都是惰性的,它們的作用僅僅是描述了一個新的stream,不會馬上被執行。
終止操作:
終止操作(terminal operations)則指的是那些會產生一個新值或副作用(side-effect)的操作,譬如count 和 forEach 操作。只有遇到終止操作時,之前定義的中間操作才會真正被執行。需要注意,當一個stream執行了一個終止操作后,它的狀態會變成“已消費”,不能再被使用。
為了證實“中間操作都是惰性的”,我們設計了一個實驗性的示例代碼:
public static void intermediateOperations() {
List<String> words = Arrays.asList("hello", "world", "I", "love", "you");
System.out.println("start: " + System.currentTimeMillis());
Stream<String> interStream = words.stream()
.filter(s -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// do nothing
}
return s.length() > 3;
});
IntStream intStream = interStream.mapToInt(s -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// do nothing
}
return s.length();
});
// 因為 filter 和 map 操作都屬於中間操作,並不會真正執行,
// 所以它們不受 Thread.sleep 的影響,耗時很短
System.out.println("after filter && map: " + System.currentTimeMillis());
int letterCount = intStream.sum();
// sum 屬於終止操作,會執行之前定義的中間操作,
// Thread.sleep 被真正執行了,耗時為 5(filter) + 3(mapToInt) = 8秒
System.out.println("after sum: " + System.currentTimeMillis());
// 輸出為 14
System.out.println(letterCount);
}
上述代碼的輸出類似:
start: 1633438922526
after filter && map: 1633438922588
after sum: 1633438930620
14
可以看到,上述代碼驗證了“中間操作都是惰性的”:打印“start”和打印“after filter && map”之間只隔了幾十毫秒,而打印“after sum”則在8秒之后,證明了只有在遇到 sum 操作后,filter 和 map 中定義的函數才真正被執行。
生成一個stream對象
Java 8中,引入了4個stream的接口:Stream、IntStream、LongStream、DoubleStream,分別對應Object類型,以及基礎類型int、long和double。如下圖所示:
在Java中,與stream相關的操作基本都是通過上述的4個接口來實現的,不會涉及到具體的stream實現類。要得到一個stream,通常不會手動創建,而是調用對應的工具方法。
常用的工具方法包括:
- Collection方法:Collection.stream() 或 Collection.parallelStream()
- 數組方法:Arrays.stream(Object[])
- 工廠方法:Stream.of(Object[]), IntStream.range(int, int) 或 Stream.iterate(Object, UnaryOperator) 等等
- 讀取文件方法:BufferedReader.lines()
- 類 java.nio.file.Files 中,也提供了Stream相關的API,例如 Files.list, Files.walk 等等
Stream的基本操作
我們以接口Stream為例,先介紹stream的一些基本操作。
forEach()
Stream中的forEach方法和Collection中的forEach方法相似,都是對每個元素執行指定的操作。
forEach方法簽名為:
void forEach(Consumer<? super T> action)
forEach方法是一個終止操作,意味着在它之前的所有中間操作都將會被執行,然后再馬上執行 action 。
filter()
filter方法的方法簽名是:
Stream<T> filter(Predicate<? super T> predicate)
filter方法是一個中間操作,它的作用是根據參數 predicate 過濾元素,返回一個只包含滿足predicate條件元素的Stream。
示例代碼:
public static void filterStream() {
List<String> words = Arrays.asList("hello", "world", "I", "love", "you");
words.stream()
.filter(s -> s.length() > 3) // 過濾掉長度小於等於3的單詞
.forEach(s -> System.out.println(s));
}
上述代碼輸出為:
hello
world
love
limit()
limit方法簽名為:
Stream<T> limit(long maxSize);
limit方法是一個短路型(short-circuiting)的中間操作,作用是將當前的Stream截斷,只留下最多 maxSize 個元素組成一個新的Stream。短路型(short-circuiting)的含義是指將一個無限元素的Stream轉換為一個有限元素的Stream。
例如,Random.ints 可以生成一個近似無限的隨機整數流,我們可以通過limit方法限制生成隨機整數的個數。示例代碼:
public static void limitStream() {
Random random = new Random();
// 打印左閉右開區間中 [1, 100) 中的 5 個隨機整數
random.ints(1, 100)
.limit(5)
.forEach(System.out::println);
}
上述代碼的輸出類似:
90
31
31
52
63
distinct()
distinct的方法簽名是:
Stream<T> distinct();
distinct是一個中間操作,作用是返回一個去除重復元素后的Stream。
作者曾遇到過一個有趣的場景:要生成10個不重復的隨機數字。可以結合Random.ints (Random.ints 可以生成一個近似無限的隨機整數流)方法來實現這個需求。示例代碼如下:
public static void distinctStream() {
Random random = new Random();
// 在左閉右開區間中 [1, 100) 隨機生成 10 個不重復的數字
random.ints(1, 100)
.distinct()
.limit(10)
.forEach(System.out::println);
/*
// 一個有趣的問題,如果 limit 方法放在 distinct 前面,
// 結果和上面的代碼有什么區別嗎?
// 歡迎加群討論。
random.ints(1, 100)
.limit(10)
.distinct()
.forEach(System.out::println);
*/
}
sorted()
sorted的方法簽名有兩個,分別是:
Stream<T> sorted();
Stream<T> sorted(Comparator<? super T> comparator);
前者是按照自然順序排序,后者是根據指定的比較器進行排序。
sorted方法是一個中間操作,和Collection.sort方法作用相似。
示例代碼如下:
public static void sortedStream() {
List<String> list = Arrays.asList("Guangdong", "Fujian", "Hunan", "Guangxi");
// 自然排序
list.stream().sorted().forEach(System.out::println);
System.out.println("===============");
// 對省份進行排序,首先按照長度排序,如果長度一樣,則按照字母順序排序
list.stream().sorted((first, second) -> {
int lenDiff = first.length() - second.length();
return lenDiff == 0 ? first.compareTo(second) : lenDiff;
}).forEach(System.out::println);
}
上述代碼的輸出為:
Fujian
Guangdong
Guangxi
Hunan
===============
Hunan
Fujian
Guangxi
Guangdong
結語
歡迎來到 Java 的函數式編程世界!!!
本文介紹了 Stream 的概念和基本操作。大家尤其要理解中間操作和終止操作的概念。
認真閱讀完本文后,你應該對 Stream 有了一個初步的認識,但這只是 Stream 編程的入門,更有趣更有挑戰性更有可玩性的還是隨后即將要介紹的 map-reduce 操作。