首页 » Java 8实战 » Java 8实战全文在线阅读

《Java 8实战》第3章 Lambda表达式

关灯直达底部

本章内容

  • Lambda管中窥豹

  • 在哪里以及如何使用Lambda

  • 环绕执行模式

  • 函数式接口,类型推断

  • 方法引用

  • Lambda复合

在上一章中,你了解了利用行为参数化来传递代码有助于应对不断变化的需求。它允许你定义一个代码块来表示一个行为,然后传递它。你可以决定在某一事件发生时(例如单击一个按钮)或在算法中的某个特定时刻(例如筛选算法中类似于“重量超过150克的苹果”的谓词,或排序中的自定义比较操作)运行该代码块。一般来说,利用这个概念,你就可以编写更为灵活且可重复使用的代码了。

但你也看到,使用匿名类来表示不同的行为并不令人满意:代码十分啰嗦,这会影响程序员在实践中使用行为参数化的积极性。在本章中,我们会教给你Java 8中解决这个问题的新工具——Lambda表达式。它可以让你很简洁地表示一个行为或传递代码。现在你可以把Lambda表达式看作匿名功能,它基本上就是没有声明名称的方法,但和匿名类一样,它也可以作为参数传递给一个方法。

我们会展示如何构建Lambda,它的使用场合,以及如何利用它使代码更简洁。我们还会介绍一些新的东西,如类型推断和Java 8 API中重要的新接口。最后,我们将介绍方法引用(method reference),这是一个常常和Lambda表达式联用的有用的新功能。

本章的行文思想就是教你如何一步一步地写出更简洁、更灵活的代码。在本章结束时,我们会把所有教过的概念融合在一个具体的例子里:我们会用Lambda表达式和方法引用逐步改进第2章中的排序例子,使之更加简明易读。这一章很重要,而且你将在本书中大量使用Lambda。

3.1 Lambda管中窥豹

可以把Lambda表达式理解为简洁地表示可传递的匿名函数的一种方式:它没有名称,但它有参数列表、函数主体、返回类型,可能还有一个可以抛出的异常列表。这个定义够大的,让我们慢慢道来。

  • 匿名——我们说匿名,是因为它不像普通的方法那样有一个明确的名称:写得少而想得多!

  • 函数——我们说它是函数,是因为Lambda函数不像方法那样属于某个特定的类。但和方法一样,Lambda有参数列表、函数主体、返回类型,还可能有可以抛出的异常列表。

  • 传递——Lambda表达式可以作为参数传递给方法或存储在变量中。

  • 简洁——无需像匿名类那样写很多模板代码。

你是不是好奇Lambda这个词是从哪儿来的?其实它来自于学术界开发出来的一套用来描述计算的λ演算法。 你为什么应该关心Lambda表达式呢?你在上一章中看到了,在Java中传递代码十分繁琐和冗长。那么,现在有了好消息!Lambda解决了这个问题:它可以让你十分简明地传递代码。理论上来说,你在Java 8之前做不了的事情,Lambda也做不了。但是,现在你用不着再用匿名类写一堆笨重的代码,来体验行为参数化的好处了!Lambda表达式鼓励你采用我们上一章中提到的行为参数化风格。最终结果就是你的代码变得更清晰、更灵活。比如,利用Lambda表达式,你可以更为简洁地自定义一个Comparator对象。

图 3-1 Lambda表达式由参数、箭头和主体组成

先前:

Comparator<Apple> byWeight = new Comparator<Apple> {    public int compare(Apple a1, Apple a2){        return a1.getWeight.compareTo(a2.getWeight);    }};  

之后(用了Lambda表达式):

Comparator<Apple> byWeight =    (Apple a1, Apple a2) -> a1.getWeight.compareTo(a2.getWeight);  

不得不承认,代码看起来更清晰了!要是现在你觉得Lambda表达式看起来一头雾水的话也没关系,我们很快会一点点解释清楚的。现在,请注意你基本上只传递了比较两个苹果重量所真正需要的代码。看起来就像是只传递了compare方法的主体。你很快就会学到,你甚至还可以进一步简化代码。我们将在下一节解释在哪里以及如何使用Lambda表达式。

我们刚刚展示给你的Lambda表达式有三个部分,如图3-1所示。

  • 参数列表——这里它采用了Comparatorcompare方法的参数,两个Apple

  • 箭头——箭头->把参数列表与Lambda主体分隔开。

  • Lambda主体——比较两个Apple的重量。表达式就是Lambda的返回值了。

为了进一步说明,下面给出了Java 8中五个有效的Lambda表达式的例子。

代码清单3-1 Java 8中有效的Lambda表达式

(String s) -> s.length           ←─第一个Lambda表达式具有一个String类型的参数并返回一个int。Lambda没有return语句,因为已经隐含了return(Apple a) -> a.getWeight > 150        ←─第二个Lambda表达式有一个Apple 类型的参数并返回一个boolean(苹果的重量是否超过150克)(int x, int y) -> {    System.out.println("Result:");    System.out.println(x+y);            ←─第三个Lambda表达式具有两个int类型的参数而没有返回值(void返回)。注意Lambda表达式可以包含多行语句,这里是两行} -> 42            ←─第四个Lambda表达式没有参数, 返回一个int(Apple a1, Apple a2) -> a1.getWeight.compareTo(a2.getWeight)    ←─第五个Lambda表达式具有两个Apple类型的参数,返回一个int:比较两个Apple的重量  

Java语言设计者选择这样的语法,是因为C#和Scala等语言中的类似功能广受欢迎。Lambda的基本语法是

(parameters) -> expression  

或(请注意语句的花括号)

(parameters) -> { statements; }  

你可以看到,Lambda表达式的语法很简单。做一下测验3.1,看看自己是不是理解了这个模式。

