Java 8 官方教程翻译——Lambda表达式

时间:2021-07-12 19:09:54
使用匿名类的一个问题是,如果匿名类的实现过于简单,例如实现的接口只包含一个方法,那么匿名类的语法就显得笨拙和不清晰。在这种情况下,你要做的事情通常是向某方法传递一些功能代码块,例如在响应按钮被点击时应该执行什么操作。Lambda表达式能简化这项工作,它将功能代码块作为参数,或者说将代码当数据。

在前一节中讲述匿名类时,我们向你展示了如何在不指定类名的情况下实现一个类。即使这样要比一个有名类更加简洁,然而对于只包含一个方法的类来说,匿名类也显得有些繁琐。Lambda表达式可以更加紧凑的表达"单方法"类的实例。

这一节包含如下话题:
  • 使用Lambda表达式的理想情景
    • 方案1:创建检索特定成员的方法
    • 方案2:创建更加通用的检索方法
    • 方案3:通过局部类指定检索条件
    • 方案4:通过匿名类指定检索条件
    • 方案5:通过Lambda表达式指定检索条件
    • 方案6:配合Lambda表达式使用标准函数接口(Functional Interface)
    • 方案7:在应用程序中全面使用Lambda表达式
    • 方案8:更大范围的使用泛型(Generics)
    • 方案9:使用以Lambda表达式作为参数的聚集操作(Aggregate Operation)
  • 在GUI应用程序中应用Lambda表达式
  • Lambda表达式的语法
  • 访问外围作用域(Enclosing Scope)中的局部变量
  • 目标定型(Target Typing)
    • 目标类型与方法参数
  • 序列化

使用Lambda表达式的理想情景

假设你正在编写一个社交网络应用。你希望的一个特性是,管理员可以执行任意类型的操作,例如发送消息给社交网络上符合特定条件的成员。以下表格详细描述了使用情形:
Field Description
Name 对选定成员执行的操作
Primary Actor(主要角色) 管理员
Preconditions(先决条件) 管理员已登陆系统
Postconditions(附加条件) 操作仅仅作用于符合特定条件的成员
Main Success Scenario(主要的情景) 1. 管理员指定执行特定操作的成员选择条件
2. 管理员指定要对选定成员执行的操作
3. 管理员选择了提交按钮
4. 系统检索所有符合指定条件的成员
5. 系统对所有符合条件的成员执行特定操作
Extensions(扩展) 管理员可以在指定操作前或者选择提交按钮前查看满足条件的成员
Frequency of Occurrence(发生频率) 很多次

假设该社交网络应用程序的成员使用如下的Person类进行表示:
  
  
  
public class Person {
 
public enum Sex {
MALE, FEMALE
}
 
String name;
LocalDate birthday;
Sex gender;
String emailAddress;
 
public int getAge() {
// ...
}
 
public void printPerson() {
// ...
}
}
假设社交网络应用程序的成员全部存放在一个List<Person>实例中。
本节以一个简陋的方案开始。我们会使用局部类和匿名类对该方案进行增强,然后使用Lambda表达式给出了更高效更简洁的方案。

方案1:创建检索特定成员的方法
一个最简单的方法就是创建多个方法;每个方法以检索满足特定条件的成员,如性别或年龄。如下方法打印出年龄大于某特定值的成员:
   
   
   
public static void printPersonsOlderThan(List<Person> roster, int age) {
for (Person p : roster) {
if (p.getAge() >= age) {
p.printPerson();
}
}
}
注意:List是一种有序的容器(Collection)。容器是将多个对象元素组合成一个单元的对象。容器被用来进行存储,取回,操作以及聚合数据。更多相关信息,参看Collections章节。

这种方案会使得你的应用程序非常脆弱,经不起变更,一旦引入变更(例如新的数据类型)便可能引发故障。假设你修改了Person类的结构,如包含了不同的成员变量,记录和衡量年龄的数据类型或者算法发生了变更。那么你可能需要重写很多的API来适应这种变化。除此之外,这种方案带来了很多不必要的限制;假如你需要打印年龄小于某值的成员,怎么办?

方案2:创建更加通用的检索方法
下面的方法相比printPersonOlderThan更加通用,它打印年龄在某个指定范围内的成员:
   
   
   
public static void printPersonsWithinAgeRange(
List<Person> roster, int low, int high) {
for (Person p : roster) {
if (low <= p.getAge() && p.getAge() < high) {
p.printPerson();
}
}
}
那如果你需要打印满足特定性别或者是特定性别和年龄范围的组合条件的成员,怎么做?或者你决定向Person类添加其他属性,如关系状态或者地理位置,又该如何做?尽管这个方法相比printPersonOlderThan更加通用,但是试图添加以各种条件进行检索的方法,仍旧使得代码变得脆弱。取而代之,你可以将指定检索条件的工作分配给另外一个类。

