第 3 章 Lambda表达式

第 3 章 Lambda表达式

本章内容

  • Lambda管中窥豹
  • 在哪里以及如何使用Lambda
  • 环绕执行模式
  • 函数式接口,类型推断
  • 方法引用
  • Lambda复合

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

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

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

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

3.1 Lambda管中窥豹

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

  • 匿名——说它是匿名的,因为它不像普通的方法那样有一个明确的名称:写得少而想得多!
  • 函数——说它是一种函数,是因为Lambda函数不像方法那样属于某个特定的类。但和方法一样,Lambda有参数列表、函数主体、返回类型,还可能有可以抛出的异常列表。
  • 传递——Lambda表达式可以作为参数传递给方法或存储在变量中。
  • 简洁——你无须像匿名类那样写很多模板代码。

你是不是很好奇Lambda这个词是从哪儿来的?其实它起源于学术界开发出的一套用来描述计算的λ演算法

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

先前:

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所示。

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

  • 参数列表——这里它采用了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等语言中的类似功能广受欢迎。JavaScript也有类似的语法。Lambda的基本语法是(被称为表达式–风格的Lambda)

(parameters) -> expression

或(请注意语句的花括号,这种Lambda经常被叫作块–风格的Lambda)

(parameters) -> { statements; }

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

测验3.1:Lambda语法

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

(1) () -> {}

(2) () -> "Raoul"

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

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

(5) (String s) -> {"Iron Man";}

答案:只有(4)和(5)是无效的Lambda,其余都是有效的。详细解释如下。

(1) 这个Lambda没有参数,并返回void。它类似于主体为空的方法:public void run() {}。一个有趣的事实:这种Lambda也经常被叫作“汉堡型Lambda”。如果只从一边看,它的形状就像是两块圆面包组成的汉堡。

(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 "Iron Man";}

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

表 3-1 Lambda示例

使用案例

Lambda示例

布尔表达式

(List<pString> 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() throws Exception;
}
public interface PrivilegedAction<T> {       ←---- java.security.PrivilegedAction
    T run();
}

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

为了检查你的理解程度,测验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");   ←---- 使用Lambda
Runnable 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及空方法调用

虽然下面这种Lambda表达式调用看起来很奇怪,但是合法的:

process(() -> System.out.println("This is awesome"));

System.out.println返回void,所以很明显这不是一个表达式!为什么不像下面这样用花括号环绕方法体呢?

process(() -> { System.out.println("This is awesome"); });

结果表明,方法调用的返回值为空时,Java语言规范有一条特殊的规定。这种情况下,你不需要使用括号环绕返回值为空的单行方法调用。

你可能会想:“为什么在只需要函数式接口的时候才可以传递Lambda呢?”语言的设计者也考虑过其他办法,例如给Java添加函数类型(有点儿像我们介绍描述Lambda表达式签名时的特殊表示法,第20章和第21章会继续讨论这个问题)。但是他们选择了现在这种方式,因为这种方式很自然,并且能避免让语言变得更复杂。此外,大多数Java程序员都已经熟悉了带有一个抽象方法的接口(譬如进行事件处理时)。然而,最重要的原因在于Java 8之前函数式接口就已经得到了广泛应用。这意味着,采用这种方式,遗留代码迁移到Lambda表达式的迁移路径会比较顺畅。实际上,你已经使用了函数式接口,像ComparatorRunnable,甚至你自己的接口,如果只定义了一个抽象方法,都算是函数式接口。你可以使用Lambda表达式替换他们,而无须修改你的API。试试看测验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)是有效的。

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

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

第(3)个例子无效,因为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 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吧。

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

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

public String processFile(BufferedReaderProcessor p) throws IOException {
    ...
}

3.3.3 第3步:执行一个行为

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

public 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节中见到的ComparatorRunnableCallable

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