测验3.1:Lambda语法

根据上述语法规则,以下哪个不是有效的Lambda表达式?

(1) -> {}

(2) -> "Raoul"

(3) -> {return "Mario";}

(4) (Integer i) -> return "Alan" + i;

(5) (String s) -> {"IronMan";}

答案:只有4和5是无效的Lambda。

(1) 这个Lambda没有参数,并返回void。它类似于主体为空的方法:public void run {}

(2) 这个Lambda没有参数,并返回String作为表达式。

(3) 这个Lambda没有参数,并返回String(利用显式返回语句)。

(4) return是一个控制流语句。要使此Lambda有效,需要使花括号,如下所示:(Integer i) -> {return "Alan" + i;}

(5)“Iron Man”是一个表达式,不是一个语句。要使此Lambda有效,你可以去除花括号和分号,如下所示:(String s) -> "Iron Man"。或者如果你喜欢,可以使用显式返回语句,如下所示:(String s)->{return "IronMan";}

表3-1提供了一些Lambda的例子和使用案例。

表3-1 Lambda示例

使用案例

Lambda示例

布尔表达式

(List<String> list) -> list.isEmpty

创建对象

-> new Apple(10)

消费一个对象

(Apple a) -> {    System.out.println(a.getWeight);}  

从一个对象中选择/抽取

(String s) -> s.length

组合两个值

(int a, int b) -> a * b

比较两个对象

(Apple a1, Apple a2) -> a1.getWeight.compareTo(a2.getWeight)

3.2 在哪里以及如何使用Lambda

现在你可能在想,在哪里可以使用Lambda表达式。在上一个例子中,你把Lambda赋给了一个Comparator<Apple>类型的变量。你也可以在上一章中实现的filter方法中使用Lambda:

List<Apple> greenApples =    filter(inventory, (Apple a) -> "green".equals(a.getColor));  

那到底在哪里可以使用Lambda呢?你可以在函数式接口上使用Lambda表达式。在上面的代码中,你可以把Lambda表达式作为第二个参数传给filter方法,因为它这里需要Predicate<T>,而这是一个函数式接口。如果这听起来太抽象,不要担心,现在我们就来详细解释这是什么意思,以及函数式接口是什么。

3.2.1 函数式接口

还记得你在第2章里,为了参数化filter方法的行为而创建的Predicate<T>接口吗?它就是一个函数式接口!为什么呢?因为Predicate仅仅定义了一个抽象方法:

public interface Predicate<T>{    boolean test (T t);}  

一言以蔽之,函数式接口就是只定义一个抽象方法的接口。你已经知道了Java API中的一些其他函数式接口,如我们在第2章中谈到的ComparatorRunnable

public interface Comparator<T> {    ←─java.util.Comparator    int compare(T o1, T o2);}public interface Runnable{    ←─java.lang.Runnable    void run;}public interface ActionListener extends EventListener{    ←─java.awt.event.ActionListener    void actionPerformed(ActionEvent e);}public interface Callable<V>{    ←─java.util.concurrent.Callable    V call;}public interface PrivilegedAction<V>{    ←─java.security.PrivilegedAction    V run;}  

注意 你将会在第9章中看到,接口现在还可以拥有默认方法(即在类没有对方法进行实现时,其主体为方法提供默认实现的方法)。哪怕有很多默认方法,只要接口只定义了一个抽象方法,它就仍然是一个函数式接口。

为了检查你的理解程度,测验3.2将帮助你测试自己是否掌握了函数式接口的概念。

测验3.2:函数式接口

下面哪些接口是函数式接口?

public interface Adder{    int add(int a, int b);}public interface SmartAdder extends Adder{    int add(double a, double b);}public interface Nothing{}  

答案:只有Adder是函数式接口。

SmartAdder不是函数式接口,因为它定义了两个叫作add的抽象方法(其中一个是从Adder那里继承来的)。

Nothing也不是函数式接口,因为它没有声明抽象方法。

用函数式接口可以干什么呢?Lambda表达式允许你直接以内联的形式为函数式接口的抽象方法提供实现,并把整个表达式作为函数式接口的实例(具体说来,是函数式接口一个具体实现的实例)。你用匿名内部类也可以完成同样的事情,只不过比较笨拙:需要提供一个实现,然后再直接内联将它实例化。下面的代码是有效的,因为Runnable是一个只定义了一个抽象方法run的函数式接口:

Runnable r1 =  -> System.out.println("Hello World 1");    ←─使用LambdaRunnable r2 = new Runnable{    ←─使用匿名类    public void run{        System.out.println("Hello World 2");    }};public static void process(Runnable r){    r.run;}process(r1);    ←─打印“Hello World 1”process(r2);    ←─打印“Hello World 2”process( -> System.out.println("Hello World 3"));    ←─利用直接传递的Lambda打印“Hello World 3”  

3.2.2 函数描述符

函数式接口的抽象方法的签名基本上就是Lambda表达式的签名。我们将这种抽象方法叫作函数描述符。例如,Runnable接口可以看作一个什么也不接受什么也不返回(void)的函数的签名,因为它只有一个叫作run的抽象方法,这个方法什么也不接受,什么也不返回(void)。1

1Scala等语言的类型系统提供显式类型标注,可以描述函数的类型(称为“函数类型”)。Java重用了函数式接口提供的标准类型,并将其映射成一种形式的函数类型。

我们在本章中使用了一个特殊表示法来描述Lambda和函数式接口的签名。 -> void代表了参数列表为空,且返回void的函数。这正是Runnable接口所代表的。 举另一个例子,(Apple, Apple) -> int代表接受两个Apple作为参数且返回int的函数。我们会在3.4节和本章后面的表3-2中提供关于函数描述符的更多信息。