方案3:通过局部类指定检索条件
以下方法打印满足指定检索条件的成员:
   
   
   
public static void printPersons(
List<Person> roster, CheckPerson tester) {
for (Person p : roster) {
if (tester.test(p)) {
p.printPerson();
}
}
}
该方法通过调用CheckPerson类型参数tester的test方法来判定List类型参数roster中满足指定条件的成员。如果方法tester.test返回了true,就执行该Person对象的printPerson方法。
为了指定检索条件,你需要实现CheckPerson接口:
   
   
   
interface CheckPerson {
boolean test(Person p);
}
下面的类实现了CheckPerson。该方法过滤出符合美国义务兵役条件的成员,即年龄在18至25岁的男性。
   
   
   
class CheckPersonEligibleForSelectiveService implements CheckPerson {
public boolean test(Person p) {
return p.gender == Person.Sex.MALE &&
p.getAge() >= 18 &&
p.getAge() <= 25;
}
}
要使用该类,实例化并将之作为参数调用printPersons方法:
   
   
   
printPersons(
roster, new CheckPersonEligibleForSelectiveService());
虽然这种方案较好——不需要在更改Person结构后重写方法——但是你仍然需要为每个检索条件增加额外的接口和局部类。由于CheckPersonEligibleForSelectiveService实现了一个接口,因此你可以选择使用匿名类从而避免为每个检索条件声明一个新的局部类。

方案4:通过匿名类指定检索条件
以下方法printPerson调用中,包含一个匿名类,该类过滤出了符合美国义务兵役条件的成员:
   
   
   
printPersons(
roster,
new CheckPerson() {
public boolean test(Person p) {
return p.getGender() == Person.Sex.MALE
&& p.getAge() >= 18
&& p.getAge() <= 25;
}
}
);
该方案减少了所需的代码量,因为你不必为每一个检索条件创建一个新类。然后,匿名类的语法依然显得笨重(考虑到CheckPerson接口只包含一个方法)。在这种情况下,你可以使用Lambda表达式代替匿名类。

方案5:通过Lambda表达式指定检索条件
CheckPerson接口是一个函数接口。函数接口是任何包含唯一抽象方法的接口。(函数接口也可能包含一个或多个默认方法或静态方法)由于函数接口只包含一个抽象方法,你可以在实现该接口时省略方法名。为了做到这点,你应该使用Lambda表达式代替匿名类。如下:
  
  
  
printPersons(
roster,
(Person p) -> p.getGender() == Person.Sex.MALE
&& p.getAge() >= 18
&& p.getAge() <= 25
);
参考Lambda表达式语法小节,了解如何定义lambda表达式
你可以使用标准函数接口来代替CheckPerson,这进一步减少了所需代码量。

方案6:配合Lambda表达式使用标准函数接口
重新查看CheckPerson接口:
   
   
   
interface CheckPerson {
boolean test(Person p);
}
这是一个非常简单的接口。它只包含了唯一的抽象方法因而它是一个函数接口。该方法接收一个参数并返回一个boolean值。该方法是如此的简单以至于没有必要在应用程序中进行定义。 因此,JDK中的java.util.function包中定义了很多标准的函数接口。

例如,你可以使用Predicate<T>接口来代替CheckPerson。该接口包含方法boolean test(T t):
   
   
   
interface Predicate<T> {
boolean test(T t);
}
Predicate<T>接口是一个泛型接口。(关于泛型的更多信息,参看Generics一节)泛型(如泛型接口)在尖括号内(<>)指定一个或多个类型参数。这个接口包含了唯一的类型参数T。当你使用真实的类型声明或者实例化一个泛型时,你将得到一个参数化类型。例如,参数化类型Predicate<Person>定义如下:
   
   
   
interface Predicate<Person> {
boolean test(Person t);
}
该参数化类型包含了一个方法,其参数和返回类型均与CheckPerson.boolean test(Person p)方法相同。因而,你可以使用Predicate<T>代替CheckPerson:
   
   
   
public static void printPersonsWithPredicate(
List<Person> roster, Predicate<Person> tester) {
for (Person p : roster) {
if (tester.test(p)) {
p.printPerson();
}
}
}
如下的方法调用跟方案3中printPersons效果相同,获取了符合义务兵役条件的成员:
   
   
   