@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
}
public <T> List<T> filter(List<T> list, Predicate<T> p) {
    List<T> results = new ArrayList<>();
    for(T t: list) {
        if(p.test(t)) {
            results.add(t);
        }
    }
    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

@FunctionalInterface
public interface Consumer<T>{
    void accept(T t);
}
public <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

@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);
}
public <T, R> List<R> map(List<T> list, Function<T, R> f) {
    List<R> result = new ArrayList<>();
    for(T t: list) {
        result.add(f.apply(t));
    }
    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等语言只有引用类型。第20章会再次探讨这个问题。

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 != 0;
oddNumbers.test(1000);       ←---- false(装箱)

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

表3-2总结了Java API中最常用的函数式接口,它们的函数描述符及其基本类型特化。请记住这个集合只是一个启始集。如果有需要,你完全可以设计一个自己的基本类型特化(测验3.7中的TriFunction就是出于这个目的而设计的)。此外,创建你自己的接口,让接口的名字反映其在领域中的功能,还能帮助程序员理解代码逻辑,同时也便于程序的维护。请记住,标记符(T, U) -> R展示的是该怎样理解一个函数描述符。箭头左侧代表了参数的类型,右侧代表了返回结果的类型。这儿它代表的是一个函数,具有两个参数,分别为泛型TU,返回类型为R

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

函数式接口

Predicate<T>

Consumer<T>

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>,
DoubleToIntFunction,
DoubleToLongFunction,
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<T, U>

(T, U) -> 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

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

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

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

使用案例

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> or ToIntFunction<String>

合并两个值

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

IntBinaryOperator

比较两个对象

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

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

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

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

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

@FunctionalInterface
public 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 apple) -> apple.getWeight() > 150);

类型检查过程分解如下。

  • 第一,你要找出filter方法的声明。
  • 第二,要求它是Predicate<Apple>(目标类型)对象的第二个正式参数。
  • 第三,Predicate<Apple>是一个函数式接口,定义了一个叫作test的抽象方法。
  • 第四,test方法描述了一个函数描述符,它可以接受一个Apple,并返回一个boolean
  • 第五,filter的任何实际参数都必须匹配这个要求。

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

这段代码是有效的,因为我们所传递的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返回了一个boolean
Predicate<String> p = (String s) -> list.add(s);
// Consumer返回了一个void
Consumer<String> b = (String 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表达式转换成Runnable,显式地生成一个目标类型,以这种方式来修复这个问题:

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

处理方法重载时,如果两个不同的函数式接口却有着同样的函数描述符,使用这个技巧有立竿见影的效果。到底该选择使用哪一个方法签名呢?为了消除这种显式的二义性,你可以对Lamda进行强制类型转换。

譬如,下面这段代码中,方法调用execute( () -> {} )使用了execute方法,不过它存在着二义性,因为RunnableAction接口中都提供了同样的函数描述符:

public void execute(Runnable runnable) {
    runnable.run();
}
public void execute(Action<T> action) {
    action.act();
}
@FunctionalInterface
interface Action {
    void act();
}

然而,通过强制类型转换表达式,这种显式的二义性被消除了:

execute((Action) () -> { });

你已经了解如何利用目标类型来判断某个Lambda是否适用于某个特定的上下文。其实,它还可以用来做一些别的事:推断Lambda参数的类型。

3.5.3 类型推断

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

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

List<Apple> greenApples =
        filter(inventory, apple -> GREEN.equals(apple.getColor()));       ←---- 参apple没有显式类型

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。请记住,getWeight后面不需要括号,因为你没有实际调用这个方法,只是引用了它的名称。方法引用就是Lambda表达式(Apple apple) -> apple.getWeight()的快捷写法。表3-4给出了Java 8中方法引用的其他一些例子。

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

Lambda

等效的方法引用

(Apple apple) -> apple.getWeight()

Apple::getWeight

() -> Thread.currentThread().dumpStack()

Thread.currentThread()::dumpStack

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

String::substring

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

System.out::println this::isValidName

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

如何构建方法引用

方法引用主要有三类。

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

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

(3) 指向现存对象或表达式实例方法的方法引用(假设你有一个局部变量expensive Transaction保存了Transaction类型的对象,它提供了实例方法getValue,那你就可以这么写expensiveTransaction::getValue)。

第二种和第三种方法引用可能乍看起来有点儿晕。第二种方法引用的思想是你在引用一个对象的方法,譬如String::length,而这个对象是Lambda表达式的一个参数。举个例子,Lambda表达式(String s) -> s.toUppeCase()可以重写成String::toUpperCase。而第三种方法引用主要用在你需要在Lambda中调用一个现存外部对象的方法时。例如,Lambda表达式()->expensiveTransaction.getValue()可以重写为expensiveTransaction::getValue。第三种方法引用在你需要传递一个私有辅助方法时特别有用。譬如,你定义了一个辅助方法isValidName

private boolean isValidName(String string) {
    return Character.isUpperCase(string.charAt(0));
}

你可以借助方法引用,在Predicate<String>的上下文中传递该方法:

filter(words, this::isValidName)

为了帮助你消化这些新知识,我们准备了一份将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) ToIntFunction<String> stringToInt = (String s) -> Integer.parseInt(s);

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