你可能已经在想,Lambda表达式是怎么做类型检查的。我们会在3.5节中详细介绍,编译器是如何检查Lambda在给定上下文中是否有效的。现在,只要知道Lambda表达式可以被赋给一个变量,或传递给一个接受函数式接口作为参数的方法就好了,当然这个Lambda表达式的签名要和函数式接口的抽象方法一样。比如,在我们之前的例子里,你可以像下面这样直接把一个Lambda传给 process方法:

public void process(Runnable r){    r.run;}process( -> System.out.println("This is awesome!!"));  

此代码执行时将打印“This is awesome!!”。Lambda表达式-> System.out.println ("This is awesome!!")不接受参数且返回void。 这恰恰是Runnable接口中run方法的签名。

你可能会想:“为什么只有在需要函数式接口的时候才可以传递Lambda呢?”语言的设计者也考虑过其他办法,例如给Java添加函数类型(有点儿像我们介绍的描述Lambda表达式签名的特殊表示法,我们会在第15章和第16章回过来讨论这个问题)。但是他们选择了现在这种方式,因为这种方式自然且能避免语言变得更复杂。此外,大多数Java程序员都已经熟悉了具有一个抽象方法的接口的理念(例如事件处理)。试试看测验3.3,测试一下你对哪里可以使用Lambda这个知识点的掌握情况。

测验3.3:在哪里可以使用Lambda?

以下哪些是使用Lambda表达式的有效方式?

(1)

execute( -> {});public void execute(Runnable r){    r.run;}  

(2)

public Callable<String> fetch {    return  -> "Tricky example  ;-)";}  

(3)

Predicate<Apple> p = (Apple a) -> a.getWeight;  

答案:只有1和2是有效的。

第一个例子有效,是因为Lambda -> {}具有签名 -> void,这和Runnable中的抽象方法run的签名相匹配。请注意,此代码运行后什么都不会做,因为Lambda是空的!

第二个例子也是有效的。事实上,fetch方法的返回类型是Callable<String>Callable<String>基本上就定义了一个方法,签名是 -> String,其中TString代替了。因为Lambda -> "Trickyexample;-)"的签名是 -> String,所以在这个上下文中可以使用Lambda。

第三个例子无效,因为Lambda表达式(Apple a) -> a.getWeight的签名是(Apple) -> Integer,这和Predicate<Apple>:(Apple) -> boolean中定义的test方法的签名不同。

@FunctionalInterface又是怎么回事?

如果你去看看新的Java API,会发现函数式接口带有@FunctionalInterface的标注(3.4节中会深入研究函数式接口,并会给出一个长长的列表)。这个标注用于表示该接口会设计成一个函数式接口。如果你用@FunctionalInterface定义了一个接口,而它却不是函数式接口的话,编译器将返回一个提示原因的错误。例如,错误消息可能是“Multiple non-overriding abstract methods found in interface Foo”,表明存在多个抽象方法。请注意,@FunctionalInterface不是必需的,但对于为此设计的接口而言,使用它是比较好的做法。它就像是@Override标注表示方法被重写了。

3.3 把Lambda付诸实践:环绕执行模式

让我们通过一个例子,看看在实践中如何利用Lambda和行为参数化来让代码更为灵活,更为简洁。资源处理(例如处理文件或数据库)时一个常见的模式就是打开一个资源,做一些处理,然后关闭资源。这个设置和清理阶段总是很类似,并且会围绕着执行处理的那些重要代码。这就是所谓的环绕执行(execute around)模式,如图3-2所示。例如,在以下代码中,高亮显示的就是从一个文件中读取一行所需的模板代码(注意你使用了Java 7中的带资源的try语句,它已经简化了代码,因为你不需要显式地关闭资源了):

public static String processFile throws IOException {    try (BufferedReader br =            new BufferedReader(new FileReader("data.txt"))) {        return br.readLine;    ←─这就是做有用工作的那行代码    }}  

图 3-2 任务A和任务B周围都环绕着进行准备/清理的同一段冗余代码

3.3.1 第1步:记得行为参数化

现在这段代码是有局限的。你只能读文件的第一行。如果你想要返回头两行,甚至是返回使用最频繁的词,该怎么办呢?在理想的情况下,你要重用执行设置和清理的代码,并告诉processFile方法对文件执行不同的操作。这听起来是不是很耳熟?是的,你需要把processFile的行为参数化。你需要一种方法把行为传递给processFile,以便它可以利用BufferedReader执行不同的行为。

传递行为正是Lambda的拿手好戏。那要是想一次读两行,这个新的processFile方法看起来又该是什么样的呢?基本上,你需要一个接收BufferedReader并返回String的Lambda。例如,下面就是从BufferedReader中打印两行的写法:

String result = processFile((BufferedReader br) ->                             br.readLine + br.readLine);  

3.3.2 第2步:使用函数式接口来传递行为

我们前面解释过了,Lambda仅可用于上下文是函数式接口的情况。你需要创建一个能匹配BufferedReader -> String,还可以抛出IOException异常的接口。让我们把这一接口叫作BufferedReaderProcessor吧。

@FunctionalInterfacepublic interface BufferedReaderProcessor {    String process(BufferedReader b) throws IOException;}  

现在你就可以把这个接口作为新的processFile方法的参数了:

public static String processFile(BufferedReaderProcessor p) throws        IOException {   …}  

3.3.3 第3步:执行一个行为

任何BufferedReader -> String形式的Lambda都可以作为参数来传递,因为它们符合BufferedReaderProcessor接口中定义的process方法的签名。现在你只需要一种方法在processFile主体内执行Lambda所代表的代码。请记住,Lambda表达式允许你直接内联,为函数式接口的抽象方法提供实现,并且将整个表达式作为函数式接口的一个实例。因此,你可以在processFile主体内,对得到的BufferedReaderProcessor对象调用process方法执行处理:

