《Java核心技术》
现在可以来学习lambda表达式,这是这些年来Java语言最让人激动的一个变化。你会了解如何使用lambda表达式采用一种简洁的语法定义代码块,以及如何编写处理lambda表达式的代码。
6.3.1 为什么引入lambda表达式
lambda表达式是一个可传递的代码块,可以在以后执行一次或多次。具体介绍语法(以及解释这个让人好奇的名字)之前,下面先退一步,观察一下我们在Java中的哪些地方用过这种代码块。
在6.2.1节中,你已经了解了如何按指定时间间隔完成工作。将这个工作放在一个ActionListener的actionPerformed方法中:
class Worker implements ActionListener
{
public void actionPerformed(ActionEvent event)
{
//do some work
}
}
想要反复执行这个代码时,可以构造Worker类的一个实例。然后把这个实例提交到一个Timer对象。这里的重点是actionPerformed方法包含希望以后执行的代码。
或者可以考虑如何用一个定制比较器完成排序。如果想按长度而不是默认的字典顺序对字符串排序,可以向sort方法传入一个Comparator对象:
class LengthComparator implements Comparator<String>
{
public void compare(String first,String second)
{
return first.length() - second.length();
}
}
...
Arrays.sort(strings,new LengthComparator());
compare方法不是立即调用。实际上,在数组完成排序之前,sort方法会一直调用compare方法,只要元素的顺序不正确就会重新排列元素。将比较元素所需的代码段放在sort方法中,这个代码将与其余的排序逻辑集成(你可能并不打算重新实现其余的这部分逻辑)。
这两个例子有一些共同点,都是将一个代码块传递到某个对象(一个定时器,或者一个sort方法)。这个代码块会在将来某个时间调用。
到目前为止,在Java中传递一个代码段并不容易,不能直接传递代码段。Java是一种面向对象语言,所以必须构造一个对象,这个对象的类需要有一个方法能包含所需的代码。
在其他语言中,可以直接处理代码块。Java设计者很长时间以来一直拒绝增加这个特性。毕竟,Java的强大之处就在于其简单性和一致性。如果只要一个特性能够让代码稍简洁一些,就把这个特性增加到语言中,这个语言很快就会变得一团糟,无法管理。不过,在另外那些语言中,并不只是创建线程或注册按钮点击事件处理器更容易;它们的大部分API都更简单、更一致而且更强大。在Java中,也可以编写类似的API利用类对象实现特定的功能,不过这种API使用可能很不方便。
就现在来说,问题已经不是是否增强Java来支持函数式编程,而是要如何做到这一点。设计者们做了多年的尝试,终于找到一种适合Java的设计。下一节中,你会了解Java SE 8中如何处理代码块。
6.3.2 lambda表达式的语法
再来考虑上一节讨论的排序例子。我们传入代码来检查一个字符串是否比另一个字符串短。这里要计算:
first.length() - second.length();
first和second是什么?它们都是字符串。Java是一种强类型语言,所以我们还要指定它们的类型:
(String first,String second)
->first.length()-second.length();
这就是你看到的第一个lambda表达式。lambda表达式就是一个代码块,以及必须传入代码的变量规范。
为什么起这个名字呢?很多年前,那时还没有计算机,逻辑学家Alonzo Church想要形式化地表示能有效计算的数学函数。(奇怪的是,有些函数已经知道是存在的,但是没有人知道该如何计算这些函数的值。)他使用了希腊字母lambda (λ)来标记参数。如果他知道Java API,可能就会写为
λfirst.λsecond.first.length() - second.length();
注释:为什么是字母λ? Church已经把字母表里的所有其他字母都用完了吗?实际上,权威的《数学原理》一书中就使用重音符^来表示自由变量,受此启发,Church使用大写lambda(Λ)表示参数。不过,最后他还是改为使用小写的lambda(λ)。从那以后,带参数变量的表达式就被称为lambda表达式。
你已经见过Java中的一种lambda表达式形式:参数,箭头(->)以及一个表达式。如果代码要完成的计算无法放在一个表达式中,就可以像写方法一样,把这些代码放在{}中,并包含显式的return语句。例如:
(String first,String second) ->
{
if(first.length() < second.length()) return -1;
else if(first.length()>second.length()) return 1;
else return 0;
}
即使lambda表达式没有参数,仍然要提供空括号,就像无参数方法一样:
()->{ for (int i = 100; i>=0; i--) System.out.println(i);}
如果可以推导出一个lambda表达式的参数类型,则可以忽略其类型。例如:
Comparator<String> comp
= (first,second) //Same as (String first,String second)
->first.length() - second.length();
在这里,编译器可以推导出first和second必然是字符串,因为这个lambda表达式将赋给一个字符串比较器。(下一节会更详细地分析这个赋值。)
如果方法只有一个参数,而且这个参数的类型可以推导得出,那么甚至还可以省略小括号:
ActionListener listener = event ->
System.out.println("The time is " + new Date()");
//Instead of (event) ->...or (ActionEvent event)->...
无需指定lambda表达式的返回类型。lambda表达式的返回类型总是会由上下文推导得出。例如,下面的表达式
(String first,String second) ->first.length() - second.length()
可以在需要int类型结果的上下文中使用。
注释:如果一个lambda表达式只在某些分支返回一个值,而在另外一些分支不返回值,这是不合法的。例如,(int x) -> { if (x >= 0) return 1; }就不合法。
程序清单6-6中的程序显示了如何在一个比较器和一个动作监听器中使用lambda表达式。
程序清单6-6 lambda/LambdaTest.java
package lambda;
import java.util.*;
import javax.swing.*;
import javax.swing.Timer;
/**
* This program demonstrates the use of lambda expressions.
* @version 1.0 2015-05-12
* @author Cay Horstmann
*/
public class LambdaTest
{
public static void main(String[] args)
{
String[] planets = new String[] { "Mercury", "Venus", "Earth", "Mars",
"Jupiter", "Saturn", "Uranus", "Neptune" };
System.out.println(Arrays.toString(planets));
System.out.println("Sorted in dictionary order:");
Arrays.sort(planets);
System.out.println(Arrays.toString(planets));
System.out.println("Sorted by length:");
Arrays.sort(planets, (first, second) -> first.length() - second.length());
System.out.println(Arrays.toString(planets));
Timer t = new Timer(1000, event ->
System.out.println("The time is " + new Date()));
t.start();
// keep program running until user selects "Ok"
JOptionPane.showMessageDialog(null, "Quit program?");
System.exit(0);
}
}
6.3.3 函数式接口
前面已经讨论过,Java中已经有很多封装代码块的接口,如ActionListener或Comparator。lambda表达式与这些接口是兼容的。
对于只有一个抽象方法的接口,需要这种接口的对象时,就可以提供一个lambda表达式。这种接口称为函数式接口(functional interface)。
注释:你可能想知道为什么函数式接口必须有一个抽象方法。不是接口中的所有方法都是抽象的吗?实际上,接口完全有可能重新声明Object类的方法,如toString或clone,这些声明有可能会让方法不再是抽象的。(Java API中的一些接口会重新声明Object方法来附加javadoc注释。Comparator API就是这样一个例子。)更重要的是,正如6.1.5节所述,在Java SE 8中,接口可以声明非抽象方法。
为了展示如何转换为函数式接口,下面考虑Arrays.sort方法。它的第二个参数需要一个Comparator实例,Comparator就是只有一个方法的接口,所以可以提供一个lambda表达式:
Arrays.sort(words,(first,second)->first.length()-second.length());
在底层,Arrays.sort方法会接收实现了Comparator
lambda表达式可以转换为接口,这一点让lambda表达式很有吸引力。具体的语法很简短。下面再来看一个例子:
Timer t = new Timer(1000, event ->
System.out.println("At the tone,the time is " + new Date()));
Toolkit.getDefaultToolkit().beep();
});
与使用实现了ActionListener接口的类相比,这个代码可读性要好得多。
实际上,在Java中,对lambda表达式所能做的也只是能转换为函数式接口。在其他支持函数字面量的程序设计语言中,可以声明函数类型(如(String, String) ->int)、声明这些类型的变量,还可以使用变量保存函数表达式。不过,Java设计者还是决定保持我们熟悉的接口概念,没有为Java语言增加函数类型。
注释:甚至不能把lambda表达式赋给类型为Object的变量,Object不是一个函数式接口。
Java API在java.util.function包中定义了很多非常通用的函数式接口。其中一个接口BiFunction
BiFunction<String,String,Integer> comp = (first,second)->first.length()-second.length();
不过,这对于排序并没有帮助。没有哪个Arrays.sort方法想要接收一个BiFunction。如果你之前用过某种函数式程序设计语言,可能会发现这很奇怪。不过,对于Java程序员而言,这非常自然。类似Comparator的接口往往有一个特定的用途,而不只是提供一个有指定参数和返回类型的方法。Java SE 8沿袭了这种思路。想要用lambda表达式做某些处理,还是要谨记表达式的用途,为它建立一个特定的函数式接口。
java.util.function包中有一个尤其有用的接口Predicate:
public interface Predicate<T>
{
boolean test(T t);
//Additional default adn static methods
}
ArrayList类有一个removeIf方法,它的参数就是一个Predicate。这个接口专门用来传递lambda表达式。例如,下面的语句将从一个数组列表删除所有null值:
list.removeIf(e -> e==null );
6.3.4 方法引用
有时,可能已经有现成的方法可以完成你想要传递到其他代码的某个动作。例如,假设你希望只要出现一个定时器事件就打印这个事件对象。当然,为此也可以调用:
Timer t = new Timer(1000,event -> System.out.println(event));
但是,如果直接把println方法传递到Timer构造器就更好了。具体做法如下:
Timer t = new Timer(1000,System.out::println);
表达式System.out::println是一个方法引用(method reference),它等价于lambda表达式x -> System.out.println(x)。
再来看一个例子,假设你想对字符串排序,而不考虑字母的大小写。可以传递以下方法表达式:
Arrays.sort(strings,String::compareToIgnoreCase)
从这些例子可以看出,要用::操作符分隔方法名与对象或类名。主要有3种情况:
- object::instanceMethod
- Class::staticMethod
- Class::instanceMethod
在前2种情况中,方法引用等价于提供方法参数的lambda表达式。前面已经提到,System.out::println等价于x -> System.out.println(x)。类似地,Math::pow等价于(x, y) ->Math.pow(x, y)。
对于第3种情况,第1个参数会成为方法的目标。例如,String::compareToIgnoreCase等同于(x, y) -> x.compareToIgnoreCase(y)。
注释:如果有多个同名的重载方法,编译器就会尝试从上下文中找出你指的那一个方法。例如,Math.max方法有两个版本,一个用于整数,另一个用于double值。选择哪一个版本取决于Math::max转换为哪个函数式接口的方法参数。类似于lambda表达式,方法引用不能独立存在,总是会转换为函数式接口的实例。
可以在方法引用中使用this参数。例如,this::equals等同于x -> this.equals(x)。使用super也是合法的。下面的方法表达式
super::instanceMethod
使用this作为目标,会调用给定方法的超类版本。
为了展示这一点,下面给出一个假想的例子:
class Greeter{
public void greet(){
System.out.println("Hello,world!");
}
}
class TimedGreeter extends Greeter{
public void greet(){
Timer t = new Timer(1000,super::greet);
t.start();
}
}
TimedGreeter.greet方法开始执行时,会构造一个Timer,它会在每次定时器滴答时执行super::greet方法。这个方法会调用超类的greet方法。
6.3.5 构造器引用
构造器引用与方法引用很类似,只不过方法名为new。例如,Person::new是Person构造器的一个引用。哪一个构造器呢?这取决于上下文。假设你有一个字符串列表。可以把它转换为一个Person对象数组,为此要在各个字符串上调用构造器,调用如下:
ArrayList<String> names = ...;
Stream<Person> stream = names.stream().map(Person::new);
List<Person> people = stream.collect(Collectors.toList());
我们将在卷Ⅱ的第1章讨论stream、map和collect方法的详细内容。就现在来说,重点是map方法会为各个列表元素调用Person(String)构造器。如果有多个Person构造器,编译器会选择有一个String参数的构造器,因为它从上下文推导出这是在对一个字符串调用构造器。
可以用数组类型建立构造器引用。例如,int[]::new是一个构造器引用,它有一个参数:即数组的长度。这等价于lambda表达式x -> new int[x]。
Java有一个限制,无法构造泛型类型T的数组。数组构造器引用对于克服这个限制很有用。表达式new T[n]会产生错误,因为这会改为new Object[n]。对于开发类库的人来说,这是一个问题。例如,假设我们需要一个Person对象数组。Stream接口有一个toArray方法可以返回Object数组:
Object[] perple = stream.toArray();
不过,这并不让人满意。用户希望得到一个Person引用数组,而不是Object引用数组。流库利用构造器引用解决了这个问题。可以把Person[]::new传入toArray方法:
Person[] perple = stream.toArray(Person[]::new);
toArray方法调用这个构造器来得到一个正确类型的数组。然后填充这个数组并返回。
6.3.6 变量作用域
通常,你可能希望能够在lambda表达式中访问外围方法或类中的变量。考虑下面这个例子:
public static void repeatMessage(String text,int delay){
ActionListener listener = event ->
{
System.out.println(text);
Toolkit.getDefaultToolkit().beep();
};
new Timer(delay,listener).start();
}
来看这样一个调用:
repeatMessage(“Hello”,1000);//Prints Hello every 1000 milliseconds
现在来看lambda表达式中的变量text。注意这个变量并不是在这个lambda表达式中定义的。实际上,这是repeatMessage方法的一个参数变量。
如果再想想看,这里好像会有问题,尽管不那么明显。lambda表达式的代码可能会在repeatMessage调用返回很久以后才运行,而那时这个参数变量已经不存在了。如何保留text变量呢?
要了解到底会发生什么,下面来巩固我们对lambda表达式的理解。lambda表达式有3个部分:
1)一个代码块;
2)参数;
3)自由变量的值,这是指非参数而且不在代码中定义的变量。
在我们的例子中,这个lambda表达式有1个自由变量text。表示lambda表达式的数据结构必须存储自由变量的值,在这里就是字符串”Hello”。我们说它被lambda表达式捕获(captured)。(下面来看具体的实现细节。例如,可以把一个lambda表达式转换为包含一个方法的对象,这样自由变量的值就会复制到这个对象的实例变量中。)
注释:关于代码块以及自由变量值有一个术语:闭包(closure)。如果有人吹嘘他们的语言有闭包,现在你也可以自信地说Java也有闭包。在Java中,lambda表达式就是闭包。
可以看到,lambda表达式可以捕获外围作用域中变量的值。在Java中,要确保所捕获的值是明确定义的,这里有一个重要的限制。在lambda表达式中,只能引用值不会改变的变量。例如,下面的做法是不合法的:
public static void countDown(int start,int delay){
ActionListener listener = event ->
{
start--;//Error:Can't mutate captured variabled
System.out.println(start);
};
new Timer(delay,listener).start();
}
之所以有这个限制是有原因的。如果在lambda表达式中改变变量,并发执行多个动作时就会不安全。对于目前为止我们看到的动作不会发生这种情况,不过一般来讲,这确实是一个严重的问题。关于这个重要问题的更多内容参见第14章。
另外如果在lambda表达式中引用变量,而这个变量可能在外部改变,这也是不合法的。例如,下面就是不合法的:
这里有一条规则:lambda表达式中捕获的变量必须实际上是最终变量(effectively final)。实际上的最终变量是指,这个变量初始化之后就不会再为它赋新值。在这里,text总是指示同一个String对象,所以捕获这个变量是合法的。不过,i的值会改变,因此不能捕获i。
lambda表达式的体与嵌套块有相同的作用域。这里同样适用命名冲突和遮蔽的有关规则。在lambda表达式中声明与一个局部变量同名的参数或局部变量是不合法的。
在方法中,不能有两个同名的局部变量,因此,lambda表达式中同样也不能有同名的局部变量。
在一个lambda表达式中使用this关键字时,是指创建这个lambda表达式的方法的this参数。例如,考虑下面的代码:
表达式this.toString()会调用Application对象的toString方法,而不是ActionListener实例的方法。在lambda表达式中,this的使用并没有任何特殊之处。lambda表达式的作用域嵌套在init方法中,与出现在这个方法中的其他位置一样,lambda表达式中this的含义并没有变化。
6.3.7 处理lambda表达式
到目前为止,你已经了解了如何生成lambda表达式,以及如何把lambda表达式传递到需要一个函数式接口的方法。下面来看如何编写方法处理lambda表达式。
使用lambda表达式的重点是延迟执行(deferred execution)。毕竟,如果想要立即执行代码,完全可以直接执行,而无需把它包装在一个lambda表达式中。之所以希望以后再执行代码,这有很多原因,如:
- 在一个单独的线程中运行代码;
- 多次运行代码;
- 在算法的适当位置运行代码(例如,排序中的比较操作);
- 发生某种情况时执行代码(如,点击了一个按钮,数据到达,等等);
- 只在必要时才运行代码。
下面来看一个简单的例子。假设你想要重复一个动作n次。将这个动作和重复次数传递到一个repeat方法:
repeat(10,() -> System.out.println("hello,World!"));
要接受这个lambda表达式,需要选择(偶尔可能需要提供)一个函数式接口。表6-1列出了Java API中提供的最重要的函数式接口。在这里,我们可以使用Runnable接口:
public static void repeat(int n,Runnable action){
for(int i=0;i<n;i++) action.run();
}
需要说明,调用action.run()时会执行这个lambda表达式的主体。
表6-1 常用函数式接口
现在让这个例子更复杂一些。我们希望告诉这个动作它出现在哪一次迭代中。为此,需要选择一个合适的函数式接口,其中要包含一个方法,这个方法有一个int参数而且返回类型为void。处理int值的标准接口如下:
public interface IntConsumer{
void accept(int value);
}
下面给出repeat方法的改进版本:
public static void repeat(int n,IntConsumer action){
for(int i=0;i<n;i++)action.accept(i);
}
可以如下调用它:
repeat(10,i->System.out.println("Countdown:"+(9-i)));
表6-2列出了基本类型int、long和double的34个可能的规范。最好使用这些特殊化规范来减少自动装箱。出于这个原因,我在上一节的例子中使用了IntConsumer而不是Consumer
表6-2 基本类型的函数式接口
注:p, q为int, long, double; P, Q为Int, Long, Double
提示:最好使用表6-1或表6-2中的接口。例如,假设要编写一个方法来处理满足某个特定条件的文件。对此有一个遗留接口java.io.FileFilter,不过最好使用标准的Predicate
。只有一种情况下可以不这么做,那就是你已经有很多有用的方法可以生成FileFilter实例。 注释:大多数标准函数式接口都提供了非抽象方法来生成或合并函数。例如,Predicate. isEqual(a)等同于a::equals,不过如果a为null也能正常工作。已经提供了默认方法and、or和negate来合并谓词。例如,Predicate.isEqual(a).or(Predicate.isEqual(b))就等同于x ->a.equals(x) ||b.equals(x)。
注释:如果设计你自己的接口,其中只有一个抽象方法,可以用@FunctionalInterface注解来标记这个接口。这样做有两个优点。如果你无意中增加了另一个非抽象方法,编译器会产生一个错误消息。另外javadoc页里会指出你的接口是一个函数式接口。
并不是必须使用注解。根据定义,任何有一个抽象方法的接口都是函数式接口。不过使用@FunctionalInterface注解确实是一个很好的做法。
6.3.8 再谈Comparator
Comparator接口包含很多方便的静态方法来创建比较器。这些方法可以用于lambda表达式或方法引用。
静态comparing方法取一个“键提取器”函数,它将类型T映射为一个可比较的类型(如String)。对要比较的对象应用这个函数,然后对返回的键完成比较。例如,假设有一个Person对象数组,可以如下按名字对这些对象排序:
Arrays.sort(people,Comparator.comparing(Person::getName));
与手动实现一个Comparator相比,这当然要容易得多。另外,代码也更为清晰,因为显然我们都希望按人名来进行比较。
可以把比较器与thenComparing方法串起来。例如,
Arrays.sort(people,Comparator.comparing(Person::getLastName).thenComparing(Person::getFirstName));
如果两个人的姓相同,就会使用第二个比较器。
这些方法有很多变体形式。可以为comparing和thenComparing方法提取的键指定一个比较器。例如,可以如下根据人名长度完成排序:
Arrays.sort(people,Comparator.comparing(Person::getName,(s,t)->Integer.compare(s.length(),t.length())));
另外,comparing和thenComparing方法都有变体形式,可以避免int、long或double值的装箱。要完成前一个操作,还有一种更容易的做法:
Arrays.sort(people,Comparator.comparingInt(p->p.getName().length()));
如果键函数可以返回null,可能就要用到nullsFirst和nullsLast适配器。这些静态方法会修改现有的比较器,从而在遇到null值时不会抛出异常,而是将这个值标记为小于或大于正常值。例如,假设一个人没有中名时getMiddleName会返回一个null,就可以使用Comparator.comparing(Person::getMiddleName(),Comparator.nullsFirst(…))。
nullsFirst方法需要一个比较器,在这里就是比较两个字符串的比较器。naturalOrder方法可以为任何实现了Comparable的类建立一个比较器。在这里,Comparator.
Arrasys.sort(people,comparing(Person::getMiddleName,nullsFirst(naturalOrder())));
静态reverseOrder方法会提供自然顺序的逆序。要让比较器逆序比较,可以使用reversed实例方法。例如naturalOrder().reversed()等同于reverseOrder()。