printPersonsWithPredicate(
roster,
p -> p.getGender() == Person.Sex.MALE
&& p.getAge() >= 18
&& p.getAge() <= 25
);
这不是该方法中可以使用Lambda表达式的唯一位置。如下方案展示了使用Lambda表达式的其他方法。

方案7:在应用程序中全面使用Lambda表达式
重新考虑printPersonWithPredicate方法中可以使用Lambda表达式的其他位置:
   
   
   
public static void printPersonsWithPredicate(
List<Person> roster, Predicate<Person> tester) {
for (Person p : roster) {
if (tester.test(p)) {
p.printPerson();
}
}
}
该方法检查List参数roster中的每个Person实例,通过Predicate参数tester测试Person实例是否满足条件。如果满足,就调用Person实例的printPerson方法。

除了调用printPerson方法,你可以通过Lambda表达式指定在Person实例上进行的操作。假设你需要一个类似于printPerson的Lambda表达式,接收一个参数(Person类的对象)并返回void。请记住,要使用lambda表达式,你需要实现一个函数接口。在这种情况下,你需要一个包含一个抽象方法的函数接口,该抽象方法接收一个Person类参数并返回void。Consumer<T>接口包含了方法void accept<T t>,正好满足需求。下面的方法将p.printPerson()调用替换为一个Consumer<Person>实例的accept方法调用。
   
   
   
public static void processPersons(
List<Person> roster,
Predicate<Person> tester,
Consumer<Person> block) {
for (Person p : roster) {
if (tester.test(p)) {
block.accept(p);
}
}
}
结果上,如下的方法调用与方案3中的printPersons调用效果相同:
   
   
   
processPersons(
roster,
p -> p.getGender() == Person.Sex.MALE
&& p.getAge() >= 18
&& p.getAge() <= 25,
p -> p.printPerson()
);
如果你需要对成员信息做更多的操作而非仅仅打印。假设你需要验证成员的个人信息或者获取他们的联系信息,该怎么做?在这种情况下,你需要一个函数接口,该接口包含了一个能返回某值的抽象方法。Function<T, R>接口包含了R apply(T t)方法。如下方法获取了mapper参数指定的数据,并对其执行由block参数指定的操作:
   
   
   
public static void processPersonsWithFunction(
List<Person> roster,
Predicate<Person> tester,
Function<Person, String> mapper,
Consumer<String> block) {
for (Person p : roster) {
if (tester.test(p)) {
String data = mapper.apply(p);
block.accept(data);
}
}
}
如下的方法获取了每个成员email address,并且打印出来:
   
   
   
processPersonsWithFunction(
roster,
p -> p.getGender() == Person.Sex.MALE
&& p.getAge() >= 18
&& p.getAge() <= 25,
p -> p.getEmailAddress(),
email -> System.out.println(email)
);

方案8:更大范围的使用泛型
重新考虑方法processPersonsWithFunction。如下是一个泛型版本:
   
   
   
public static <X, Y> void processElements(
Iterable<X> source,
Predicate<X> tester,
Function <X, Y> mapper,
Consumer<Y> block) {
for (X p : source) {
if (tester.test(p)) {
Y data = mapper.apply(p);
block.accept(data);
}
}
}
打印满足义务兵役条件的成员电子邮件方法如下:

   
   
   
processElements(
roster,
p -> p.getGender() == Person.Sex.MALE
&& p.getAge() >= 18
&& p.getAge() <= 25,
p -> p.getEmailAddress(),
email -> System.out.println(email)
);
该方法调用执行了如下操作:
  1. 获取对象集合。在本例中,获取了Person对象的集合roster。值得注意的是集合roster是属于List类型的集合同时也是Iterable类型的对象。
  2. 筛选出满足Predicate对象tester指定条件的对象。本例中,Predicate对象由一个Lambda表达式提供,表达式指定了义务兵役的条件。
  3. 通过Function对象将筛选出的对象映射为一个值。本例中,Function对象由一个返回成员电子邮箱的Lambda表达式提供。
  4. 在每一个映射后的值上执行由Consumer对象block指定的操作。本例中,Consumer对象是一个Lambda表达式,表达式打印由Function对象返回的电子邮箱地址。
你还可以将这每个操作替换为聚集操作(aggregate operation)

方案9:使用以Lambda表达式作为参数的聚集操作
接下来的示例使用了聚集操作完成了方案8中相同的任务:
   
   
   
