十、函数式编程
文章目录
函数式编程的一个特点就是,允许把函数本身作为参数传入另一个函数,还允许返回一个函数。
从 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
。