Java8 Lambda 表达式

“越来越多的迹象标明,函数式编程已经不再是学术界的最爱,开始大步地在业界投入使用。”

Lambda 表达式是Java 8 发布的最重要的特性,这里对 Java Lambda 表达式做一些记录。

Lambda 表达式的引入

Java 是一门面向对象语言,而且也一直在完善自己的面向对象特性,除了基本的数据类型之外,一切都是对象,包括数组在内,这就意味着 Java 中定义的方法或者说函数不能独立于类存在,也不能将某一个方法或函数作为参数传递。

而在函数式变成语言中,函数是一等公民,可以独立存在,可以将函数作为一个变量一样看待,也可以作为参数传递给其他函数,函数式编程的好处就不一一赘述,可以参考阮一峰的博文 函数式编程初探

Java 8 新增了函数式接口(Functional Interface)和 Lambda 表达式来引入函数式编程特性,新特性的引入大大提高了 Java 开发的效率。

Lambda 表达式结构

Lambda 表达式是一种匿名函数(虽然这对 Java 而言这并不完全正确,但是可以暂时简单这么认为),简单地说,它是没有声明的方法,也没有访问修饰符、返回值声明和名字。

你可以认为它是一种“临时工”,在你需要使用某个方法的地方写上它。如果某个方法只使用一次而且定义很简短,使用这种“临时工”尤其有效,这样,你就不必在类中费力写声明与方法了。

Java 中的 Lambda 表达式通常使用 (argument) -> (body) 语法书写,例如:

(arg1, arg2...) > { body }
(type1 arg1, type2 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; }

() -> System.out.println("Hello World");

(String s) -> { System.out.println(s); }

() -> 42

() -> { return 3.1415 };

函数式接口(Functional Interface)

在讲函数式接口之前先说一下 Java 中的标记接口(Marker Interface),标记接口是指没有属性和方法的空接口,例如 java.io.Serializable 接口就是空接口:

public interface Serializable {

}

这种接口只是作为一种标记存在,并不是要使用什么属性或方法,类似的还有 java.lang.Cloneablejava.rmi.Remote 等。

类似于标记接口,函数式接口是指只有一个方法声明的接口,例如多线程编程时常用的 Runnalble 接口中只声明了一个 run() 方法:

@FunctionalInterface
public interface Runnable {
public abstract void run();
}

@FunctionalInterface 是 Java 8 中新加入的一种接口,用于指明该接口类型声明是根据 Java 语言规范定义的函数式接口, Java 8 还声明了一些 Lambda 表达式可以使用的函数式接口,当你注释的接口不是有效的函数式接口时,可以使用 @FunctionalInterface 来解决编译时的错误。根据规范定义,函数式接口只能有一个抽象方法,如果你尝试添加第二个抽象方法,将抛出编译时错误。

在 Java 8 中可以将 Lambda 隐式地赋值给函数式接口,比如,我们可以通过 Lambda 表达式创建 Runnable 接口的引用:

Runnable r = () -> System.out.println("New Thread Created");
Thread t = new Thread(r);
t.start();

以上代码等价于:

Runnable r = new Thread {
@Override
public void run() {
System.out.println("New Thread Created");
}
}
Thread t = new Thread(r);
t.start();

以上代码也可以简化如下:

new Thread(() -> {
System.out.println("New Thread Created");
}).start();

在有了 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) {
System.out.println(num);
}

很简单是吧?而且我们日常使用 Collection 类的时候也是这么做的,而在 Java 8 中可以这样写:

numbers.forEach(new Consumer<Integer>() {
public void accept(Integer value) {
System.out.println(value);
}
});

这种方式叫做“声明式(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) {
int total = 0;
for (int number : numbers) {
total += number;
}
return total;
}

第二天,产品经理过来告诉你,由于业务需要还要实现一个将列表中所有偶数求和的函数,你信手拈来:

public int sumAllEven(List<Integer> numbers) {
int total = 0;
for (int number : numbers) {
if (number % 2 == 0) {
total += number;
}
}
return total;
}

过了几天,产品经理又过来了,说由于业务改进,需要将列表中所有比3大的数求和,你陷入了沉思:是可以继续像之前一下复制粘贴来完成业务需要的功能,但是是不是有什么地方可以改进呢?正所谓 “一写,二改,三重构”,现在是时候想想有没有更通用的做法了。

这里可以使用 Java 8 中引入的新特性断言(Predicate)来定义在加和之前如何过滤数据:

public int sumAll(List<Integer> numbers, Predicate<Integer> p) {
int total = 0;
for (int number : numbers) {
if (p.test(number)) {
total += number;
}
}
return total;
}

也就是说,我们不仅将数据的指(Integer 数组)传递给了函数,也将对加在这个数据之上的行为(断言)传递给了函数,这样就可以用三个简单的语句来完成上述功能:

sumAll(numbers, n -> true);
sumAll(numbers, n -> n % 2 == 0);
sumAll(numbers, n -> n > 3);

这种用法类似于在函数式编程语言中常见的 filter

高效惰性计算(Lazy evaluation)

使用内部迭代的另一个好处,也是在函数式编程范式中更常见的是惰性计算,我们可以用之前的例子来做一个解释,还是之前的那个整数列表:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

这次我们做一些复杂的操作,将列表中所有的偶数取平方,然后打印出比5大的数:

for (int number : numbers) {
if (number % 2 == 0) {
int n2 = number * 2;
if (n2 > 5) {
System.out.println(n2);
break;
}
}
}

这么写的缺点就是一段代码中做的事情太多了,有很多嵌套结构,很难读也不容易维护。为了解决这个问题,我们可以把它分成三个部分,每个部分只做一件事:

public boolean isEven(int number) {
return number % 2 == 0;
}

public int doubleIt(int number) {
return number * 2;
}

public boolean isGreaterThan5(int number) {
return number > 5;
}

然后重写一下上面的代码:

List<Integer> l1 = new ArrayList<Integer>();
for (int n : numbers) {
if (isEven(n)) l1.add(n);
}

List<Integer> l2 = new ArrayList<Integer>();
for (int n : l1) {
l2.add(doubleIt(n));
}

List<Integer> l3 = new ArrayList<Integer>();
for (int n : l2) {
if (isGreaterThan5(n)) l3.add(n);
}

System.out.println(l3.get(0));

这里用一个“流水线”来完成原来的功能,每个方法只处理一件事情,但是这样就比之前的好了吗?可能没有。原因有两个:第一,第二种比第一种看起来更啰嗦;第二,第二种方法实际上并没有第一种方法效率更高,因为第二种比第一种执行的步骤多了。我们可以给每个子函数设置一个输出语句,来验证这一点,得到的输出结果是:

isEven: 1
isEven: 2
isEven: 3
isEven: 4
isEven: 5
isEven: 6
doubleIt: 2
doubleIt: 4
doubleIt: 6
isGreaterThan5: 4
isGreaterThan5: 8
isGreaterThan5: 12
8

从结果可以看出,列表中所有的6个数都经历了一遍流水线,而用前面嵌套 for 循环的方法只有前4个会经过流水线。

使用 Stream 可以帮我们解决上面的问题,你可以在一个 Collection 类调用 stream() 方法创建 Stream,有了 Stream 我们就可以用一个流畅的代码解决前面的问题了:

System.out.println(
numbers.stream()
.filter(Lazy::isEven)
.map(Lazy::doubleIt)
.filter(Lazy::isGreaterThan5)
.findFirst()
);

输出的结果是:

isEven: 1
isEven: 2
doubleIt: 2
isGreaterThan5: 4
isEven: 3
isEven: 4
doubleIt: 4
isGreaterThan5: 8
IntOptional[8]

Stream 的惰性计算会在不必要的计算上节省很多CPU时间。

贷款模式(Load pattern)

最后这个例子可以展示出如何更好地进行包装从而避免重复写代码,我们在写代码的时候可能会经常需要操控一些资源(比如文件、数据库、网络连接等),这里假如我们有个 Resource 类:

public class Resource {

public Resource() {
System.out.println("Opening resource");
}

public void operate() {
System.out.println("Operating on resource");
}

public void dispose() {
System.out.println("Disposing resource");
}
}

我们可以创建、使用这个资源,在使用之后我们需要将其释放来避免一些错误,比如内存溢出、文件描述符溢出等:

Resource resource = new Resource();
resource.operate();
resource.dispose();

但是,在我们使用资源的时候可能会出现一些问题,为了确保 dispose() 方法能被执行,我们应该把它放到一个 finally 块里面:

Resource resource = new Resource();
try {
resource.operate();
} finally {
resource.dispose();
}

那么问题来了,当我们重复使用资源的时候,就得一遍一遍地重复写 try/catch 代码,一旦忘了释放资源就可能会导致溢出。

为了解决这个问题,我们可以用一个静态方法来将 try/catch 代码段包装起来,如果这种资源在创建的时候需要参数,我们也可以将参数传入这个静态方法:

public static void withResource(Consumer<Resource> consumer) {
Resource resource = new Resource();
try {
consumer.accept(resource);
} finally {
resource.dispose();
}
}

然后我们就可以这样使用资源了:

withResource(resource -> resource.operate());

这样可以保证资源一定会被正确释放,而且不用重复写代码。

这个方式叫做“贷款模式”,是因为“借出者”(持有资源的代码)在“借入者”( 访问资源的 Lambda 表达式)使用完资源之后立即将资源收回。

参考资料