public static String processFile(BufferedReaderProcessor p) throws        IOException {    try (BufferedReader br =            new BufferedReader(new FileReader("data.txt"))) {        return p.process(br);    ←─处理BufferedReader对象    }}  

3.3.4 第4步:传递Lambda

现在你就可以通过传递不同的Lambda重用processFile方法,并以不同的方式处理文件了。

处理一行:

String oneLine =    processFile((BufferedReader br) -> br.readLine);  

处理两行:

String twoLines =    processFile((BufferedReader br) -> br.readLine + br.readLine);  

图3-3总结了所采取的使pocessFile方法更灵活的四个步骤。

图 3-3 应用环绕执行模式所采取的四个步骤

我们已经展示了如何利用函数式接口来传递Lambda,但你还是得定义你自己的接口。在下一节中,我们会探讨Java 8中加入的新接口,你可以重用它来传递多个不同的Lambda。

3.4 使用函数式接口

就像你在3.2.1节中学到的,函数式接口定义且只定义了一个抽象方法。函数式接口很有用,因为抽象方法的签名可以描述Lambda表达式的签名。函数式接口的抽象方法的签名称为函数描述符。所以为了应用不同的Lambda表达式,你需要一套能够描述常见函数描述符的函数式接口。Java API中已经有了几个函数式接口,比如你在3.2节中见到的ComparableRunnableCallable

Java 8的库设计师帮你在java.util.function包中引入了几个新的函数式接口。我们接下来会介绍PredicateConsumerFunction,更完整的列表可见本节结尾处的表3-2。

3.4.1 Predicate

java.util.function.Predicate<T>接口定义了一个名叫test的抽象方法,它接受泛型T对象,并返回一个boolean。这恰恰和你先前创建的一样,现在就可以直接使用了。在你需要表示一个涉及类型T的布尔表达式时,就可以使用这个接口。比如,你可以定义一个接受String对象的Lambda表达式,如下所示。

代码清单3-2 使用Predicate

@FunctionalInterfacepublic interface Predicate<T>{    boolean test(T t);}public static <T> List<T> filter(List<T> list, Predicate<T> p) {    List<T> results = new ArrayList<>;    for(T s: list){        if(p.test(s)){            results.add(s);        }    }    return results;}Predicate<String> nonEmptyStringPredicate = (String s) -> !s.isEmpty;List<String> nonEmpty = filter(listOfStrings, nonEmptyStringPredicate);  

如果你去查Predicate接口的Javadoc说明,可能会注意到诸如andor等其他方法。现在你不用太计较这些,我们会在3.8节讨论。

3.4.2 Consumer

java.util.function.Consumer<T>定义了一个名叫accept的抽象方法,它接受泛型T的对象,没有返回(void)。你如果需要访问类型T的对象,并对其执行某些操作,就可以使用这个接口。比如,你可以用它来创建一个forEach方法,接受一个Integers的列表,并对其中每个元素执行操作。在下面的代码中,你就可以使用这个forEach方法,并配合Lambda来打印列表中的所有元素。

代码清单3-3 使用Consumer

@FunctionalInterfacepublic interface Consumer<T>{    void accept(T t);}public static <T> void forEach(List<T> list, Consumer<T> c){    for(T i: list){        c.accept(i);    }}forEach(         Arrays.asList(1,2,3,4,5),        (Integer i) -> System.out.println(i)    ←─Lambda是Consumer中accept方法的实现       );  

3.4.3 Function

java.util.function.Function<T, R>接口定义了一个叫作apply的方法,它接受一个泛型T的对象,并返回一个泛型R的对象。如果你需要定义一个Lambda,将输入对象的信息映射到输出,就可以使用这个接口(比如提取苹果的重量,或把字符串映射为它的长度)。在下面的代码中,我们向你展示如何利用它来创建一个map方法,以将一个String列表映射到包含每个String长度的Integer列表。

代码清单3-4 使用Function

@FunctionalInterfacepublic interface Function<T, R>{    R apply(T t);}public static <T, R> List<R> map(List<T> list,                                 Function<T, R> f) {    List<R> result = new ArrayList<>;    for(T s: list){        result.add(f.apply(s));    }    return result;}// [7, 2, 6]List<Integer> l = map(                       Arrays.asList("lambdas","in","action"),                       (String s) -> s.length    ←─Lambda是Function接口的apply方法的实现               );  

原始类型特化

我们介绍了三个泛型函数式接口:Predicate<T>Consumer<T>Function<T,R>。还有些函数式接口专为某些类型而设计。

回顾一下:Java类型要么是引用类型(比如ByteIntegerObjectList),要么是原始类型(比如intdoublebytechar)。但是泛型(比如Consumer<T>中的T)只能绑定到引用类型。这是由泛型内部的实现方式造成的。2因此,在Java里有一个将原始类型转换为对应的引用类型的机制。这个机制叫作装箱(boxing)。相反的操作,也就是将引用类型转换为对应的原始类型,叫作拆箱(unboxing)。Java还有一个自动装箱机制来帮助程序员执行这一任务:装箱和拆箱操作是自动完成的。比如,这就是为什么下面的代码是有效的(一个int被装箱成为Integer):

2C#等其他语言没有这一限制。Scala等语言只有引用类型。我们会在第16章再次探讨这个问题。

List<Integer> list = new ArrayList<>;for (int i = 300; i < 400; i++){    list.add(i);}  

但这在性能方面是要付出代价的。装箱后的值本质上就是把原始类型包裹起来,并保存在堆里。因此,装箱后的值需要更多的内存,并需要额外的内存搜索来获取被包裹的原始值。

Java 8为我们前面所说的函数式接口带来了一个专门的版本,以便在输入和输出都是原始类型时避免自动装箱的操作。比如,在下面的代码中,使用IntPredicate就避免了对值 1000进行装箱操作,但要是用Predicate<Integer>就会把参数1000装箱到一个Integer对象中:

