“越来越多的迹象标明,函数式编程已经不再是学术界的最爱,开始大步地在业界投入使用。”
Lambda 表达式是Java 8 发布的最重要的特性,这里对 Java Lambda 表达式做一些记录。
Lambda 表达式的引入
Java 是一门面向对象语言,而且也一直在完善自己的面向对象特性,除了基本的数据类型之外,一切都是对象,包括数组在内,这就意味着 Java 中定义的方法或者说函数不能独立于类存在,也不能将某一个方法或函数作为参数传递。
而在函数式变成语言中,函数是一等公民,可以独立存在,可以将函数作为一个变量一样看待,也可以作为参数传递给其他函数,函数式编程的好处就不一一赘述,可以参考阮一峰的博文 函数式编程初探。
Java 8 新增了函数式接口(Functional Interface)和 Lambda 表达式来引入函数式编程特性,新特性的引入大大提高了 Java 开发的效率。
Lambda 表达式结构
Lambda 表达式是一种匿名函数(虽然这对 Java 而言这并不完全正确,但是可以暂时简单这么认为),简单地说,它是没有声明的方法,也没有访问修饰符、返回值声明和名字。
你可以认为它是一种“临时工”,在你需要使用某个方法的地方写上它。如果某个方法只使用一次而且定义很简短,使用这种“临时工”尤其有效,这样,你就不必在类中费力写声明与方法了。
Java 中的 Lambda 表达式通常使用 (argument) -> (body)
语法书写,例如:
(arg1, arg2...) > { body } |
Lambda 表达式应该满足以下条件:
- 一个 Lambda 表达式可以有零个或多个参数。
- 参数的类型既可以明确声明,也可以根据上下文来推断。例如:
(int a)
与(a)
效果相同 - 所有参数需包含在圆括号内,参数之间用逗号相隔。例如:
(a, b)
或(int a, int b)
或(String a, int b, float c)
。 - 空圆括号代表参数集为空。例如:
() -> 42
。 - 当只有一个参数,且其类型可推导时,
()
可省略。例如:a -> return a*a
。 - Lambda 表达式的主体可包含 0 条或多条语句。
- 如果 Lambda 表达式的主体只有一条语句,花括号
{}
可省略。匿名函数的返回类型与该主体表达式一致。 - 如果 Lambda 表达式的主体包含一条以上语句,则表达式必须包含在花括号
{}
中(形成代码块)。匿名函数的返回类型与代码块的返回类型一致,若没有返回则为空。
下面给出一些合法的 Lambda 表达式的例子:
(int a, int b) -> { return a + b; } |
函数式接口(Functional Interface)
在讲函数式接口之前先说一下 Java 中的标记接口(Marker Interface),标记接口是指没有属性和方法的空接口,例如 java.io.Serializable
接口就是空接口:
public interface Serializable { |
这种接口只是作为一种标记存在,并不是要使用什么属性或方法,类似的还有 java.lang.Cloneable
、java.rmi.Remote
等。
类似于标记接口,函数式接口是指只有一个方法声明的接口,例如多线程编程时常用的 Runnalble
接口中只声明了一个 run()
方法:
|
@FunctionalInterface
是 Java 8 中新加入的一种接口,用于指明该接口类型声明是根据 Java 语言规范定义的函数式接口, Java 8 还声明了一些 Lambda 表达式可以使用的函数式接口,当你注释的接口不是有效的函数式接口时,可以使用 @FunctionalInterface
来解决编译时的错误。根据规范定义,函数式接口只能有一个抽象方法,如果你尝试添加第二个抽象方法,将抛出编译时错误。
在 Java 8 中可以将 Lambda 隐式地赋值给函数式接口,比如,我们可以通过 Lambda 表达式创建 Runnable
接口的引用:
Runnable r = () -> System.out.println("New Thread Created"); |
以上代码等价于:
Runnable r = new Thread { |
以上代码也可以简化如下:
new Thread(() -> { |
在有了 Lambda 表达式之后代码变得清晰简洁。
Lambda 表达式的一些经典用法
Mario Fusco 在 这篇文章 中举了很多例子来解释为什么 Java 需要 Lambda 表达式,其中提到几个点:
- 用内部迭代(Internal iteration)代替外部迭代(External Iteration)
- 传递行为而不只是数据
- 高效惰性计算(Laziness)
- 贷款模式(Loan pattern)
外部迭代(External iteration) VS 内部迭代(Internal iteration)
现在要给定一个 Integer
列表:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5); |
然后用一个 for
循环来遍历并打印:
for (Integer num : numbers) { |
很简单是吧?而且我们日常使用 Collection
类的时候也是这么做的,而在 Java 8 中可以这样写:
numbers.forEach(new Consumer<Integer>() { |
这种方式叫做“声明式(Declarative)编程”,相对应的之前的写法叫做“命令式(Imperative)编程”,声明式编程告诉机器你想要什么(what),让机器去想如何去做(how)。
使用 Lambda 表达式可以让代码更加简洁可读:
numbers.forEach((Integer value) -> System.out.println(value)); |
在这里编译器看到 Lambda 表达式和 Consumer
接口的唯一未实现方法有相同的句柄(Signature),也就是前面说到的函数式接口,就将前者作为后者的一个实例,但是生成的字节码可能会有些不同。
这个例子里 Lambda 表达式还可以将参数类型省略,编译器会自行推断:
numbers.forEach(value -> System.out.println(value)); |
我们还可以用 Java 8 中引入的另一个新特性方法引用(Method reference)来进一步简化代码,方法引用可以用新操作符 ::
来引用实例方法和静态方法:
numbers.forEach(System.out::println); |
这种方式在函数式编程范式中被称作“Eta 表达式”,也就是说编译器可以将另一个具有相同句柄(Signature)的实例方法来转换成 Consumer
接口唯一抽象方法(函数式接口)的实现来使用。
注意: 用
forEach
方法并不适用于数组,原因参考 In Java 8, why were Arrays not given the forEach method of Iterable? - Stackoverflow
传递行为,而不只是值
前面的例子只是用来表明 Lambda 表达式很有用,除此之外,将 Lambda 表达式传递给其他方法不仅可以将值传递,还可以将行为传递出去,以此提升抽象的级别,使代码更通用、灵活、可复用。
我们还用之前那个例子进一步举例:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5); |
第一天,产品经理要求说要实现一个方法,来将列表中所有的数求和,你随手一写:
public int sumAll(List<Integer> numbers) { |
第二天,产品经理过来告诉你,由于业务需要还要实现一个将列表中所有偶数求和的函数,你信手拈来:
public int sumAllEven(List<Integer> numbers) { |
过了几天,产品经理又过来了,说由于业务改进,需要将列表中所有比3大的数求和,你陷入了沉思:是可以继续像之前一下复制粘贴来完成业务需要的功能,但是是不是有什么地方可以改进呢?正所谓 “一写,二改,三重构”,现在是时候想想有没有更通用的做法了。
这里可以使用 Java 8 中引入的新特性断言(Predicate)来定义在加和之前如何过滤数据:
public int sumAll(List<Integer> numbers, Predicate<Integer> p) { |
也就是说,我们不仅将数据的指(Integer 数组)传递给了函数,也将对加在这个数据之上的行为(断言)传递给了函数,这样就可以用三个简单的语句来完成上述功能:
sumAll(numbers, n -> true); |
这种用法类似于在函数式编程语言中常见的 filter
。
高效惰性计算(Lazy evaluation)
使用内部迭代的另一个好处,也是在函数式编程范式中更常见的是惰性计算,我们可以用之前的例子来做一个解释,还是之前的那个整数列表:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6); |
这次我们做一些复杂的操作,将列表中所有的偶数取平方,然后打印出比5大的数:
for (int number : numbers) { |
这么写的缺点就是一段代码中做的事情太多了,有很多嵌套结构,很难读也不容易维护。为了解决这个问题,我们可以把它分成三个部分,每个部分只做一件事:
public boolean isEven(int number) { |
然后重写一下上面的代码:
List<Integer> l1 = new ArrayList<Integer>(); |
这里用一个“流水线”来完成原来的功能,每个方法只处理一件事情,但是这样就比之前的好了吗?可能没有。原因有两个:第一,第二种比第一种看起来更啰嗦;第二,第二种方法实际上并没有第一种方法效率更高,因为第二种比第一种执行的步骤多了。我们可以给每个子函数设置一个输出语句,来验证这一点,得到的输出结果是:
isEven: 1 |
从结果可以看出,列表中所有的6个数都经历了一遍流水线,而用前面嵌套 for
循环的方法只有前4个会经过流水线。
使用 Stream
可以帮我们解决上面的问题,你可以在一个 Collection
类调用 stream()
方法创建 Stream
,有了 Stream
我们就可以用一个流畅的代码解决前面的问题了:
System.out.println( |
输出的结果是:
isEven: 1 |
Stream
的惰性计算会在不必要的计算上节省很多CPU时间。
贷款模式(Load pattern)
最后这个例子可以展示出如何更好地进行包装从而避免重复写代码,我们在写代码的时候可能会经常需要操控一些资源(比如文件、数据库、网络连接等),这里假如我们有个 Resource
类:
public class Resource { |
我们可以创建、使用这个资源,在使用之后我们需要将其释放来避免一些错误,比如内存溢出、文件描述符溢出等:
Resource resource = new Resource(); |
但是,在我们使用资源的时候可能会出现一些问题,为了确保 dispose()
方法能被执行,我们应该把它放到一个 finally
块里面:
Resource resource = new Resource(); |
那么问题来了,当我们重复使用资源的时候,就得一遍一遍地重复写 try/catch
代码,一旦忘了释放资源就可能会导致溢出。
为了解决这个问题,我们可以用一个静态方法来将 try/catch
代码段包装起来,如果这种资源在创建的时候需要参数,我们也可以将参数传入这个静态方法:
public static void withResource(Consumer<Resource> consumer) { |
然后我们就可以这样使用资源了:
withResource(resource -> resource.operate()); |
这样可以保证资源一定会被正确释放,而且不用重复写代码。
这个方式叫做“贷款模式”,是因为“借出者”(持有资源的代码)在“借入者”( 访问资源的 Lambda 表达式)使用完资源之后立即将资源收回。