(3) Predicate<String> startsWithNumber = (String string) -> this .startsWithNumber(string);

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

ToIntFunction<String> stringToInt = Integer::parseInt;

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

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

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

(3) 这种“表达式–风格”的Lambda会调用一个私有方法。你可以使用图3-5中的办法❸,如下所示:

Predicate<String> startsWithNumber = this::startsWithNumber

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

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 List<Apple> map(List<Integer> list, Function<Integer, Apple> f) {
    List<Apple> result = new ArrayList<>();
    for(Integer i: list) {
        result.add(f.apply(i));
    }
    return result;
}

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

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

这就等价于:

BiFunction<String, Integer, Apple> c3 =       ←---- 用要求的颜色和重量创建一个Apple的Lambda表达式
    (color, weight) -> new Apple(color, weight);
Apple a3 = 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:构造函数引用

你已经看到了如何将有零个、一个、两个参数的构造函数转变为构造函数引用。那要怎么样才能对具有三个参数的构造函数,比如RGB(int, int, int),使用构造函数引用呢?

答案:你看,构造函数引用的语法是ClassName::new,那么在这个例子里面就是RGB::new。但是你需要与构造函数引用的签名匹配的函数式接口。由于语言本身并没有提供这样的函数式接口,因此你可以自己创建一个:

public interface TriFunction<T, U, V, R> {
    R apply(T t, U u, V v);
}

现在你可以像下面这样使用构造函数引用了:

TriFunction<Integer, Integer, Integer, RGB> colorFactory = RGB::new;

我们讲了好多新内容:Lambda、函数式接口和方法引用。下一节会把这一切付诸实践!

3.7 Lambda和方法引用实战

为了给这一章还有我们讨论的所有关于Lambda的内容收个尾,我们需要继续研究开始的那个问题——用不同的排序策略给一个Apple列表排序,并需要展示如何把一个原始粗暴的解决方案转变得更为简明。这会用到书中迄今讲到的所有概念和功能:行为参数化、匿名类、Lambda表达式和方法引用。我们想要实现的最终解决方案是这样的:

inventory.sort(comparing(Apple::getWeight));

3.7.1 第1步:传递代码

你很幸运,Java 8 API已经为你提供了一个List可用的sort方法,你不用自己去实现它。那么最困难的部分已经搞定了!但是,如何把排序策略传递给sort方法呢?你看,sort方法的签名是这样的:

void sort(Comparator<? super E> c)

它需要一个Comparator对象来比较两个Apple!这就是在Java中传递策略的方式:它们必须包裹在一个对象里。我们说sort行为参数化了:传递给它的排序策略不同,其行为也会不同。

你的第一个解决方案看上去是这样的:

public class AppleComparator implements Comparator<Apple> {
        public int compare(Apple a1, Apple a2){
                return a1.getWeight().compareTo(a2.getWeight());
        }
}
inventory.sort(new AppleComparator());

3.7.2 第2步:使用匿名类

你在前面看到了,你可以使用匿名类来改进解决方案,而不是实现一个Comparator却只实例化一次:

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

3.7.3 第3步:使用Lambda表达式

但你的解决方案仍然挺啰唆的。Java 8引入了Lambda表达式,它提供了一种轻量级语法来实现相同的目标:传递代码。你看到了,在需要函数式接口的地方可以使用Lambda表达式。回顾一下:函数式接口就是仅仅定义一个抽象方法的接口。抽象方法的签名(称为函数描述符)描述了Lambda表达式的签名。在这个例子里,Comparator代表了函数描述符(T, T) -> int。因为你用的是苹果,所以它具体代表的就是(Apple, Apple) -> int。改进后的新解决方案看上去就是这样的了:

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

前面解释过了,Java编译器可以根据Lambda出现的上下文来推断Lambda表达式参数的类型。那么你的解决方案就可以重写成这样:

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