public interface IntPredicate{    boolean test(int t);}IntPredicate evenNumbers = (int i) -> i % 2 == 0;evenNumbers.test(1000);                                 ←─true(无装箱)Predicate<Integer> oddNumbers = (Integer i) -> i % 2 == 1;oddNumbers.test(1000);                                           ←─false(装箱)  

一般来说,针对专门的输入参数类型的函数式接口的名称都要加上对应的原始类型前缀,比如DoublePredicateIntConsumerLongBinaryOperatorIntFunction等。Function接口还有针对输出参数类型的变种:ToIntFunction<T>IntToDoubleFunction等。

表3-2总结了Java API中提供的最常用的函数式接口及其函数描述符。请记得这只是一个起点。如果有需要,你可以自己设计一个。请记住,(T,U) -> R的表达方式展示了应当如何思考一个函数描述符。表的左侧代表了参数类型。这里它代表一个函数,具有两个参数,分别为泛型TU,返回类型为R

表3-2 Java 8中的常用函数式接口

函数式接口

函数描述符

原始类型特化

Predicate<T>

T->boolean

IntPredicate,LongPredicate,DoublePredicate

Consumer<T>

T->void

IntConsumer,LongConsumer, DoubleConsumer

Function<T,R>

T->R

IntFunction<R>,IntToDoubleFunction,IntToLongFunction,LongFunction<R>,LongToDoubleFunction,LongToIntFunction,DoubleFunction<R>,ToIntFunction<T>,ToDoubleFunction<T>,ToLongFunction<T>

Supplier<T>

->T

BooleanSupplier,IntSupplier,LongSupplier, DoubleSupplier

UnaryOperator<T>

T->T

IntUnaryOperator,LongUnaryOperator,DoubleUnaryOperator

BinaryOperator<T>

(T,T)->T

IntBinaryOperator,LongBinaryOperator,DoubleBinaryOperator

BiPredicate<L,R>

(L,R)->boolean

BiConsumer<T,U>

(T,U)->void

ObjIntConsumer<T>,ObjLongConsumer<T>,ObjDoubleConsumer<T>

BiFunction<T,U,R>

(T,U)->R

ToIntBiFunction<T,U>,ToLongBiFunction<T,U>,ToDoubleBiFunction<T,U>

你现在已经看到了很多函数式接口,可以用于描述各种Lambda表达式的签名。为了检验你的理解程度,试试测验3.4。

测验3.4:函数式接口

对于下列函数描述符(即Lambda表达式的签名),你会使用哪些函数式接口?在表3-2中可以找到大部分答案。作为进一步练习,请构造一个可以利用这些函数式接口的有效Lambda表达式:

(1) T->R

(2) (int, int)->int

(3) T->void

(4) ->T

(5) (T, U)->R

答案如下。

(1) Function<T,R>不错。它一般用于将类型T的对象转换为类型R的对象(比如Function<Apple, Integer>用来提取苹果的重量)。

(2) IntBinaryOperator具有唯一一个抽象方法,叫作applyAsInt,它代表的函数描述符是(int, int) -> int

(3) Consumer<T>具有唯一一个抽象方法叫作accept,代表的函数描述符是T -> void

(4) Supplier<T>具有唯一一个抽象方法叫作get,代表的函数描述符是-> T。或者, Callable<T>具有唯一一个抽象方法叫作call,代表的函数描述符是 -> T

(5) BiFunction<T, U, R>具有唯一一个抽象方法叫作apply,代表的函数描述符是(T, U) -> R

为了总结关于函数式接口和Lambda的讨论,表3-3总结了一些使用案例、Lambda的例子,以及可以使用的函数式接口。

表3-3 Lambdas及函数式接口的例子

使用案例

Lambda的例子

对应的函数式接口

布尔表达式

(List<String> list) -> list.isEmpty

Predicate<List<String>>

创建对象

-> new Apple(10)

Supplier<Apple>

消费一个对象

(Apple a) ->System.out.println(a.getWeight)

Consumer<Apple>

从一个对象中选择/提取

(String s) -> s.length

Function<String, Integer>ToIntFunction<String>

合并两个值

(int a, int b) -> a * b

IntBinaryOperator

比较两个对象

(Apple a1, Apple a2) ->a1.getWeight.compareTo(a2.getWeight)

Comparator<Apple>BiFunction<Apple, Apple, Integer>ToIntBiFunction<Apple, Apple>

异常、Lambda,还有函数式接口又是怎么回事呢?

请注意,任何函数式接口都不允许抛出受检异常(checked exception)。如果你需要Lambda表达式来抛出异常,有两种办法:定义一个自己的函数式接口,并声明受检异常,或者把Lambda包在一个try/catch块中。

比如,在3.3节我们介绍了一个新的函数式接口BufferedReaderProcessor,它显式声明了一个IOException

@FunctionalInterfacepublic interface BufferedReaderProcessor {    String process(BufferedReader b) throws IOException;}BufferedReaderProcessor p = (BufferedReader br) -> br.readLine;  

但是你可能是在使用一个接受函数式接口的API,比如Function<T, R>,没有办法自己创建一个(你会在下一章看到,Stream API中大量使用表3-2中的函数式接口)。这种情况下,你可以显式捕捉受检异常:

Function<BufferedReader, String> f = (BufferedReader b) -> {    try {        return b.readLine;    }    catch(IOException e) {        throw new RuntimeException(e);    }};  

现在你知道如何创建Lambda,在哪里以及如何使用它们了。接下来我们会介绍一些更高级的细节:编译器如何对Lambda做类型检查,以及你应当了解的规则,诸如Lambda在自身内部引用局部变量,还有和void兼容的Lambda等。你无需立即就充分理解下一节的内容,可以留待日后再看,现在可继续看3.6节讲的方法引用。

