十、函數式編程
文章目錄
函數式編程的一個特點就是,允許把函數本身作爲參數傳入另一個函數,還允許返回一個函數。
從 Java8 開始支持函數式編程。
1、Lambda表達式
Java的方法分爲實例方法,例如 Integer
定義的 equals()
方法:
public final class Integer {
boolean equals(Object o) {
...
}
}
以及靜態方法,例如 Integer
定義的 parseInt()
方法:
public final class Integer {
public static int parseInt(String s) {
...
}
}
上面的方法,本質上都相當於過程式語言的函數。只不過Java的實例方法隱含的傳入了一個 this
變量。
函數式編程是把函數作爲基本元算單元,函數可以作爲變量,可以接受函數,還可以返回函數。
在Java程序中,我們經常遇到一些但方法接口,即一個接口只定義一個方法:
- Comparator
- Runnable
- Callable
以 Comparator
爲例,從 Java8 開始,我們可以使用 Lambda表達式替換單方法接口:
@Test
public void m0() {
String[] array = new String[] { "Apple", "Orange", "Banana", "Lemon" };
Arrays.sort(array, (s1, s2) -> {
return s1.compareTo(s2);
});
System.out.println(String.join(", ", array));
}
和 JavaScript
的箭頭函數一樣,在方法中只有一句時,可以省略 {}
:
Arrays.sort(array, (s1, s2) -> s1.compareTo(s2));
在Lambda 表達式中,它只需要寫出方法定義,參數爲(s1, s2),參數的類型可以省略,因爲編譯器可以自動推斷出
String
類型。返回值的類型也是由編譯器自動推斷。
1.1 FunctionalInterface
只定義了單方法的接口稱之爲 FunctionalInterface
,用註解 @FunctionalInterface
標記。例如, Callable
接口:
@FunctionalInterface
public interface Callable<V> {
/**
* Computes a result, or throws an exception if unable to do so.
*
* @return computed result
* @throws Exception if unable to compute a result
*/
V call() throws Exception;
}
在接口Comparator
中:
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2);
boolean equals(Object obj);
default Comparator<T> reversed() {
return Collections.reverseOrder(this);
}
default Comparator<T> thenComparing(Comparator<? super T> other) {
...
}
...
}
雖然 Comparator
接口有很多方法,但只有一個抽象方法 int compare(T o1, T o2)
,其他方法都是default
和static
方法。boolean equals(object obj)
是 Object
定義的方法,不算在接口方法內。
2、方法引用
使用 Lambda表達式,我們可以不必編寫 FunctionalInterface
接口的實現類,從而簡化了代碼。
當然,除了 Lambda 表達式,還可以直接傳入方法引用,例如:
public class LambdaTest {
@Test
public void m1() {
String[] array = new String[] { "Apple", "Orange", "Banana", "Lemon" };
Arrays.sort(array, LambdaTest::cmp);
System.out.println(String.join(", ", array));
}
static int cmp(String s1, String s2) {
return s1.compareTo(s2);
}
}
上述代碼在 Arrays.sort()
中傳入了靜態方法 cmp
的引用,用 LambdaTest::cmp
表示。
方法引用,就是說某個方法簽名和接口一樣,就可以直接傳入方法引用。
ps:方法簽名只看參數類型和返回類型,不看方法名稱,也不看類的繼承關係。
2.1 構造方法引用
如果要把一個String
數組轉化爲 Person
數組,在以前,我們可能會:
@Test
public void m2() {
Stream<String> stream = Stream.of("Bob", "Alice", "Tim");
List<Person> list = new ArrayList<>();
stream.forEach(s -> {
list.add(new Person(s));
});
System.out.println(list);
}
現在我們可以引用 Person
的構造方法來實現 String
到 Person
的轉化:
public class Main {
public static void main(String[] args) {
List<String> names = List.of("Bob", "Alice", "Tim");
List<Person> persons = names.stream().map(Person::new).collect(Collectors.toList());
System.out.println(persons);
}
}
class Person {
String name;
public Person(String name) {
this.name = name;
}
public String toString() {
return "Person:" + this.name;
}
}
3、使用Stream
從 Java8 開始,引入了一種全新的流式API:Stream API。位於 java.util.stream
包中。
這個 stream
和 java.io
中的 InputStram
和 OutputStream
不一樣,它代表的是任意Java對象的序列。
java.io | java.util.stream | |
---|---|---|
存儲 | 順序讀寫的byte 或char |
順序輸出的任意Java對象實例 |
用途 | 序列化至文件或網絡 | 內存計算/業務邏輯 |
java.util.stream
和 List
也不一樣,List
存儲的每個元素都已經在內存中存儲,而 stream
輸出的對象可能並沒有存儲在內存中,而是實時計算得到的,且是惰性計算的。
簡單來說,List
就是一個個實實在在的元素,這些元素也已經存儲在內存中,用戶可以用它來操作其中的元素(例如,遍歷、排序等)。而 stream
可能就根本沒有分配內存。下面,看一個例子:
如果想用 List
表示全體的自然數,這是不可能的,因爲自然數是無窮的,但內存是有限的。
如果我們使用 stram
就可以做到,如下:
Stream<BigInteger> naturals = createNaturalStream(); // 全體自然數
上面的 createNaturalStram() 方法沒有實現。
也可以對 Stream
計算,例如,對每個自然數做一個平方:
Stream<BigInteger> naturals = createNaturalStream(); // 全體自然數
Stream<BigInteger> streamNxN = naturals.map(n -> n.multiply(n)); // 全體自然數的平方
上面的 streamNxN
也有無限多個元素,如果要打印它,可以用 limit()
方法截取前100個元素,最後用 forEach()
處理每個元素。
Stream<BigInteger> naturals = createNaturalStream();
naturals.map(n -> n.multiply(n)) // 1, 4, 9, 16, 25...
.limit(100)
.forEach(System.out::println);
Stream
惰性計算的特點:一個Stream
轉換爲另一個時,實際上只存儲了轉化規則,並不會有任何的計算。例如,上面的例子中,只有在調用forEach
確實需要輸出元素時,纔會進行計算。
3.1 創建Stream
Stream.of()
使用 Stream.of()
創建雖然沒有實質性用途,但在測試時很方便。
@Test
public void m1() {
Stream<String> stream = Stream.of("A", "B", "C", "D");
// forEach()方法相當於內部循環調用,
// 可傳入符合Consumer接口的void accept(T t)的方法引用:
stream.forEach(System.out::println);
}
基於數組或Collection
可以基於一個數組或者 Collection
創建Stream
,這樣Stream
在輸出時的元素也就是數組或 Collection
的元素。
@Test
public void m1() {
//數組變成 `Stream` 使用 `Arrays.stream()` 方法
Stream<String> stream1 = Arrays.stream(new String[] { "A", "B", "C" });
//對於 `Collection`(List/Set/Queue等),直接調用 `stream()` 方法即可。
Stream<String> stream2 = List.of("X", "Y", "Z").stream();
stream1.forEach(System.out::println);
stream2.forEach(System.out::println);
}
上述創建
Stream
的方法都是把一個現有的序列變成Stream
,它的元素都是固定的。
基於Supplier
創建 Stream
還可以通過 Stream.generate()
方法,它需要傳入的是一個 Supplier
對象:
Stream<String> s = Stream.generate(Supplier<String> sp);
這時 Stream
保存的不是具體的元素,而是一種規則,在需要產生一個元素時,Stream
自己回去調用 Supplier.get()
方法。
例子,通過Stream
不斷的產生自然數:
public class StreamTest {
@Test
public void m1() {
Stream<Integer> natural = Stream.generate(new NaturalSupplier());
natural.limit(10).forEach(System.out::println);
}
}
class NaturalSupplier implements Supplier<Integer> {
int n = 0;
@Override
public Integer get() {
return ++n;
}
}
即使int
的範圍有限,但如果用 List
存儲,也會佔用巨大的內存,而使用 Stream
時,因爲只保存計算規則,所以幾乎不佔用空間。
在調用 forEach()
或者 count()
進行最終求值前,一定要把 Stream
的無限序列變成有限序列,否則會因爲不能完成這個計算進入死循環。
其他方法
創建 Stream
的第三種方法是通過一些API提供的接口,直接獲得 Stream
。
例如,Files
類的 lines()
方法把一個文件變成 Stream
,每個元素代表文件的一行內容:
try (Stream<String> lines = Files.lines(Paths.get("/path/to/file.txt"))) {
...
}
在需要對文本文件按行遍歷時,該方法十分有用。
正則表達式的Pattern
對象有一個splitAsStream()
方法,可以直接把一個長字符串分割成Stream
序列而不是數組:
Pattern p = Pattern.compile("\\s+");
Stream<String> s = p.splitAsStream("The quick brown fox jumps over the lazy dog");
s.forEach(System.out::println);
基本類型
因爲Java的泛型不支持基本類型,所以不能使用 Stream<int>
這種類型。爲了方便,Java標準庫提供了 IntStream
、LongStream
、DoubleStream
這3種使用基本類型的Stream
,它們的使用方法和範型Stream
沒有大的區別。
3.2 使用map
Java中的map
、filter
、 reduce
類似於JavaScript
中高階函數的用法。
類似的用法,可以寫出下面的例子:
@Test
public void m2() {
List.of(1, 2, 3, 4)
.stream()
.map(n -> n * n) //求平方
.forEach(System.out::println); // 打印
}
3.3 使用filter
@Test
public void m2() {
IntStream.of(1, 2, 3, 4, 5, 6, 7, 8, 9)
.filter(n -> n % 2 != 0)
.forEach(System.out::println);
}
3.4 使用reduce
map()
和filter()
都是Stream
的轉換方法,而Stream.reduce()
則是Stream
的一個聚合方法。
@Test
public void m2() {
int n = IntStream.of(1, 2, 3, 4, 5, 6, 7, 8, 9)
.reduce(0, (x, y) -> x + y);
System.out.println(n);
}
上面的代碼如果去掉初始值,會返回一個
Optional<Integer>
:Optional<Integer> opt = stream.reduce((acc, n) -> acc + n); if (opt.isPresent) { System.out.println(opt.get()); }
這是因爲
Stream
的元素有可能是0個,這樣就沒法調用reduce()
方法,因此返回Optional
對象,需要怕斷結果是否存在。
對
Stream
的操作分爲兩類:
- 轉換操作:把一個
Stream
轉化爲 另一個Stream
,例如map()
和filter()
,- 聚合操作:會對
Stream
的每個元素進行計算,得到一個確定的結果,例如reduce()
,這類操作會觸發計算。
3.5 輸出集合
輸出爲List
因爲需要把 Stream
的元素保存到集合,而集合保存的都是確定的 Java對象,所以把 Stream
變成 List
是一個聚合操作。
@Test
public void m2() {
Stream<String> stream = Stream.of("Apple", "", null, "Pear", " ", "Orange");
List<String> lists =
stream.filter(s -> s != null && !s.trim().isEmpty()).collect(Collectors.toList());
System.out.println(lists);
}
把Stream
的每個元素收集到List
的方法是調用collect()
並傳入Collectors.toList()
對象,它實際上是一個Collector
實例,通過類似reduce()
的操作,把每個元素添加到一個收集器中(實際上是ArrayList
)。
類似的,collect(Collectors.toSet())
可以把Stream
的每個元素收集到Set
中。
輸出爲數組
@Test
public void m2() {
Stream<String> stream = Stream.of("Apple", "Pear", "Orange");
String[] array = stream.toArray(String[]::new);
System.out.println(String.join(", ", array));
}
傳入的“構造方法”是
String[]::new
,它的簽名實際上是IntFunction
定義的String[] apply(int)
,即傳入int
參數,獲得String[]
數組的返回值
輸出爲Map
對於 Stream
的元素輸出到 Map
,需要分別把元素映射爲key和value:
@Test
public void m2() {
Stream<String> stream = Stream.of("APPL:Apple", "MSFT:Microsoft");
Map<String, String> map = stream
.collect(Collectors.toMap(
// 把元素s映射爲key:
s -> s.substring(0, s.indexOf(':')),
// 把元素s映射爲value:
s -> s.substring(s.indexOf(':') + 1)));
System.out.println(map);
}
分組輸出
Stream
還有一個強大的功能就是可以按組輸出。
@Test
public void m2() {
Stream<String> stream =
Stream.of("Apple", "Banana", "Blackberry", "Coconut", "Avocado", "Cherry", "Apricots");
Map<String, List<String>> groups = stream
.collect(Collectors.groupingBy(s -> s.substring(0, 1), Collectors.toList()));
System.out.println(groups);
}
//輸出結果
{A=[Apple, Avocado, Apricots], B=[Banana, Blackberry], C=[Coconut, Cherry]}
在上面使用到的 Collectors.groupinigBy()
方法,需要提供兩個函數:
- 第一個是分組的key,
s -> s.substring(0, 1)
表示只要首字母相同的String
分到一組。 - 第二個是分組的value,這裏直接輸出爲
List
。