你的代码还能变得更易读一点吗?Comparator具有一个叫作comparing的静态辅助方法,它可以接受一个Function来提取Comparable键值,并生成一个Comparator对象(第13章会解释为什么接口可以有静态方法)。它可以像下面这样用(注意你现在传递的Lambda只有一个参数,Lambda说明了如何从Apple中提取需要比较的键值):

Comparator<Apple> c = Comparator.comparing((Apple a) -> a.getWeight());

现在你可以把代码再改得紧凑一点了:

import static java.util.Comparator.comparing;
inventory.sort(comparing(apple -> apple.getWeight()));

3.7.4 第4步:使用方法引用

前面解释过,方法引用就是替代那些转发参数的Lambda表达式的语法糖。你可以用方法引用让你的代码更简洁(假设你静态导入了java.util.Comparator.comparing):

inventory.sort(comparing(Apple::getWeight));

恭喜你,这就是你的最终解决方案!这比Java 8之前的代码好在哪儿呢?它比较短;它的意思也很明显,并且代码读起来和问题描述差不多:“对库存进行排序,比较苹果的重量。”

3.8 复合Lambda表达式的有用方法

Java 8的好几个函数式接口都有为方便而设计的方法。具体而言,许多函数式接口,比如用于传递Lambda表达式的ComparatorFunctionPredicate都提供了允许你进行复合的方法。这是什么意思呢?在实践中,这意味着你可以把多个简单的Lambda复合成复杂的表达式。比如,你可以让两个谓词之间做一个or操作,组合成一个更大的谓词。而且,你还可以让一个函数的结果成为另一个函数的输入。你可能会想,函数式接口中怎么可能有更多的方法呢?(毕竟,这违背了函数式接口的定义啊!)窍门在于,我们即将介绍的方法都是默认方法,也就是说它们不是抽象方法。第13章会详谈。现在只需相信我们,等想要进一步了解默认方法以及你可以用它做什么时,再去看看第13章。

3.8.1 比较器复合

我们前面看到,你可以使用静态方法Comparator.comparing,根据提取用于比较的键值的Function来返回一个Comparator,如下所示:

Comparator<Apple> c = Comparator.comparing(Apple::getWeight);
  1. 逆序

    如果你想要对苹果按重量递减排序怎么办?用不着去建立另一个Comparator的实例。接口有一个默认方法reversed可以使给定的比较器逆序。因此仍然用开始的那个Comparator,只要修改一下前一个例子就可以对苹果按重量递减排序:

    inventory.sort(comparing(Apple::getWeight).reversed());   ←---- 按重量递减排序
  2. 比较器链

    上面说得都很好,但如果发现有两个苹果一样重怎么办?哪个苹果应该排在前面呢?你可能需要再提供一个Comparator来进一步定义这个比较。比如,在按重量比较两个苹果之后,你可能想要按原产国排序。thenComparing方法就是做这个用的。它接受一个函数作为参数(就像comparing方法一样),如果两个对象用第一个Comparator比较之后是一样的,就提供第二个Comparator。你又可以优雅地解决这个问题了:

    inventory.sort(comparing(Apple::getWeight)
             .reversed()   ←---- 按重量递减排序
             .thenComparing(Apple::getCountry));   ←---- 两个苹果一样重时,进一步按国家排序

3.8.2 谓词复合

谓词接口包括三个方法:negateandor,让你可以重用已有的Predicate来创建更复杂的谓词。比如,你可以使用negate方法来返回一个Predicate的非,比如苹果不是红的:

Predicate<Apple> notRedApple = redApple.negate();   ←---- 产生现有Predicate对象redApple的非

你可能想要把两个Lambda用and方法组合起来,比如一个苹果既是红色又比较重:

Predicate<Apple> redAndHeavyApple =
    redApple.and(apple -> apple.getWeight() > 150);   ←---- 链接两个谓词来生成另一个Predicate对象

你可以进一步组合谓词,表达要么是重(150克以上)的红苹果,要么是绿苹果:

Predicate<Apple> redAndHeavyAppleOrGreen =
    redApple.and(apple -> apple.getWeight() > 150)
            .or(apple -> GREEN.equals(a.getColor()));   ←---- 链接三个谓词来

这一点为什么很好呢?从简单Lambda表达式出发,你可以构建更复杂的表达式,但读起来仍然和问题的陈述差不多!请注意,andor方法是按照在表达式链中的位置,从左向右确定优先级的。因此,a.or(b).and(c)可以看作(a || b) && c。同样,a.and(b).or(c) 可以看作(a && b) || c