3.5 类型检查、类型推断以及限制

当我们第一次提到Lambda表达式时,说它可以为函数式接口生成一个实例。然而,Lambda表达式本身并不包含它在实现哪个函数式接口的信息。为了全面了解Lambda表达式,你应该知道Lambda的实际类型是什么。

3.5.1 类型检查

Lambda的类型是从使用Lambda的上下文推断出来的。上下文(比如,接受它传递的方法的参数,或接受它的值的局部变量)中Lambda表达式需要的类型称为目标类型。让我们通过一个例子,看看当你使用Lambda表达式时背后发生了什么。图3-4概述了下列代码的类型检查过程。

List<Apple> heavierThan150g =        filter(inventory, (Apple a) -> a.getWeight > 150);  

图 3-4 解读Lambda表达式的类型检查过程

类型检查过程可以分解为如下所示。

  • 首先,你要找出filter方法的声明。

  • 第二,要求它是Predicate<Apple>(目标类型)对象的第二个正式参数。

  • 第三,Predicate<Apple>是一个函数式接口,定义了一个叫作test的抽象方法。

  • 第四,test方法描述了一个函数描述符,它可以接受一个Apple,并返回一个boolean

  • 最后,filter的任何实际参数都必须匹配这个要求。

这段代码是有效的,因为我们所传递的Lambda表达式也同样接受Apple为参数,并返回一个boolean。请注意,如果Lambda表达式抛出一个异常,那么抽象方法所声明的throws语句也必须与之匹配。

3.5.2 同样的Lambda,不同的函数式接口

有了目标类型的概念,同一个Lambda表达式就可以与不同的函数式接口联系起来,只要它们的抽象方法签名能够兼容。比如,前面提到的CallablePrivilegedAction,这两个接口都代表着什么也不接受且返回一个泛型T的函数。 因此,下面两个赋值是有效的:

Callable<Integer> c =  -> 42;PrivilegedAction<Integer> p =  -> 42;  

这里,第一个赋值的目标类型是Callable<Integer>,第二个赋值的目标类型是PrivilegedAction<Integer>

在表3-3中我们展示了一个类似的例子;同一个Lambda可用于多个不同的函数式接口:

Comparator<Apple> c1 =    (Apple a1, Apple a2) -> a1.getWeight.compareTo(a2.getWeight);ToIntBiFunction<Apple, Apple> c2 =    (Apple a1, Apple a2) -> a1.getWeight.compareTo(a2.getWeight);BiFunction<Apple, Apple, Integer> c3 =    (Apple a1, Apple a2) -> a1.getWeight.compareTo(a2.getWeight);  

菱形运算符

那些熟悉Java的演变的人会记得,Java 7中已经引入了菱形运算符(<>),利用泛型推断从上下文推断类型的思想(这一思想甚至可以追溯到更早的泛型方法)。一个类实例表达式可以出现在两个或更多不同的上下文中,并会像下面这样推断出适当的类型参数:

List<String> listOfStrings = new ArrayList<>;List<Integer> listOfIntegers = new ArrayList<>;  

特殊的void兼容规则

如果一个Lambda的主体是一个语句表达式, 它就和一个返回void的函数描述符兼容(当然需要参数列表也兼容)。例如,以下两行都是合法的,尽管Listadd方法返回了一个boolean,而不是Consumer上下文(T -> void)所要求的void

// Predicate返回了一个booleanPredicate<String> p = s -> list.add(s);// Consumer返回了一个voidConsumer<String> b = s -> list.add(s);  

到现在为止,你应该能够很好地理解在什么时候以及在哪里可以使用Lambda表达式了。它们可以从赋值的上下文、方法调用的上下文(参数和返回值),以及类型转换的上下文中获得目标类型。为了检验你的掌握情况,请试试测验3.5。

测验3.5:类型检查——为什么下面的代码不能编译呢?

你该如何解决这个问题呢?

Object o =  -> {System.out.println("Tricky example"); };  

答案:Lambda表达式的上下文是Object(目标类型)。但Object不是一个函数式接口。为了解决这个问题,你可以把目标类型改成Runnable,它的函数描述符是 -> void

Runnable r =  -> {System.out.println("Tricky example"); };  

你已经见过如何利用目标类型来检查一个Lambda是否可以用于某个特定的上下文。其实,它也可以用来做一些略有不同的事:推断Lambda参数的类型。

3.5.3 类型推断

你还可以进一步简化你的代码。Java编译器会从上下文(目标类型)推断出用什么函数式接口来配合Lambda表达式,这意味着它也可以推断出适合Lambda的签名,因为函数描述符可以通过目标类型来得到。这样做的好处在于,编译器可以了解Lambda表达式的参数类型,这样就可以在Lambda语法中省去标注参数类型。换句话说,Java编译器会像下面这样推断Lambda的参数类型:3

3请注意,当Lambda仅有一个类型需要推断的参数时,参数名称两边的括号也可以省略。

List<Apple> greenApples =    filter(inventory, a -> "green".equals(a.getColor));    ←─参数a没有显式类型  

Lambda表达式有多个参数,代码可读性的好处就更为明显。例如,你可以这样来创建一个Comparator对象:

Comparator<Apple> c =    (Apple a1, Apple a2) -> a1.getWeight.compareTo(a2.getWeight);    ←─没有类型推断Comparator<Apple> c =    (a1, a2) -> a1.getWeight.compareTo(a2.getWeight);    ←─有类型推断  

请注意,有时候显式写出类型更易读,有时候去掉它们更易读。没有什么法则说哪种更好;对于如何让代码更易读,程序员必须做出自己的选择。

3.5.4 使用局部变量

