概述
利用行为参数化来传递代码有助于应对不断变化的需求。它允许你定义一个代码块来表示一个行为,然后传递它。你可以决定在某一个事件发生时(例如单机一个按钮)或在算法中的某个特定时刻运行该代码。一般来说,利用这个概念,你就可以编写更为灵活且可重复使用的代码了。
但你也看到,使用匿名类来表示不同的行为并不令人满意:代码十分啰嗦,这会影响程序员在实践中使用行为参数化的积极性。在本章中,我们会教给你Java8中解决这个问题的新工具一一Lambda表达式。它可以让你很简洁地表示一个行为或传递代码。现在你可以把Lambda表达式看做匿名功能,它基本上就是没有声明名称的方法,但和匿名类一样,它也可以作为参数传递给一个方法。
我们会展示如何构建Lambda,它的使用场合,以及如何利用它使代码更简洁。我们还会接收一些新的东西,如类型推断和Java8 API中重要的新接口。最后,我们将介绍方法引用(method reference),这是一个常常和Lambda表达式联用的有用的新功能。
本章的行文思想就是教你如何一步一步地写出更简洁、更灵活的代码。
Lambda管中窥豹
可以把Lambda表达式理解为简洁地表示可传递的匿名函数的一种方式:它没有名称,但它有参数列表、函数主体、返回类型,可能还有一个可以抛出的异常参数列表。这个定义够大了,让我们慢慢道来。
- 匿名一一我们说匿名,是因为它不像普通的方法那样有一个明确的名称:写得少而想的多。
- 函数一一我们说它是函数,是因为Lambda函数不像方法那样属于某个特定的类。但和方法一样,Lambda有参数列表、函数主体、返回类型,还可能有可以抛出的异常列表。
- 传递一一Lambda表达式可以作为参数传递给方法或存储在变量中。
- 简洁一一无需像匿名类那样写很多模板代码。
Lambda的基本语法是:
(parameters) -> expression
或(请注意语句的花括号)
(parameters) -> { statements; }
下面例举两个错误的写法:
(Integer i) -> return “Alan” + 1;
(String s) -> { “IronMan”; }
- return是一个控制流语句。要使此Lambda有效,需要使花括号,如下所示:(Integer i) -> { return “Alam” + 1; }。
- “Iron Man” 是一个表达式,不是一个语句。要使此Lambda有效,你可以去除花括号和分号,如下所示:(String s) -> “Iron Man”。或者如果你喜欢,可以使用显示返回语句,如下所示:(String s) -> { return “IronMan”; }
在哪里已经如何使用Lambda
函数接口
为了参数化filter方法的行为而创建的Predicate接口,它就是一个函数式接口!为什么呢?因为Predicate仅仅定义了一个抽象方法:
public interface Predicate<T> {
boolean test(T t);
}
一言以蔽之,函数式接口就是只定义一个抽象方法的接口。
注意:接口现在还可以拥有默认方法(即在类没有对方法进行实现时,其主体为方法提供默认实现的方法)。哪怕有很多默认方式,只要接口只定义了一个抽象方法,它就仍然是一个函数式接口。
用函数式接口可以干什么呢?Lambda表达式允许你直接以内联的形式为函数式接口的抽象方法提供实现,并把整个表达式作为函数式接口的实例(具体说来,是函数式接口一个具体实现的实例)。你用匿名内部类也可以完成同样的事情,只不过比较笨拙:需要提供一个实现,然后再直接内联将它实例化。
@FunctionalInterface
如果你去看看新的Java API,会发现函数式接口带有@FunctionalInterface的标注。这个标注用于表示该接口会设计成一个函数式接口。如果你用@FunctionalInterface定义一个接口,而它却不是函数式接口的话,编译器将返回一个提示原因的错误。例如,错误消息可能是“Multiple non-overriding abstract methods found in interface Foo”,表面存在多个抽象方法。请注意,@FunctionalInterface不是必需的,但对于为此设计的接口而言,使用它是比较好的做法。它就像是@Override标注表示方法被重写了。
把Lambda付诸实现:环绕执行模式
让我们通过一个例子,看看在实践中如何利用Lambda和行为参数化来让代码更为灵活,更为简洁。资源处理(例如处理文件或数据库)时一个常见的模式就是打开一个资源,做一些处理,然后关闭资源。这个设置和清理阶段总是很类似,并且会围绕着执行处理的那些重要代码。这就是所谓的环绕执行(execute around)模式。
public static String processFile() throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
return br.readLine();
}
}
第1步:记得行为参数化
现在这段diam是有局限的。你只能读取文件的第一行。如果你想要返回头两行,甚至是返回使用最频繁的词,该怎么办呢?在理想的情况下,你要重用执行设置和清理的代码,并告诉processFile方法对文件执行不同的操作。这听起来是不是很耳熟?是的,你需要把processFile的行为参数化。你需要一种方法把行为传递给processFile,以便它可以利用BufferedReader执行不同的行为。
传递行为正是Lambda的拿手好戏。那要是想一次读两行,这个新的processFile方法看起来又该是什么样呢?基本上,你需要一个接收BufferedReader并返回String的Lambda。例如,下面就是从BufferedReader中打印两行的写法:
String result = processFile((BufferedReader brd) -> br.readLine() + br.readLine());
第2步:使用函数式接口来传递行为
我们前面解释过了,Lambda仅可用于上下文是函数式接口的情况。你需要创建一个能匹配BufferedReader -> String,还可以抛出IOException异常的接口。让我们把这一接口叫做BufferedReaderProcessor吧。
@FunctionalInterface
public interface BufferedReaderProcessor {
String process(BufferedReader b) throws IOException;
}
现在你就可以把这个接口作为新的processFile方法的参数了:
public static String processFile(BufferedReaderProcessor p) throws IOException {
...
}
第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);
}
}
第4步:传递Lambda
现在你就可以通过传递不同的Lambda重用processFile方法,并以不同的方式处理文件了。
处理一行:
String oneLine = processFile((BufferedReader br) -> br.readLine());
处理两行:
String twoLine = processFile((BufferedReader br) -> br.readLine() + br.readLine());
我们已经展示了如何利用函数式接口来传递Lambda,但你还是得定义你自己的接口。在下一节中,我们会探讨Java8中加入的新接口,你可以重用它来传递多个不同的Lambda。
使用函数式接口
Predicate
java.util.function.Predicate接口定义了一个名叫test的抽象方法,它接受泛型T对象,并返回一个boolean。这恰恰和你先前创建的一样,现在就可以直接使用了。在你需要表示一个涉及类型T的布尔表达式时,就可以使用这个接口。比如,你可以定义一个接受String对象的Lambda表达式,如下所示:
@FunctionalInterface
public 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) {
results.add(s);
}
return results;
}
Predicate<String> nonEmptyStringPredicate = (String s) -> !s.isEmpty();
List<String> nonEmpty = filter(listOfString, nonEmptyStringPredicate);
Consumer
Function
Java API中提供的最常用的函数式接口及其函数描述符。请记得这只是一个起点。如果有需要,你可以自己设计一个。请记住,
(T,U)-> R 的表达式展示了应当如何思考一个函数描述符。这里它代表一个函数,具有两个参数,分别为泛型T和U,返回类型为R。
类型检测、类型推断以及限制
当我们第一次提到Lambda表达式时,说他可以为函数式接口的一个实例。然而,Lambda表达式本身并不包含它在实现哪个函数接口的信息。为了全面了解Lambda表达式,你应该知道Lambda的实际类型是什么?
类型检查
Lambda的类型是从使用Lambda的上下文推断出来的。上下文(比如,接受它传递的方法的参数,或者接受它的值的局部变量)中的Lambda表达式需要的类型称为目标类型。让我们通过一个例子,看看当你使用Lambda表达式时背后发生了什么。下图概述了如下代码的类型检查过程。
List<Apple> heavierThan150 = filter(inventory, (Apple a) -> a.getWeight() > 150);
Lambda表达式的类型检查过程
类型检查过程可以分解为如下所示。
- 首先,你要找出filter方法的声明。
- 第二,要求它是Predicate(目标类型)对象的第二个正式参数。
- 第三,Predicate是一个函数式接口,定义了一个叫作test的抽象方法。
- 第四,test方法描述了一个函数描述符,它可以接受一个Apple,并返回一个boolean。
- 最后,filter的任何实际参数都必须匹配这个要求。
这段代码是有效的,因为我们所传递的Lambda表达式也同样接受Apple为参数,并返回一个boolean。请注意,如果Lambda表达式抛出一个异常,那么抽象方法所声明的throws语句也必须与之匹配。
同样的Lambda,不同的函数式接口
有了目标类型的概念,同一个Lambda表达式就可以与不同的函数式接口联系起来,只要他们的抽象方法签名能够兼容。比如,前面提到的Callable和PrivilegedAction,这两个接口都代表着什么也不接受且返回一个泛型T的函数。因此,下面两个赋值是有效的:
Callable<Integer> c = () -> 42;
PrivilegedAction<Integer> p = () -> 42;
这里,第一个赋值的目标类型是Callable,第二个赋值的目标类型是PrivilegedAction。
菱形运算符
那些熟悉Java的演变的人会记得,Java7中已经引入了菱形运算符(<>),利用泛型推断从上下文推断类型的思想(这一思想甚至可以追溯到更早的泛型方法)。一个类实例表达式可以出现在两个或更多不同的上下文中,并会向下面这一推断出适当的类型参数:
List<String> listOfString = new ArrayList<>();
List<Integer> listofIntegers = new ArrayList<>();
到现在为止,你应该能够很好的理解在什么时候以及在哪里可以使用Lambda表达式了。它们可以从赋值的上下文,方法调用的上下文(参数和返回值),以及类型转换的上下文中获得目标类型。
你已经见过如何利用目标类型来检查一个Lambda是否可以用于某个特定的上下文。其实,它也可以用来做一些略有不同的事:推断Lambda参数的类型。
类型推断
你还可以进一步简化你的代码。Java编译器会从上下文(目标类型)推断出用什么函数式接口来配合Lambda表达式,这意味着它也可以推断出适合Lambda的签名,因为函数描述符可以通过目标类型来得到。这样做的好处在于,编译器可以了解Lambda表达式的参数类型,这样就可以在Lambda语法中省去标注参数类型。
请注意,有时候显式写出类型更易读,有时候去掉它们更易读。没什么法则说哪种更好;对于如何让代码更易读,程序员必须做出自己的选择。
使用局部变量
我们迄今为止所介绍的所有Lambda表达式都只用到了其主体里面的参数。但Lambda表达式也允许使用自由变量(不是参数,而是在外层作用域中定义的变量),就像匿名类一样。它们被称做捕获Lambda。例如,下面的Lambda捕获了portNumber变量:
int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber);
尽管如此,还有一点点小麻烦:关于能对这些变量做什么有一些限制。Lambda可以没有限制的捕获(也就是在其主体中引用)实例变量和静态变量。但局部变量必须显示声明为final,或事实上是final。换句话说,Lambda表达式只能捕获指派给他们的局部变量一次。(注:捕获实例变量可以被看做捕获最终局部变量this。)例如,下面的代码无法编译,因为portNumber变量被赋值两次:
// 错误:Lambda表达式引用的局部变量必须是最终的(final)或事实上最终的
int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber);
portNumber = 31337;
对局部变量的限制
你可能会问自己,为什么局部变量有这些限制。第一,实例变量和局部变量背后的实现有一个关键不同。实例变量都存储在堆中,而局部变量则保存在栈上。如果Lambda可以直接访问局部变量,而且Lambda是在另一个线程中使用的,则使用Lambda的线程,可能会在分配该变量的线程将这个变量回收之后,去访问该变量。因此,Java在访问自由局部变量时,实际上是在访问它的副本,而不是访问原始变量。如果局部变量仅仅赋值一次那就没有什么区别了一一因此就有了这个限制。
第二,这一限制不鼓励你使用改变外部变量的典型命令式模式(这种模式会阻碍很容易做到的并行处理)。
闭包
你可能已经听说过闭包(closure,可以通过它访问包里面的变量)这个词,你可能会想Lambda是否满足闭包的定义。用科学的说法来说,闭包就是一个函数的实例,且它可以无限制的访问那个函数的非本地变量。例如,闭包可以作为参数传递给另一函数。它也可以访问和修改其作用域之外的变量。现在,Java8的Lambda和匿名类可以做类似于闭包的事情:它们可以作为参数传递给方法,并且可以访问其作用域之外的变量。但有一个限制:它们不能修改定义Lambda的方法的局部变量的内容(就是lambda方法所在的主方法的局部变量)。这些变量必须是隐式最终的。可以认为Lambda是对值封闭,而不是对变量封闭。如前所述,这种限制存在的原因在于局部变量存在于栈上,并且隐式表示它们仅限于其所在的线程。如果允许捕获可改变的局部变量,就会引发造成线程不安全的新的可能性,而这是我们不想看到的(实例变量可以,因为它们保存在堆中,而堆是在线程之间共享的)。
方法引用
方法引用让你可以重复使用现有的方法定义,并像Lambda一样传递它们。在一些情况下,比起使用Lambda表达式,它们似乎更易读,感觉也更自然。下面就是我们借助更新的Java 8 API,用方法引用写的一个排序的例子:
inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
之后(使用方法引用和java.util.Comparator.comparing);
inventory.sort(comparing(Apple::getWeight()));
管中窥豹
你为什么应该关心方法引用?方法引用可以被看作仅仅调用特定方法的Lambda的一种快捷写法。它的基本思想是,如果一个Lambda代表的只是“直接调用这个方法”,那最好还是用名称来调用它,而不是去描述如何调用它。事实上,方法引用就是让你根据已有的方法实现来创建Lambda表达式。但是,显示地指明方法的名称,你的代码的可读性会更好。它是如何工作的呢?当你需要使用方法引用时,目标引用放在分隔符 :: 前,方法的名称放在后面。例如,Apple::getWeight()就是引用了Apple类中定义的方法getWeight。请记住,不需要括号,因为你没有实际调用这个方法。方法引用就是Lambda表达式 (Apple a) -> a.getWeight()的快捷写法。
你可以把方法引用看作针对仅仅涉及单一方法的Lambda的语法糖,因为你表达同样的事情时要写的代码更少了。
如何构建方法引用
方法引用主要有三类。
指向静态方法的方法引用
例如Integer的parseInt方法,写作Integer::parseInt。
指向任意类型实例方法的方法引用
就是对象本身的方法引用。
例如String的length方法,写作String::length。
指向现有对象的实例方法的方法引用
假设你有一个局部变量expensiveTransaction用于存放Transaction类型的对象,它支持实例方法getValue,那么你就可以写expensiveTransaction::getValue。
第二种和第三种方法引用可能咋看起来有点晕。
类似于String::length的第二种方法引用的思想就是你在引用一个对象的方法,而这个对象本身是Lambda的一个参数。例如,Lambda表达式(String s)-> s.toUpperCase()可以写作String::toUpperCase。
但第三种方法引用指的是,你在Lambda中调用一个已经存在的外部对象中的方法。例如,Lambda表达式() -> expensiveTransaction.getValue()可以写作expensiveTransaction::getValue。
小结
以下是你应从本章中学到的关键概念。
- Lambda表达式可以理解为一种匿名函数:它没有名称,但有参数列表、函数主体、返回类型,可能还有一个可以抛出的异常的列表。
- Lambda表达式让你可以简洁地传递代码。
- 函数式接口就是仅仅声明一个抽象方法的接口。
- 只有在接收函数式接口的地方才可以使用Lambda表达式。
- Lambda表达式允许你直接内联,为函数式接口的抽象方法提供实现,并且将整个表达式作为函数式接口的一个实例。
- Java 8自带一些常用的函数式接口,放在java.util.function包里,包括Predicate、Function<T,R>、Supplier、Consumer和BinaryOperator。
- 为了避免装箱操作,对Predicate和Function<T,R>等通用函数式接口的原始类型特化:IntPredicate、IntToLongFunction等。
- 环绕执行模式(即在方法所必需的代码中间,你需要执行点儿什么操作,比如资源分配和清理)可以配合Lambda提高灵活性和可重用性。
- Lambda表达式所需要代表的类型称为目标类型。
- 方法引用让你重复使用现有的方法实现并直接传递它们。
- Comparator、Predicate和Function等函数式接口都有几个可以用来结合Lambda表达式的默认方法。