roster
.stream()
.filter(
p -> p.getGender() == Person.Sex.MALE
&& p.getAge() >= 18
&& p.getAge() <= 25)
.map(p -> p.getEmailAddress())
.forEach(email -> System.out.println(email));
下面的表格列出了processElements方法执行的每个操作及相应的聚集操作
processElements Action Aggregate Operation
获取对象源 Stream<E> stream()
筛选匹配Predicate对象的对象 Stream<T> filter(Predicate<? super T> predicate)
根据Function对象将筛选对象映射到某值 <R> Stream<R> map(Function<? super T, ? extends R> mapper)
通过Consumer对象执行指定操作 void forEach(Consumer<? super T> action)
filter,map以及forEach操作都是聚集操作。聚集操作从一个流中获取需要操作的元素,而不是直接从集合中获取(这就是开始需要调用stream的原因)。一个流是元素的序列。不同于集合,它不是一个存储元素的数据结构。相反,流通过管道从源(例如集合)中运输数据。管道是一系列的流操作,本例中就是filter-map-forEach。除此之外,聚集操作一般使用Lambda表达式作为其参数,从而达到自定义的目的。

在GUI应用程序中应用Lambda表达式

在图形用户界面的应用中响应诸如键盘事件、鼠标事件或者滚动事件的时候,你一般都需要创建事件处理器,而这通常涉及到实现某个接口。通常情况下,事件处理接口都是包含一个方法的函数接口。

在匿名类一节中讨论了javaFX示例HelloWorld.java,你可以使用Lambda表达式替代匿名类:
   
   
   
btn.setOnAction(new EventHandler<ActionEvent>() {
 
@Override
public void handle(ActionEvent event) {
System.out.println("Hello World!");
}
});
btn.setOnAction方法指定了当btn代表的按钮被触发时需要执行的操作。这个方法需要一个EventHandler<ActionEvent>类型的对象。EventHandler<ActionEvent>接口只包含了一个方法,void handle(T event)。这是一个函数接口,因此你可以如下使用Lambda表达式代替:
   
   
   
btn.setOnAction(
event -> System.out.println("Hello World!")
);

Lambda表达式的语法

一个lambda表达式由以下几个部分组成:
  • 包围在小括号内的用逗号分隔的形参列表。CheckPerson.test方法包含了一个参数,p,代表了Person类的一个实例。
          注意:lambda表达式的形参类型是可以省略的。除此之外,如果只有一个形参,小括号也可以省略。例如下面的lambda表达式依然是合法的:
  
  
  
p -> p.getGender() == Person.Sex.MALE
&& p.getAge() >= 18
&& p.getAge() <= 25
  • 箭头符号,->
  • 主体,由单独的表达式或者语句块组成。本例中使用如下的表达式:
   
   
   
p.getGender() == Person.Sex.MALE
&& p.getAge() >= 18
&& p.getAge() <= 25
 如果使用了单独的表达式,那么java运行时对表达式求值然后返回该值。或者,你可以使用return 语句:
   
   
   
p -> {
return p.getGender() == Person.Sex.MALE
&& p.getAge() >= 18
&& p.getAge() <= 25;
}
return语句由于不是一个表达式,因而你必须将它放置在一对花括号({})中。然而你不必将返回void的方法调用放到花括号中。例如,下面是一个合法的lambda表达式:
   
   
   
email -> System.out.println(email)
鉴于lambda看起来和方法声明很相似;你可以将lambda表达式看成是匿名方法。

接下来的示例,Calculator展示了接收一个以上形参的Lambda用法:
   
   
   
public class Calculator {
interface IntegerMath {
int operation(int a, int b);
}
public int operateBinary(int a, int b, IntegerMath op) {
return op.operation(a, b);
}
public static void main(String... args) {
Calculator myApp = new Calculator();
IntegerMath addition = (a, b) -> a + b;
IntegerMath subtraction = (a, b) -> a - b;
System.out.println("40 + 2 = " +
myApp.operateBinary(40, 2, addition));
System.out.println("20 - 10 = " +
myApp.operateBinary(20, 10, subtraction));
}
}
operateBinary方法对两个整数进行数学运算。运算的方法由IntegerMath实例提供。该示例使用lambda定义了两个运算,addition和subtraction。以下是示例的输出:
   
   
   
40 + 2 = 42
20 - 10 = 10

访问外围作用域中的局部变量

跟局部类及匿名类一样,lambda表达式也可以捕获变量;他们对于局部变量有相同的访问权限。然而和局部及匿名类不同的是,lambda表达式不存在任何隐匿问题(shadowing issues)。lambda表达式只是词法意义上的作用域。这意味着它们没有从超类继承任何标识符或者是引入了新的作用域。lambda中的声明和在外围作用域中声明是等价的。下面的示例,LambdaScopeTest,展示了这点:
   
   
   