我们迄今为止所介绍的所有Lambda表达式都只用到了其主体里面的参数。但Lambda表达式也允许使用自由变量(不是参数,而是在外层作用域中定义的变量),就像匿名类一样。 它们被称作捕获Lambda。例如,下面的Lambda捕获了portNumber变量:

int portNumber = 1337;Runnable r =  -> System.out.println(portNumber);  

尽管如此,还有一点点小麻烦:关于能对这些变量做什么有一些限制。Lambda可以没有限制地捕获(也就是在其主体中引用)实例变量和静态变量。但局部变量必须显式声明为final,或事实上是final。换句话说,Lambda表达式只能捕获指派给它们的局部变量一次。(注:捕获实例变量可以被看作捕获最终局部变量this。) 例如,下面的代码无法编译,因为portNumber变量被赋值两次:

int portNumber = 1337;Runnable r =  -> System.out.println(portNumber);    ←─错误:Lambda表达式引用的局部变量必须是最终的(final)或事实上最终的portNumber = 31337;  

对局部变量的限制

你可能会问自己,为什么局部变量有这些限制。第一,实例变量和局部变量背后的实现有一个关键不同。实例变量都存储在堆中,而局部变量则保存在栈上。如果Lambda可以直接访问局部变量,而且Lambda是在一个线程中使用的,则使用Lambda的线程,可能会在分配该变量的线程将这个变量收回之后,去访问该变量。因此,Java在访问自由局部变量时,实际上是在访问它的副本,而不是访问原始变量。如果局部变量仅仅赋值一次那就没有什么区别了——因此就有了这个限制。

第二,这一限制不鼓励你使用改变外部变量的典型命令式编程模式(我们会在以后的各章中解释,这种模式会阻碍很容易做到的并行处理)。

闭包

你可能已经听说过闭包(closure,不要和Clojure编程语言混淆)这个词,你可能会想Lambda是否满足闭包的定义。用科学的说法来说,闭包就是一个函数的实例,且它可以无限制地访问那个函数的非本地变量。例如,闭包可以作为参数传递给另一个函数。它也可以访问和修改其作用域之外的变量。现在,Java 8的Lambda和匿名类可以做类似于闭包的事情:它们可以作为参数传递给方法,并且可以访问其作用域之外的变量。但有一个限制:它们不能修改定义Lambda的方法的局部变量的内容。这些变量必须是隐式最终的。可以认为Lambda是对值封闭,而不是对变量封闭。如前所述,这种限制存在的原因在于局部变量保存在栈上,并且隐式表示它们仅限于其所在线程。如果允许捕获可改变的局部变量,就会引发造成线程不安全的新的可能性,而这是我们不想看到的(实例变量可以,因为它们保存在堆中,而堆是在线程之间共享的)。

现在,我们来介绍你会在Java 8代码中看到的另一个功能:方法引用。可以把它们视为某些Lambda的快捷写法。

3.6 方法引用

方法引用让你可以重复使用现有的方法定义,并像Lambda一样传递它们。在一些情况下,比起使用Lambda表达式,它们似乎更易读,感觉也更自然。下面就是我们借助更新的Java 8 API(我们会在3.7节中更详细地讨论),用方法引用写的一个排序的例子:

先前:

inventory.sort((Apple a1, Apple a2)                -> a1.getWeight.compareTo(a2.getWeight));  

之后(使用方法引用和java.util.Comparator.comparing):

inventory.sort(comparing(Apple::getWeight));    ←─你的第一个方法引用  

3.6.1 管中窥豹

你为什么应该关心方法引用?方法引用可以被看作仅仅调用特定方法的Lambda的一种快捷写法。它的基本思想是,如果一个Lambda代表的只是“直接调用这个方法”,那最好还是用名称来调用它,而不是去描述如何调用它。事实上,方法引用就是让你根据已有的方法实现来创建Lambda表达式。但是,显式地指明方法的名称,你的代码的可读性会更好。它是如何工作的呢?当你需要使用方法引用时,目标引用放在分隔符::前,方法的名称放在后面。例如,Apple::getWeight就是引用了Apple类中定义的方法getWeight。请记住,不需要括号,因为你没有实际调用这个方法。方法引用就是Lambda表达式(Apple a) -> a.getWeight的快捷写法。表3-4给出了Java 8中方法引用的其他一些例子。

表3-4 Lambda及其等效方法引用的例子

Lambda

等效的方法引用

(Apple a) -> a.getWeight

Apple::getWeight

-> Thread.currentThread.dumpStack

Thread.currentThread::dumpStack

(str, i) -> str.substring(i)

String::substring

(String s) -> System.out.println(s)

System.out::println

你可以把方法引用看作针对仅仅涉及单一方法的Lambda的语法糖,因为你表达同样的事情时要写的代码更少了。

如何构建方法引用

方法引用主要有三类。

(1) 指向静态方法的方法引用(例如IntegerparseInt方法,写作Integer::parseInt)。

(2) 指向任意类型实例方法的方法引用(例如Stringlength方法,写作String::length)。

(3) 指向现有对象的实例方法的方法引用(假设你有一个局部变量expensiveTransaction用于存放Transaction类型的对象,它支持实例方法getValue,那么你就可以写expensiveTransaction::getValue)。

第二种和第三种方法引用可能乍看起来有点儿晕。类似于String::length的第二种方法引用的思想就是你在引用一个对象的方法,而这个对象本身是Lambda的一个参数。例如,Lambda表达式(String s) -> s.toUppeCase可以写作String::toUpperCase。但第三种方法引用指的是,你在Lambda中调用一个已经存在的外部对象中的方法。例如,Lambda表达式->expensiveTransaction.getValue可以写作expensiveTransaction::getValue

依照一些简单的方子,我们就可以将Lambda表达式重构为等价的方法引用,如图3-5所示。