3.8.3 函数复合

最后,你还可以把Function接口所代表的Lambda表达式复合起来。Function接口为此配了andThencompose两个默认方法,它们都会返回Function的一个实例。

andThen方法会返回一个函数,它先对输入应用一个给定函数,再对输出应用另一个函数。比如,假设有一个函数f给数字加1 (x -> x + 1),另一个函数g给数字乘2,那么你可以将它们组合成一个函数h,先给数字加1,再给结果乘2:

Function<Integer, Integer> f = x -> x + 1;
Function<Integer, Integer> g = x -> x * 2;
Function<Integer, Integer> h = f.andThen(g);       ←---- 数学上会写作g(f(x))或(g o f)(x)
int result = h.apply(1);       ←---- 这将返回4

你也可以类似地使用compose方法,先把给定的函数用作compose的参数里面给的那个函数,然后再把函数本身用于结果。比如在上一个例子里用compose的话,它将意味着f(g(x))andThen则意味着g(f(x))

Function<Integer, Integer> f = x -> x + 1;
Function<Integer, Integer> g = x -> x * 2;
Function<Integer, Integer> h = f.compose(g);       ←---- 数学上会写作f(g(x))或(f o g)(x)
int result = h.apply(1);       ←---- 这将返回3

图3-6说明了andThencompose之间的区别。

图 3-6 使用andThencompose

这一切听起来有点太抽象了。那么在实际中这有什么用呢?比方说你有一系列工具方法,对用String表示的一封信做文本转换:

public class Letter{
    public static String addHeader(String text){
        return "From Raoul, Mario and Alan: " + text;
    }
    public static String addFooter(String text){
        return text + " Kind regards";
    }
    public static String checkSpelling(String text){
        return text.replaceAll("labda", "lambda");
    }
}

现在你可以通过复合这些工具方法来创建各种转型流水线了,比如创建一个流水线:先加上抬头,然后进行拼写检查,最后加上一个落款,如图3-7所示。

Function<String, String> addHeader = Letter::addHeader;
Function<String, String> transformationPipeline
    = addHeader.andThen(Letter::checkSpelling)
               .andThen(Letter::addFooter);

图 3-7 使用andThen的转换流水线

第二个流水线可能只加抬头、落款,而不做拼写检查:

Function<String, String> addHeader = Letter::addHeader;
Function<String, String> transformationPipeline
  = addHeader.andThen(Letter::addFooter);

3.9 数学中的类似思想

如果你上学的时候对数学很擅长,那这一节就从另一个角度来谈谈Lambda表达式和函数传递的思想。你可以跳过它,书中没有任何其他内容依赖这一节,不过从另一个角度看看也挺好的。

3.9.1 积分

假设你有一个(数学,不是Java)函数f,比如说定义是

f(x)=x+10

那么,(工科学校里)经常问的一个问题就是,求画在纸上之后函数下方的面积(把x轴作为基准)。比如对于图3-8所示的面积你会写

\int^7_3 (x){\rm d}x\int^7_3 (x+10){\rm d}x

图 3-8 函数f(x)=x+10x从3到7)下方的面积

在这个例子里,函数f是一条直线,因此你很容易通过梯形方法(画几个三角形和矩形)来算出面积:

1/2 × ((3 + 10) + (7 + 10)) × (7 – 3) = 60

那么这在Java里面如何表达呢?你的第一个问题是把积分号或{\rm d}y/{\rm d}x之类的换成熟悉的编程语言符号。

确实,根据第一条原则你需要一个方法,比如说叫integrate,它接受三个参数:一个是f,还有上下限(这里是3.0和7.0)。于是写在Java里就是下面这个样子,函数f是作为参数被传递进去的:

integrate(f, 3, 7)

请注意,你不能简单地写:

integrate(x + 10, 3, 7)

原因有两个。第一,x的作用域不清楚;第二,这将把x + 10的值而不是函数f传给积分。

事实上,数学上{\rm d}x的秘密作用就是说“以x为自变量、结果是x+10的那个函数。”

3.9.2 与Java 8的Lambda联系起来

前面说过,Java 8的表示法(double x) -> x + 10(一个Lambda表达式)恰恰就是为此设计的,因此你可以写:

integrate((double x) -> x + 10, 3, 7)

