本篇总结一下 Java 8 的特性之一 lambda 表达式与函数式编程
从匿名类到匿名函数再到 lambda 表达式
在很久以前(2013年前后) 第一次接触 Java 线程时, 线程是这么写的:
Runnable r = new MyTask();
Thread t = new Thread(r);
t.start();
class MyTask implements Runnable(){
@Override
public void run(){
//do something
}
}
(当然也可以使用扩展 Thread 类的方法新建 Thread 启动 Thread.)
出于对接口概念的不清晰, 这样的代码需要初学者比较长的时间才能熟悉. 后来, 如果只是为了简单执行多线程任务, 匿名类取代了这种明确定义接口的方法:
Thread t = new Thread(new Runnable(){
@Override
public void run(){
//do something
}
});
t.start();
类似的在学习安卓时, 也会接触到类似于注册接口相关的代码,例如
button.setOnClickListener(new View.OnClickListener(){
@Override
public void onClick(View view){
//do something
}
});
(除了对这种注册接口的方式需要花时间接触, 当时并没有意识到这种写法是设计模式控制反转 IoC 体现, 实现了按钮组件与按钮按下后的逻辑的解耦)
由于 Java 是完全面向对象的语言, 所有的方法必须在类体内定义, 所以会出现这种为了一个方法不得不专门定义一个类的情况. 即使是用匿名类, 也会写大量代码.
为了解决这一特性, Java 8 引入了 lambda 表达式 (\(\lambda\) expression) 的概念, 也被称闭包 (closure), lambda 表达式源自于 lambda calculus , 是逻辑学家用于表示抽象的函数. 在 Java 中, 一般写作
(arguments) ->expression
例如, 比较 String 的大小
(String str1, String str2) -> Integer.compare(str1.length, str2.length)
后面没有分号, 因为 lambda 表达式, 将抽象的函数以某类参数的形式传递, 而不是一条语句, 不是完成逻辑的工具.
例如, 我们打算对一个字符串数组 String[] 执行 Arrays.sort() 方法, 本方法要求待排序的数组必须实现 Comparable 接口, 或者传入 Comparator 比较器. 那么可以写作:
String[] arr;
//Initialize, update arr
...
Arrays.sort(arr, Comparator.comparing(s -> s.length))
Comparator.comparing() 需要传入一个 Function 变量, lambda 表达式在 Function 接口中被解析为 R apply(T t). 也就是说对于 arr 的每一个元素, 经过 lammbda 表达式后映射到元素的长度这一变量. 接下来在 Comparator.comparing() 中会按照长度这一变量构造比较器.
s -> s.length 实际上是简写, 其完整写法是
s -> {
return s.length;
}
变量类型为 Function<String, Integer>, 上述代码实际上的做法是:
Function<String , Integer> function = (String s) -> {
return s.length();
}
Arrays.sort(arr, Comparator.comparing(function));
参数类型String可以不写, 当”->”右边需要执行的代码只有一条语句,return和大括号都可以省略不写.
为了进一步简略上述代码还可以写作:
Arrays.sort(arr, Comparator.comparing(String::length));
双冒号的表示方法被称为方法引用, 只要抽象方法的参数列表与方法参数的列表保持一致即可.
方法引用除了可以引用对象方法还可以引用类的静态方法或者构造器
object::instanceMethod
Class::staticMethod
Class::instanceMethod
例如, 我们按照字典顺序比较:
Arrays.sort(arr, String::compareToIgnoreCase);
Syntactic Sugar
lambda 表达式为 Java 减弱了可读性, 但是带来了很多语法特性. 例如可以像写一个正常函数一样写一个 lambda 表达式:
Consumer<String> println = System.out::println;
Function<String , String> reverse = s -> {
StringBuilder builder = new StringBuilder(s);
StringBuilder temp = new StringBuilder();
while(builder.length() > 0) {
temp.append(builder.charAt(builder.length() - 1));
builder.deleteCharAt(builder.length() - 1);
}
return temp.toString();
};
println.accept(reverse.apply("SHELDON COOPER"));
可以把省略参数类型, 因为编译器会去自动推断类型. 例如不用 Comparor.comparing() 构造比较器, 而尝试自己定义一个比较器.
Comparator<String> comp = (arg1, arg2) -> Integer.compare(arg1.length(), arg2.length())
对参数添加修饰符
(final String name) -> ... (@NOTNULL String name) -> ...
遗憾的是, 在 Java 中还是无法像 Python 或者其他语言一样定义一个方法类型, 甚至不能把 lambda 表达式赋给 Object, 因为 Object 没有 @FunctionalInterface, 根本无法赋值. 所以相对于比较奔放的高级解释语言, Java 依旧保留了强类型语言的操守.
所以 Java8 对待 lambda 表达式的态度还是比较谨慎与保守, 只做到别人有的 Java 也有了, 但是还是有点四不像的意味.
(2018年3月20日, 恭喜 Java 10 喜提var类型推断, 往弱类型又迈出了一大步.)
Java.util.stream
Java8 的 java.util.stream 是 Java8 对函数式编程最重要的体现, 其实现方式大都是通过 lambda 表达式来实现的.
Stream 有许多特定的接口, 包含一个基础接口 BaseStream, 四个接口继承了这一基础接口: IntStream, LongStream, DoubleStream, Stream. 正如前三种接口名字所示, 解决的便是 int, long, double 类型的 Stream, 剩下的一种 Stream 则解决其他参数类型的Stream. 这样设计的目的是为了提高特定情境的性能.
Stream 实例大都来自于 Collection.stream() 方法, 在先前对 Java.util.Collection 的总结中, 无论是 Set, List 都实现了 Collection 接口, Map 通过其内部的函数也可以生成 Collection. 但是 Stream 与 Collection 的区别在于:
1.stream 不是一种数据结构, 在 Collection 背后最后都是 Object[] elementData, 都在内存的相应位置可以找到. Stream 只是数据结构的一种视图或者一种打开方式. 类似于文本文档, 既可以按照 .md 打开, 可以按照 .json 打开, 也可以按照 .csv 打开, 如何打开是按照具体的文件格式来的, stream 就是定义了数据的读取方式
2.stream 的任何操作都不会修改背后的数据, 例如 stream 的过滤只是产生了一个不包含过略掉元素的新的 stream:
Stream stream = arrayList.stream().filter(x -> x % 2 == 1);
在上面的这个例子中, arrayList 为一个泛型是 Integer 的 arrayList, stream 通过 filter 将偶数过滤掉, 但是 arrayList 里面的任何元素都没有发生变化.
3.stream 的操作只有在真正需要时才会被执行(中间操作), 例如接 2 中的例子, 在后面执行了很多其他代码之后, 再去执行
stream.forEach(System.out::print);
filter 一直到这里输出时才会执行 filter 中的代码(结束操作). 前面的设置过滤器只是相当于为其添加了管道, 而不会真正使用管道. 只有在需要的时候才会被执行
4.stream 是具有可消费的特性的, 使用过后的 stream 无法再被使用, 这一点上类似与迭代器, 当迭代完毕即被销毁. 例如接 3 中的例子, 继续执行:
stream.filter(x -> null != x).forEach(System.out::print);
会抛出错误:
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed.
根据 3 与 4 的特点, 对 stream 的操作分为两类, 中间操作与结束操作:
中间操作是惰式执行, 调用中间操作只会生成一个标记了该操作的新stream,仅此而已.
结束操作会触发实际计算, 计算发生时会把所有中间操作以pipeline的方式执行, 这样可以减少迭代次数. 计算完成之后stream就会失效.
中间操作包括: concat() distinct() filter() flatMap() limit() map() peek() skip() sorted() parallel() sequential() unordered()
结束操作包括: allMatch() anyMatch() collect() count() findAny() findFirst() forEach() forEachOrdered() max() min() noneMatch() reduce() toArray()
Java.util.stream.collect()
Java8 函数式编程最重要的函数为 collect() 函数, collect() 一般用作将 stream 生成 Collection, Map, String, 或者可以直接生成任何形式的收集器.
collect()的最为泛化的参数是 collect(Supplier
Stream<String> stream = Stream.of("Denmark", "Iceland", "Sweden", "Norway", "Finland");
List<String> list = stream.collect(ArrayList::new, ArrayList::add, ArrayList::addAll);
第一个参数指定了目标容器, 第二个参数指定了元素添加到容器中方法, 第三个参数指定了并行情况下多个容器如何合并成一个.
当然为了操作的简便, 可以直接去执行:
List<String> list = stream.collect(Collection.toList());
等价于
List<String> list = stream.collect(ArrayList::new, List::add, (left, right) -> {left.addAll(right); return left;});
相对于 toList() 生成 List(实际上是ArrayList), toMap() 生成 Map 相对比较复杂.
Collection.toMap() 支持 2 个到 4 个参数输入, 至少需要指定 keyMapper 与 valueMapper, 这两个都为 Function 类型, 用于指示如何生成 key 与 value.
例如需要维护一个员工类, 变量很只包含工号与姓名:
Class Employee {
private int id;
private String name;
//getter and setter
...
}
已经存在一个 List
Map<Integer , String> staffMap = staffList.toStream().collect(Staff::getId, staff::getName);
方法引用作为 Function 类型传入.
默认情况下 mergeFunction 如果出现了重复的 key 则会抛出 IllegalStateException, 可以按照自己的需求规定 mergeFunction.
除此之外, 默认情况下构造的为 HashMap, 也可以自己定义 Map 类型, 例如 LinkedHashMap 等:
List<String> arr = Arrays.asList("Argentina ","Australia ", "Croatia ", "Denmark ","France ",
"Russia ","Portugal ", "Spanish ");
Map<Integer , String> hashMap = arr.stream().collect(Collectors.toMap( x -> new Random().nextInt(1000),
x -> arr.get(new Random().nextInt(arr.size())),
(pre, next) -> next ));//随机分配一个0-999的随机数, 然后随机分配一个16强队伍
Map<Integer , String> anotherSortedMap = hashMap.entrySet().stream().sorted().
collect(Collectors.toMap(Map.Entry::getKey,Map.Entry::getValue,
(pre, next) -> next, LinkedHashMap::new));//按照随机数大小重新排序, LinkedHashedMap 是为了保证 HashMap 的内部顺序
总的而言, \(\lambda\) 表达式与函数式编程关系紧密, Java 8 这一特性为开发带来许多优良的便利, 底层实现的思路也应当值得关注. 期待 Java 语言的发展进程与新特性的普及速度.