Java 8th 函数式编程:lambda 表达式
Lambda 表达式是 java 8th 给我们带来的几个重量级新特性之一,借用 lambda 表达式可以让我们的程序设计更加简洁。最近新的项目摒弃了 6th 版本,全面基于 8th 进行开发,本文将探讨 行为参数化 、lambda 表达式 ,以及 方法引用 等知识点。
一. 行为参数化
行为参数化简单的说就是将方法的逻辑以参数的形式传递到方法中,方法主体仅包含模板类通用代码,而一些会随着业务场景而变化的逻辑则以参数的形式传递到方法之中,采用行为参数化可以让程序更加的通用,以应对频繁变更的需求。
这里我们以 java 8 in action 中的例子进行说明。考虑一个业务场景,假设我们需要通过程序对苹果按照一定的条件进行筛选,我们先定义一个苹果实体:
1 | public class Apple { |
用户最开始的需求可能只是简单的希望能够通过程序筛选出绿色的苹果,于是我们可以很快的通过程序实现:
1 | public static List<Apple> filterGreenApples(List<Apple> apples) { |
如果过了一段时间用户提出了新的需求,希望能够通过程序筛选出红色的苹果,于是我们又需要针对性的添加了筛选红色苹果的功能:
1 | public static List<Apple> filterRedApples(List<Apple> apples) { |
更通用的实现是把颜色作为一个参数传递到方法中,这样就可以应对以后用户提出的各种颜色筛选需求:
1 | public static List<Apple> filterApplesByColor(List<Apple> apples, Color color) { |
这样的设计再也不用担心用户的颜色筛选需求变化了,但是不幸的是某一天用户提了一个需求希望能够筛选重量达到某一标准的苹果,有了前面的教训我们也把重量的标准作为参数传递给筛选函数:
1 | public static List<Apple> filterApplesByColorAndWeight(List<Apple> apples, Color color, float weight) { |
这样通过传递参数的方式真的好吗?如果筛选条件越来越多,组合模式越来越复杂,我们是不是需要考虑到所有的情况,并针对每一种情况都实现相应的策略呢?并且这些函数仅仅是筛选条件的部分不一样,其余部分都是相同的模板代码(遍历集合),这个时候我们就可以将行为进行 参数化 处理,让函数仅保留模板代码,而把筛选条件抽离出来当做参数传递进来,在 java 8th 之前,我们通过定义一个过滤器接口来实现:
1 | // 过滤器 |
通过上面行为抽象化之后,我们可以在具体调用的地方设置筛选条件,并将条件作为参数传递到方法中:
1 | public static void main(String[] args) { |
上面的行为参数化方式采用匿名类实现,这样的设计在 jdk 内部也经常采用,比如 java.util.Comparator
,java.util.concurrent.Callable
等,使用这类接口的时候,我们都可以在具体调用的地方用匿名类指定函数的具体执行逻辑,不过从上面的代码块来看,虽然很极客,但是不够简洁,在 java 8th 中我们可以通过 lambda 表达式进行简化:
1 | // 筛选苹果 |
如上述所示,通过 lambda 表达式极大精简了代码,同时行为参数让我们的程序极大的增强了可扩展性。
二. Lambda 表达式
2.1 Lambda 表达式的定义与形式
我们可以将 lambda 表达式定义为一种 简洁、可传递的匿名函数 ,首先我们需要明确 lambda 表达式本质上是一个函数,虽然它不属于某个特定的类,但具备参数列表、函数主体、返回类型,甚至能够抛出异常;其次它是匿名的,lambda 表达式没有具体的函数名称;lambda 表达式可以像参数一样进行传递,从而简化代码的编写,其格式定义如下:
- 参数列表 -> 表达式
- 参数列表 -> {表达式集合}
需要注意 lambda 表达式隐含了 return 关键字,所以在单个的表达式中,我们无需显式的写 return 关键字,但是当表达式是一个语句集合的时候则需要显式添加 return 关键字,并用花括号 {}
将多个表达式包围起来,下面看几个例子:
1 | // 1. 返回给定字符串的长度(隐含return语句) |
2.2 基于函数式接口使用 lambda 表达式
lambda 表达式的使用需要借助于 函数式接口 ,也就是说只有函数式接口出现地方,我们才可以将其用 lambda 表达式进行简化。那么什么是函数接口?函数接口的定义如下:
函数式接口定义为仅含有一个抽象方法的接口。
按照这个定义,我们可以确定一个接口如果声明了两个或两个以上的方法就不叫函数式接口,需要注意一点的是 java 8th 为接口的定义引入了默认的方法,我们可以用 default
关键字在接口中定义具备方法体的方法,这个在后面的文章中专门讲解,如果一个接口存在多个默认方法,但是仍然仅含有一个抽象方法,那么这个接口也符合函数式接口的定义。
2.2.1 自定义函数式接口
我们在前面例子中实现的苹果筛选接口就是一个函数式接口(定义如下),正因为如此我们可以将筛选逻辑参数化,并应用 lambda 表达式:
1 |
|
AppleFilter 仅包含一个抽象方法 accept(Apple apple)
,依照定义可以将其视为一个函数式接口。在定义时我们为该接口添加了 @FunctionalInterface
注解,用于标记该接口是一个函数式接口,不过该注解是可选的,当添加了该注解之后,编译器会限制了该接口只允许有一个抽象方法,否则报错,所以推荐为函数式接口添加该注解。
2.2.2 jdk 自带的函数式接口
jdk 为 lambda 表达式已经内置了丰富的函数式接口,如下表所示(仅列出部分):
函数式接口 | 函数描述符 | 原始类型特化 |
---|---|---|
Predicate |
T -> boolean | IntPredicate, LongPredicate, DoublePredicate |
Consumer |
T -> void | IntConsumer, LongConsumer, DoubleConsumer |
Funcation<T, R> | T -> R | IntFuncation |
Supplier |
() -> T | BooleanSupplier, IntSupplier, LongSupplier, DoubleSupplier |
UnaryOperator |
T -> T | IntUnaryOperator, LongUnaryOperator, DoubleUnaryOperator |
BinaryOperator |
(T, T) -> T | IntBinaryOperator, LongBinaryOperator, DoubleBinaryOperator |
BiPredicate<L, R> | (L, R) -> boolean | |
BiConsumer<T, U> | (T, U) -> void | |
BiFunction<T, U, R> | (T, U) -> R |
其中最典型的三个接口是 Predicate<T>
、Consumer<T>
,以及 Function<T, R>
,其余接口几乎都是对这三个接口的定制化,下面就这三个接口举例说明其用处,针对接口中提供的逻辑操作默认方法,留到后面介绍接口的 default 方法时再进行说明。
- Predicate
1 |
|
Predicate 的功能类似于上面的 AppleFilter,利用我们在外部设定的条件对于传入的参数进行校验并返回验证通过与否,下面利用 Predicate 对 List 集合的元素进行过滤:
1 | private <T> List<T> filter(List<T> numbers, Predicate<T> predicate) { |
上述方法的逻辑是遍历集合中的元素,通过 Predicate 对集合元素进行验证,并将验证不过的元素从集合中移除。我们可以利用上面的函数式接口筛选整数集合中的偶数:
1 | PredicateDemo pd = new PredicateDemo(); |
- Consumer
1 |
|
Consumer 提供了一个 accept 抽象函数,该函数接收参数并依据传递的行为应用传递的参数值,下面利用 Consumer 遍历字符串集合并转换成小写进行打印:
1 | private <T> void forEach(List<T> list, Consumer<T> consumer) { |
利用上面的函数式接口,遍历字符串集合并以小写形式打印输出:
1 | ConsumerDemo cd = new ConsumerDemo(); |
- Function<T, R>
1 |
|
Funcation 执行转换操作,输入类型 T 的数据,返回 R 类型的结果,下面利用 Function 对字符串集合转换成整型集合,并忽略掉不是数值型的字符:
1 | private List<Integer> parse(List<String> list, Function<String, Integer> function) { |
下面利用上面的函数式接口,将一个封装字符串的集合转换成整型集合,忽略不是数值形式的字符串:
1 | FunctionDemo fd = new FunctionDemo(); |
2.2.3 一些需要注意的事情
- 类型推断
在编码过程中,有时候可能会疑惑我们的调用代码会具体匹配哪个函数式接口,实际上编译器会根据参数、返回类型、异常类型(如果存在)等因素做正确的判定。在具体调用时,一些时候可以省略参数的类型以进一步简化代码:
1 | // 筛选苹果 |
- 局部变量
上面所有例子中使用的变量都是 lambda 表达式的主体参数,我们也可以在 lambda 中使用实例变量、静态变量,以及局部变量,如下代码为在 lambda 表达式中使用局部变量:
1 | int weight = 100; |
上述示例我们在 lambda 中使用了局部变量 weight,不过在 lambda 中使用局部变量还是有很多限制,学习初期 IDE 可能经常会提示我们 Variable used in lambda expression should be final or effectively final
的错误,即要求在 lambda 表达式中使用的变量必须 显式声明为 final 或事实上的 final 类型 。
为什么要限制我们直接使用外部的局部变量呢?主要原因在于内存模型,我们都知道实例变量在堆上分配的,而局部变量在栈上进行分配,lambda 表达式运行在一个独立的线程中,了解 JVM 的同学应该都知道栈内存是线程私有的,所以局部变量也属于线程私有,如果肆意的允许 lambda 表达式引用局部变量,可能会存在局部变量以及所属的线程被回收,而 lambda 表达式所在的线程却无从知晓,这个时候去访问就会出现错误,之所以允许引用事实上的 final(没有被声明为 final,但是实际中不存在更改变量值的逻辑),是因为对于该变量操作的是变量副本,因为变量值不会被更改,所以这份副本始终有效。这一限制可能会让刚刚开始接触函数式编程的同学不太适应,需要慢慢的转变思维方式。
实际上在 java 8th 之前,我们在方法中使用内部类时就已经遇到了这样的限制,因为生命周期的限制 JVM 采用复制的策略将局部变量复制一份到内部类中,但是这样会带来多个线程中数据不一致的问题,于是衍生了禁止修改内部类引用的外部局部变量这一简单、粗暴的策略,只不过在 8th 之前必须要求这部分变量采用 final 修饰,但是 8th 开始放宽了这一限制,只要求所引用变量是 “事实上” 的 final 类型即可。
三. 方法引用
方法引用可以更近一步的简化代码,有时候这种简化让代码看上去更加直观,先看一个例子:
1 | /* ... 省略apples的初始化操作 */ |
方法引用通过 ::
将方法隶属和方法自身连接起来,主要分为三类:
- 静态方法
1 | (args) -> ClassName.staticMethod(args) |
- 参数的实例方法
1 | (args) -> args.instanceMethod() |
- 外部的实例方法
1 | (args) -> ext.instanceMethod(args) |