import java.util.function.Consumer;
 
public class LambdaScopeTest {
 
public int x = 0;
 
class FirstLevel {
 
public int x = 1;
 
void methodInFirstLevel(int x) {
// The following statement causes the compiler to generate
// the error "local variables referenced from a lambda expression
// must be final or effectively final" in statement A:
//
// x = 99;
Consumer<Integer> myConsumer = (y) ->
{
System.out.println("x = " + x); // Statement A
System.out.println("y = " + y);
System.out.println("this.x = " + this.x);
System.out.println("LambdaScopeTest.this.x = " +
LambdaScopeTest.this.x);
};
 
myConsumer.accept(x);
 
}
}
 
public static void main(String... args) {
LambdaScopeTest st = new LambdaScopeTest();
LambdaScopeTest.FirstLevel fl = st.new FirstLevel();
fl.methodInFirstLevel(23);
}
}
示例的输出如下:
   
   
   
x = 23
y = 23
this.x = 1
LambdaScopeTest.this.x = 0
如果将lambda表达式myConsumer中的参数y替换为x,编译器会提示错误:
   
   
   
Consumer<Integer> myConsumer = (x) -> {
// ...
}
编译器提示错误“变量x已经在方法methodInFirstLevel(int)定义了”,因为lambda表达式并没有引入新的一层作用域。结果就是你可以直接访问域,方法以及外围作用域中的局部变量。例如lambda表达式可以直接访问methodInFirstLevel的参数x。需要访问外围类的域,就使用this关键字。在该示例中,this.x指向的是成员变量FirstLevel.x。

然而如同局部以及匿名类,lambda表达式只可以访问final或者effectively final的局部变量和外围作用域参数。假使在methodFirstLevel的开始处加入如下的赋值语句:
   
   
   
void methodInFirstLevel(int x) {
x = 99;
// ...
}
由于这个赋值语句,变量FirstLevel.x不再是effectively final了。此时编译器就会在lambda表达式试图访问FirstLevel.x变量的地方输出错误信息,“lambda表达式所访问的局部变量必须是final或者effectively final”。
   
   
   
System.out.println("x = " + x);

目标定型
我们是怎么决定一个lambda表达式的类型的呢?回想筛选符合义务兵役成员的lambda表达式:
   
   
   
p -> p.getGender() == Person.Sex.MALE
&& p.getAge() >= 18
&& p.getAge() <= 25
这个lambda被用在以下两个方法中:
  • public static void printPersons(List<Person> roster, CheckPerson tester),见方案3
  • public void printPersonWithPredicate(List<Person> roster, Predicate<Person> tester),见方案6
当java运行时调用printPersons时,它期望一个CheckPerson数据类型,因而lambda表达式就是这个类型了。然而,当Java运行时调用printPersonsWithPredicate的时候,它期望一个Predicate<Person>类型,因而lambda表达式就成为了这个类型的实例。这些方法期望的数据类型被称为目标类型(target type)。为了决定一个lambda的类型,当java编译器发现一个lambda表达式的时候,它会使用当前上下文或者环境的目标类型。这也就意味着你只能在编译器能够确定目标类型的情形下使用lambda表达式:
  • 变量的声明
  • 赋值
  • return 语句
  • 数组初始化
  • 方法或者构造函数参数
  • Lambda表达式bodies
  • 条件表达式,?:
  • 类型转换
目标类型与方法参数

对于方法参数,java编译器使用两个特性来进行决定目标类型:重载决议(overload resolution)和类型推导(argument inference)。
如下面两个函数接口(java.lang.Runnable和java.util.concurrent.Callable<V>)
   
   
   
public interface Runnable {
void run();
}
 
public interface Callable<V> {
V call();
}
方法Runnable.run没有返回值,然而Callable<V>.call有。
假设你重载了invoke方法:
   
   
   
void invoke(Runnable r) {
r.run();
}
 
<T> T invoke(Callable<T> c) {
return c.call();
}
那么如下语句将会调用那个函数?
   
   
   
String s = invoke(() -> "done");
invoke(Callable<T>)方法将会得到调用,因为该方法有返回值而invoke(Runnable)没有。在这种情况下,lambda表达式(()->"done")的类型为Callable<T>。

序列化
如果lambda表达式的目标参数并且捕获参数都是可以可序列化的,那么该lambda表达式也是可以序列化的。然而,正如内部类一样,对其序列化是极不提倡的。