或者

integrate((double x) -> f(x), 3, 7)

或者,用前面说的方法引用,只要写:

integrate(C::f, 3, 7)

这里C是包含静态方法f的一个类。理念就是把f背后的代码传给integrate方法。

现在你可能在想如何写integrate本身了。我们还假设f是一个线性函数(直线)。你可能会写成类似数学的形式:

public double integrate((double -> double) f, double a, double b) {       ←---- 错误的Java代码!(函数的写法不能像数学里那样。)
    return (f(a) + f(b)) * (b - a) / 2.0
}

不过,由于Lambda表达式只能用于接受函数式接口的地方(这里就是DoubleFunction4),所以你必须得写成这个样子:

4使用DoubleFunctionFunction更高效,因为它避免了结果的装箱操作。

public double integrate(DoubleFunction<Double> f, double a, double b) {
    return (f.apply(a) + f.apply(b)) * (b - a) / 2.0;
}

或者用DoubleUnaryOperator,这样也可以避免对结果进行装箱:

public double integrate(DoubleUnaryOperator f, double a, double b) {
    return (f.applyAsDouble(a) + f.applyAsDouble(b)) * (b - a) / 2.0;
}

顺便提一句,有点可惜的是你必须写f.apply(a),而不是像数学里面写f(a),但Java无法摆脱“一切都是对象”的思想——它不能让函数完全独立!

3.10 小结

以下是本章中的关键概念。

  • Lambda表达式可以理解为一种匿名函数:它没有名称,但有参数列表、函数主体、返回类型,可能还有一个可以抛出的异常的列表。
  • Lambda表达式让你可以简洁地传递代码。
  • 函数式接口就是仅仅声明了一个抽象方法的接口。
  • 只有在接受函数式接口的地方才可以使用Lambda表达式。
  • Lambda表达式允许你直接内联,为函数式接口的抽象方法提供实现,并且将整个表达式作为函数式接口的一个实例。
  • Java 8自带一些常用的函数式接口,放在java.util.function包里,包括Predicate <T>Function<T, R>Supplier<T>Consumer<T>BinaryOperator<T>,如表3-2所述。
  • 为了避免装箱操作,对Predicate<T>Function<T, R>等通用函数式接口的基本类型特化:IntPredicateIntToLongFunction等。
  • 环绕执行模式(即在方法所必需的代码中间,你需要执行点儿什么操作,比如资源分配和清理)可以配合Lambda提高灵活性和可重用性。
  • Lambda表达式所需要代表的类型称为目标类型。
  • 方法引用让你重复使用现有的方法实现并直接传递它们。
  • ComparatorPredicateFunction等函数式接口都有几个可以用来结合Lambda表达式的默认方法。

目录

  • 版权声明
  • 对本书上一版的赞誉
  • 前言
  • 致谢
  • 关于本书
  • 关于封面图片
  • 第一部分 基础知识
  • 第 1 章 Java 8、9、10以及11的变化
  • 第 2 章 通过行为参数化传递代码
  • 第 3 章 Lambda表达式
  • 第二部分 使用流进行函数式数据处理
  • 第 4 章 引入流
  • 第 5 章 使用流
  • 第 6 章 用流收集数据
  • 第 7 章 并行数据处理与性能
  • 第三部分 使用流和Lambda进行高效编程
  • 第 8 章 Collection API的增强功能
  • 第 9 章 重构、测试和调试
  • 第 10 章 基于Lambda的领域特定语言
  • 第四部分 无所不在的Java
  • 第 11 章 用Optional取代null
  • 第 12 章 新的日期和时间API
  • 第 13 章 默认方法
  • 第 14 章 Java模块系统
  • 第五部分 提升Java的并发性
  • 第 15 章 CompletableFuture及反应式编程背后的概念
  • 第 16 章 CompletableFuture:组合式异步编程
  • 第 17 章 反应式编程
  • 第六部分 函数式编程以及Java未来的演进
  • 第 18 章 函数式的思考
  • 第 19 章 函数式编程的技巧
  • 第 20 章 面向对象和函数式编程的混合:Java和Scala的比较
  • 第 21 章 结论以及Java的未来
  • 附录 A 其他语言特性的更新
  • 附录 B 其他类库的更新
  • 附录 C 如何以并发方式在同一个流上执行多种操作
  • 附录 D Lambda表达式和JVM字节码