Java中的函數式編程(六)流Stream基礎
寫在前面
如果說函數式介面和lambda表達式是Java中函數式編程的基石,那麼stream就是在基石上的最富麗堂皇的大廈。
只有熟悉了stream,你才能說熟悉了Java 的函數式編程。
本文主要介紹Stream的基礎概念和基本操作,讓大家對Stream有一個初步的理解。
本文的示例程式碼可從gitee上獲取://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 操作。