图 3-5 为三种不同类型的Lambda表达式构建方法引用的办法

请注意,还有针对构造函数、数组构造函数和父类调用(super-call)的一些特殊形式的方法引用。让我们举一个方法引用的具体例子吧。比方说你想要对一个字符串的List排序,忽略大小写。Listsort方法需要一个Comparator作为参数。你在前面看到了,Comparator描述了一个具有(T, T) -> int签名的函数描述符。你可以利用String类中的compareToIgnoreCase方法来定义一个Lambda表达式(注意compareToIgnoreCaseString类中预先定义的)。

List<String> str = Arrays.asList("a","b","A","B");str.sort((s1, s2) -> s1.compareToIgnoreCase(s2));  

Lambda表达式的签名与Comparator的函数描述符兼容。利用前面所述的方法,这个例子可以用方法引用改写成下面的样子:

List<String> str = Arrays.asList("a","b","A","B");str.sort(String::compareToIgnoreCase);  

请注意,编译器会进行一种与Lambda表达式类似的类型检查过程,来确定对于给定的函数式接口,这个方法引用是否有效:方法引用的签名必须和上下文类型匹配。

为了检验你对方法引用的理解程度,试试测验3.6吧!

测验3.6:方法引用

下列Lambda表达式的等效方法引用是什么?

(1)

Function<String, Integer> stringToInteger =     (String s) -> Integer.parseInt(s);  

(2)

BiPredicate<List<String>, String> contains =     (list, element) -> list.contains(element);  

答案如下。

(1) 这个Lambda表达式将其参数传给了Integer的静态方法parseInt。这种方法接受一个需要解析的String,并返回一个Integer。因此,可以使用图3-5中的办法➊(Lambda表达式调用静态方法)来重写Lambda表达式,如下所示:

Function<String, Integer> stringToInteger = Integer::parseInt;  

(2) 这个Lambda使用其第一个参数,调用其contains方法。由于第一个参数是List类型的,你可以使用图3-5中的办法➋,如下所示:

BiPredicate<List<String>, String> contains = List::contains;  

这是因为,目标类型描述的函数描述符是 (List<String>,String) -> boolean,而List::contains可以被解包成这个函数描述符。

到目前为止,我们只展示了如何利用现有的方法实现和如何创建方法引用。但是你也可以对类的构造函数做类似的事情。

3.6.2 构造函数引用

对于一个现有构造函数,你可以利用它的名称和关键字new来创建它的一个引用:ClassName::new。它的功能与指向静态方法的引用类似。例如,假设有一个构造函数没有参数。它适合Supplier的签名 -> Apple。你可以这样做:

Supplier<Apple> c1 = Apple::new;    ←─构造函数引用指向默认的Apple构造函数Apple a1 = c1.get;    ←─调用Supplier的get方法将产生一个新的Apple  

这就等价于:

Supplier<Apple> c1 =  -> new Apple;    ←─利用默认构造函数创建Apple的Lambda表达式Apple a1 = c1.get;    ←─调用Supplier的get方法将产生一个新的Apple  

如果你的构造函数的签名是Apple(Integer weight),那么它就适合Function接口的签名,于是你可以这样写:

Function<Integer, Apple> c2 = Apple::new;    ←─指向Apple(Integer weight)的构造函数引用Apple a2 = c2.apply(110);    ←─调用该Function函数的apply方法,并给出要求的重量,将产生一个Apple  

这就等价于:

Function<Integer, Apple> c2 = (weight) -> new Apple(weight);用要求的重量创建一个Apple的Lambda表达式Apple a2 = c2.apply(110);调用该Function函数的apply方法,并给出要求的重量,将产生一个新的Apple对象  

在下面的代码中,一个由Integer构成的List中的每个元素都通过我们前面定义的类似的map方法传递给了Apple的构造函数,得到了一个具有不同重量苹果的List

List<Integer> weights = Arrays.asList(7, 3, 4, 10);List<Apple> apples = map(weights, Apple::new);    ←─将构造函数引用传递给map方法public static List<Apple> map(List<Integer> list,                              Function<Integer, Apple> f){    List<Apple> result = new ArrayList<>;    for(Integer e: list){        result.add(f.apply(e));    }    return result;}  

如果你有一个具有两个参数的构造函数Apple(String color, Integer weight),那么它就适合BiFunction接口的签名,于是你可以这样写:

BiFunction<String, Integer, Apple> c3 = Apple::new;    ←─指向Apple(Stringcolor,Integer weight)的构造函数引用Apple c3 = c3.apply("green", 110);    ←─调用该BiFunction函数的apply方法,并给出要求的颜色和重量,将产生一个新的Apple对象  

这就等价于:

BiFunction<String, Integer, Apple> c3 =    (color, weight) -> new Apple(color, weight);    ←─用要求的颜色和重量创建一个Apple的Lambda表达式Apple c3 = c3.apply("green", 110);    ←─调用该BiFunction函数的apply方法,并给出要求的颜色和重量,将产生一个新的Apple对象  

不将构造函数实例化却能够引用它,这个功能有一些有趣的应用。例如,你可以使用Map来将构造函数映射到字符串值。你可以创建一个giveMeFruit方法,给它一个String和一个Integer,它就可以创建出不同重量的各种水果:

static Map<String, Function<Integer, Fruit>> map = new HashMap<>;static {    map.put("apple", Apple::new);    map.put("orange", Orange::new);    // etc...}public static Fruit giveMeFruit(String fruit, Integer weight){    return map.get(fruit.toLowerCase)    ←─你用map 得到了一个Function<Integer,Fruit>              .apply(weight);    ←─用Integer类型的weight参数调用Function的apply方法将提供所要求的Fruit}  

为了检验你对方法和构造函数引用的理解程度,试试测验3.7吧!

测验3.7:构造函数引用