Lambda表达式
本章内容
- Lambda管中窥豹
- 在哪里以及如何使用Lambda
- 环绕执行模式
- 函数式接口
- 类型推断
- 方法引用
- Lambda复合
Lambda 管中窥豹
可以把Lambda表达式理解为简洁地表示可传递的匿名函数的一种方式:它没有名称,但它 有参数列表、函数主体、返回类型,可能还有一个可以抛出的异常列表。这个定义够大的,让我 们慢慢道来。
- 匿名——我们说匿名, 是因为它不像普通的方法那样有一个明确的名称:写得少而想得多!
- 函数——我们说它是函数,是因为Lambda函数不像方法那样属于某个特定的类。但和方法一样,Lambda有参数列表、函数主体、返回类型,还可能有可以抛出的异常列表。
- 传递——Lambda表达式可以作为参数传递给方法或存储在变量中。
- 简洁——无需像匿名类那样写很多模板代码。
你是不是好奇Lambda这个词是从哪儿来的?其实它来自于学术界开发出来的一套用来描述 计算的λ演算法。
Java语言设计者选择这样的语法,是因为C#和Scala等语言中的类似功能广受欢迎。Lambda 的基本语法是
|
|
此处的语句表达式特指 §14.8. Expression Statements.
StatementExpression: Assignment 赋值 PreIncrementExpression 自增 PreDecrementExpression 自减 PostIncrementExpression PostDecrementExpression MethodInvocation 方法调用 ClassInstanceCreationExpression 对象创建
或(请注意语句的花括号)
|
|
表3-1提供了一些Lambda的例子和使用案例。
在哪里以及如何使用Lambda
函数式接口
一言以蔽之,函数式接口就是只定义一个抽象方法的接口。你已经知道了Java API中的一些 其他函数式接口,如我们在第2章中谈到的Comparator和Runnable。
你将会在第9章中看到,接口现在还可以拥有默认方法(即在类没有对方法进行实现时, 其主体为方法提供默认实现的方法)。哪怕有很多默认方法,只要接口只定义了一个抽象 方法,它就仍然是一个函数式接口。
Lambda表达式允许你直接以内联的形式为函数式接口的抽象方法提供实现,并把整个表达式作为函数式接口的实例(具体说来,是函数式接口一个具体实现 的实例)。你用匿名内部类也可以完成同样的事情,只不过比较笨拙:需要提供一个实现,然后 再直接内联将它实例化。
内联:把函数调用的方法直接内嵌到方法内部,减少函数调用的次数,JIT探测到是热点代码就会对方法进行内联,同样的还有循环语句。
参考:java 方法内联
函数描述符
函数式接口的抽象方法的签名基本上就是Lambda表达式的签名。我们将这种抽象方法叫作 函数描述符。
我们在本章中使用了一个特殊表示法来描述Lambda和函数式接口的签名。() -> void代表 了参数列表为空,且返回void的函数。这正是Runnable接口所代表的。
现在,只要知道Lambda表达式可以被赋给一个 变量,或传递给一个接受函数式接口作为参数的方法就好了,当然这个Lambda表达式的签名要 和函数式接口的抽象方法一样。
@FunctionalInterface又是怎么回事?
如果你去看看新的Java API,会发现函数式接口带有@FunctionalInterface的标注(3.4节中会深入研究函数式接口,并会给出一个长长的列表)。这个标注用于表示该接口会设计成一个函数式接口。如果你用@FunctionalInterface定义了一个接口,而它却不是函数式接口的话,编译器将返回一个提示原因的错误。例如,错误消息可能是“Multiple non-overriding abstract methods found in interface Foo”,表明存在多个抽象方法。请注意,@FunctionalInter-face不是必需的,但对于为此设计的接口而言,使用它是比较好的做法。它就像是@Override 标注表示方法被重写了。
把 Lambda 付诸实践:环绕执行模式
让我们通过一个例子,看看在实践中如何利用Lambda和行为参数化来让代码更为灵活,更 为简洁。资源处理(例如处理文件或数据库)时一个常见的模式就是打开一个资源,做一些处理, 然后关闭资源。这个设置和清理阶段总是很类似,并且会围绕着执行处理的那些重要代码。这就 是所谓的环绕执行(execute around)模式,如图3-2所示。例如,在以下代码中,高亮显示的就 是从一个文件中读取一行所需的模板代码(注意你使用了Java 7中的带资源的try语句,它已经 简化了代码,因为你不需要显式地关闭资源了):
jdk1.7中try-with-resources语法糖详解:主要是针对所有凡是继承了Closeable这个类,系统在方法退出的时候都会自动的关闭资源。
现在这段代码是有局限的。你只能读文件的第一行。如果你想要返回头两行,甚至是返回使 用最频繁的词, 该怎么办呢?
步骤:
-
行为参数化
-
使用函数式接口来传递行为
-
执行一个行为
-
第 4 步:传递 Lambda
使用函数式接口
函数式接口很有用, 因为抽象方法的签名可以描述Lambda表达式的签名。函数式接口的抽象方法的签名称为函数描 述符。所以为了应用不同的Lambda表达式,你需要一套能够描述常见函数描述符的函数式接口。 Java API中已经有了几个函数式接口, 比如你在3.2节中见到的 Comparable 、 Runnable 和 Callable。
Java 8的库设计师帮你在java.util.function包中引入了几个新的函数式接口。我们接下 来会介绍Predicate、Consumer和Function,更完整的列表可见本节结尾处的表3-2。
Predicate
java.util.function.Predicate<T>
接口定义了一个名叫test的抽象方法,它接受泛型 T对象,并返回一个boolean。
Consumer
java.util.function.Consumer<T>
定义了一个名叫 accept 的抽象方法, 它接受泛型 T 的对象,没有返回(void)。你如果需要访问类型T的对象,并对其执行某些操作,就可以使用 这个接口。比如,你可以用它来创建一个forEach方法,接受一个Integers的列表,并对其中 每个元素执行操作。
Function
java.util.function.Function<T, R>接口定义了一个叫作apply的方法,它接受一个泛型T的对象,并返回一个泛型R的对象。如果你需要定义一个Lambda,将输入对象的信息映射 到输出,就可以使用这个接口。
原始类型特化
自动装箱机制在性能方面是要付出代价的。装箱后的值本质上就是把原始类型包裹起来,并保存在堆里。因此,装箱后的值需要更多的内存,并需要额外的内存搜索来获取被包裹的原始值。
Java 8为我们前面所说的函数式接口带来了一个专门的版本,以便在输入和输出都是原始类 型时避免自动装箱的操作。
一般来说,针对专门的输入参数类型的函数式接口的名称都要加上对应的原始类型前缀,比 如DoublePredicate、IntConsumer、LongBinaryOperator、IntFunction等。Function 接口还有针对输出参数类型的变种:ToIntFunction
表3-2总结了Java API中提供的最常用的函数式接口及其函数描述符。请记得这只是一个起 点。如果有需要,你可以自己设计一个。请记住,(T,U) -> R的表达方式展示了应当如何思考 一个函数描述符。表的左侧代表了参数类型。这里它代表一个函数,具有两个参数,分别为泛型 T和U,返回类型为R。
异常、Lambda,还有函数式接口又是怎么回事呢?
请注意,任何函数式接口都不允许抛出受检异常(checked exception)。如果你需要Lambda表达式来抛出异常,有两种办法:定义一个自己的函数式接口,并声明受检异常,或者把Lambda包在一个try/catch块中。
比如,在3.3节我们介绍了一个新的函数式接口BufferedReaderProcessor,它显式声明了一个IOException:
|
|
但是你可能是在使用一个接受函数式接口的API,比如Function<T, R>,没有办法自己创建一个(你会在下一章看到,Stream API中大量使用表3-2中的函数式接口)。这种情况下,你可以显式捕捉受检异常:
|
|
非法抛出异常
|
|
类型检查、类型推断以及限制
当我们第一次提到Lambda表达式时,说它可以为函数式接口生成一个实例。然而,Lambda 表达式本身并不包含它在实现哪个函数式接口的信息。为了全面了解Lambda表达式,你应该知 道Lambda的实际类型是什么。
类型检查
Lambda的类型是从使用Lambda的上下文推断出来的。上下文(比如,接受它传递的方法的 参数,或接受它的值的局部变量)中Lambda表达式需要的类型称为目标类型。让我们通过一个 例子,看看当你使用Lambda表达式时背后发生了什么。图3-4概述了下列代码的类型检查过程。
如果Lambda表达式抛出一个异常,那么抽象方法所声明的throws语句也必 须与之匹配。
同样的Lambda,不同的函数式接口
有了目标类型的概念,同一个Lambda表达式就可以与不同的函数式接口联系起来,只要它 们的抽象方法签名能够兼容。比如,前面提到的Callable和PrivilegedAction,这两个接口 都代表着什么也不接受且返回一个泛型T的函数。
菱形运算符
那些熟悉Java的演变的人会记得,Java 7中已经引入了菱形运算符(<>
),利用泛型推断从上下文推断类型的思想(这一思想甚至可以追溯到更早的泛型方法)。一个类实例表达式可以出现在两个或更多不同的上下文中,并会像下面这样推断出适当的类型参数:
|
|
特殊的void兼容规则
如果一个Lambda的主体是一个语句表达式, 它就和一个返回void的函数描述符兼容(当然需要参数列表也兼容)。 例如, 以下两行都是合法的, 尽管 List 的 add 方法返回了一个boolean,而不是Consumer上下文(T -> void)所要求的void:
|
|
Lambda表达式可以从赋值的上下文、方法调用的上下文(参数和返回值),以及类型转换的上下文中获得目标类型。
类型推断
Java编译器会从上下文(目标类型)推断出用什么函数式接 口来配合Lambda表达式,这意味着它也可以推断出适合Lambda的签名,因为函数描述符可以通 过目标类型来得到。这样做的好处在于,编译器可以了解Lambda表达式的参数类型,这样就可 以在Lambda语法中省去标注参数类型。换句话说,Java编译器会像下面这样推断Lambda的参数 类型:
Lambda表达式有多个参数,代码可读性的好处就更为明显。例如,你可以这样来创建一个 Comparator对象:
当Lambda仅有一个类型需要推断的参数时,参数名称两边的括号也可以省略。
请注意,有时候显式写出类型更易读,有时候去掉它们更易读。没有什么法则说哪种更好; 对于如何让代码更易读,程序员必须做出自己的选择。
使用局部变量
我们迄今为止所介绍的所有Lambda表达式都只用到了其主体里面的参数。但Lambda表达式 也允许使用自由变量(不是参数,而是在外层作用域中定义的变量),就像匿名类一样。 它们被 称作捕获Lambda。例如,下面的Lambda捕获了portNumber变量:
|
|
尽管如此,还有一点点小麻烦:关于能对这些变量做什么有一些限制。Lambda可以没有限 制地捕获(也就是在其主体中引用)实例变量和静态变量。但局部变量必须显式声明为final, 或事实上是final。换句话说,Lambda表达式只能捕获指派给它们的局部变量一次。(注:捕获 实例变量可以被看作捕获最终局部变量this。) 例如,下面的代码无法编译,因为portNumber 变量被赋值两次:
对局部变量的限制
你可能会问自己,为什么局部变量有这些限制。
第一,实例变量和局部变量背后的实现有一 个关键不同。实例变量都存储在堆中,而局部变量则保存在栈上。如果Lambda可以直接访问局 部变量,而且Lambda是在一个线程中使用的,则使用Lambda的线程,可能会在分配该变量的线 程将这个变量收回之后,去访问该变量。因此,Java在访问自由局部变量时,实际上是在访问它 的副本,而不是访问原始变量。如果局部变量仅仅赋值一次那就没有什么区别了——因此就有了 这个限制。
第二,这一限制不鼓励你使用改变外部变量的典型命令式编程模式(我们会在以后的各章中 解释,这种模式会阻碍很容易做到的并行处理)。
闭包
你可能已经听说过闭包(closure, 不要和Clojure编程语言混淆)这个词, 你可能会想Lambda是否满足闭包的定义。用科学的说法来说,闭包就是一个函数的实例,且它可以无限制地访问那个函数的非本地变量。例如,闭包可以作为参数传递给另一个函数。它也可以访问和修改其作用域之外的变量。现在,Java 8的Lambda和匿名类可以做类似于闭包的事情:它们可以作为参数传递给方法,并且可以访问其作用域之外的变量。但有一个限制:它们不能修改定义Lambda的方法的局部变量的内容。这些变量必须是隐式最终的。可以认为Lambda是对值封闭,而不是对变量封闭。如前所述,这种限制存在的原因在于局部变量保存在栈上,并且隐式表示它们仅限于其所在线程。如果允许捕获可改变的局部变量,就会引发造成线程不安全的新的可能性,而这是我们不想看到的(实例变量可以,因为它们保存在堆中,而堆是在线程之间共享的)。
如果想使用闭包,可以将局部变量改成引用变量。
使用闭包的一个例子:打印给定数量的 斐波那契数列
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
@FunctionalInterface interface fun { int f(); } @Setter @Getter @AllArgsConstructor static class MyInter { Integer i; } public static void main(String[] args) { print(10); } public static void print(int n) { MyInter last = new MyInter(0); MyInter next = new MyInter(1); fun fun = () -> { int r = next.getI(); int i = last.getI() + next.getI(); last.setI(next.getI()); next.setI(i); return r; }; for (int i = 0; i < n; i++) { System.out.println(fun.f()); } }
方法引用
方法引用让你可以重复使用现有的方法定义,并像Lambda一样传递它们。在一些情况下, 比起使用Lambda表达式,它们似乎更易读,感觉也更自然。
管中窥豹
你为什么应该关心方法引用?方法引用可以被看作仅仅调用特定方法的Lambda的一种快捷 写法。它的基本思想是,如果一个Lambda代表的只是“直接调用这个方法”,那最好还是用名称 来调用它,而不是去描述如何调用它。事实上,方法引用就是让你根据已有的方法实现来创建 Lambda表达式。显式地指明方法的名称,你的代码的可读性会更好。它是如何工作的呢? 当你需要使用方法引用时, 目标引用放在分隔符 ::
前, 方法的名称放在后面。 例如, Apple::getWeight就是引用了Apple类中定义的方法getWeight。请记住,不需要括号,因为 你没有实际调用这个方法。方法引用就是Lambda表达式(Apple a) -> a.getWeight()的快捷 写法。表3-4给出了Java 8中方法引用的其他一些例子。
你可以把方法引用看作针对仅仅涉及单一方法的Lambda的语法糖,因为你表达同样的事情 时要写的代码更少了。
如何构建方法引用
方法引用主要有三类。
-
指向静态方法的方法引用(例如Integer的parseInt方法,写作Integer::parseInt)。
-
指向任意类型实例方法的方法引用(例如String 的 length方 法 , 写 作 String::length)。
-
指向现有对象的实例方法的方法引用(假设你有一个局部变量expensiveTransaction 用于存放Transaction类型的对象,它支持实例方法getValue,那么你就可以写expensive- Transaction::getValue)。
第二种和第三种方法引用可能乍看起来有点儿晕。类似于String::length的第二种方法引 用的思想就是你在引用一个对象的方法,而这个对象本身是Lambda的一个参数。例如,Lambda 表达式(String s) -> s.toUppeCase()可以写作String::toUpperCase。但第三种方法引用 指的是, 你在Lambda中调用一个已经存在的外部对象中的方法。 例如, Lambda表达式
()->expensiveTransaction.getValue()
可以写作expensiveTransaction::getValue。
依照一些简单的方子,我们就可以将Lambda表达式重构为等价的方法引用,如图3-5所示。
请注意,还有针对构造函数、数组构造函数和父类调用(super-call)的一些特殊形式的方法 引用。
让我们举一个方法引用的具体例子吧。比方说你想要对一个字符串的List排序,忽略大 小写。List的sort方法需要一个Comparator作为参数。你在前面看到了,Comparator描述了 一个具有(T, T) -> int签名的函数描述符。你可以利用String类中的compareToIgnoreCase 方法来定义一个Lambda表达式(注意compareToIgnoreCase是String类中预先定义的)。
|
|
Lambda表达式的签名与Comparator的函数描述符兼容。利用前面所述的方法,这个例子可 以用方法引用改写成下面的样子:
|
|
请注意,编译器会进行一种与Lambda表达式类似的类型检查过程,来确定对于给定的函数 式接口,这个方法引用是否有效:方法引用的签名必须和上下文类型匹配。
构造函数引用
对于一个现有构造函数, 你可以利用它的名称和关键字 new 来创建它的一个引用: ClassName::new。它的功能与指向静态方法的引用类似。例如,假设有一个构造函数没有参数。 它适合Supplier的签名() -> Apple。你可以这样做:
如果你的构造函数的签名是Apple(Integer weight),那么它就适合Function接口的签 名,于是你可以这样写:
符合Lambda表达式的有用方法
Java 8的好几个函数式接口都有为方便而设计的方法。具体而言,许多函数式接口,比如用 于传递Lambda表达式的Comparator、Function和Predicate都提供了允许你进行复合的方法。 这是什么意思呢?在实践中,这意味着你可以把多个简单的Lambda复合成复杂的表达式。比如, 你可以让两个谓词之间做一个or操作,组合成一个更大的谓词。而且,你还可以让一个函数的结 果成为另一个函数的输入。你可能会想,函数式接口中怎么可能有更多的方法呢?(毕竟,这违背了函数式接口的定义啊!)窍门在于,我们即将介绍的方法都是默认方法,也就是说它们不是抽象方法。
比较器复合
我们前面看到,你可以使用静态方法Comparator.comparing,根据提取用于比较的键值 的Function来返回一个Comparator,如下所示:
|
|
逆序
如果你想要对苹果按重量递减排序怎么办?用不着去建立另一个Comparator的实例。接口 有一个默认方法reversed可以使给定的比较器逆序。
|
|
比较器链
上面说得都很好,但如果发现有两个苹果一样重怎么办?哪个苹果应该排在前面呢?你可能 需要再提供一个Comparator来进一步定义这个比较。比如,在按重量比较两个苹果之后,你可 能想要按原产国排序。thenComparing方法就是做这个用的。它接受一个函数作为参数(就像 comparing方法一样),如果两个对象用第一个Comparator比较之后是一样的,就提供第二个 Comparator。
|
|
谓词复合
谓词接口包括三个方法:negate、and和or,让你可以重用已有的Predicate来创建更复 杂的谓词。
negate
比如,你可以使用negate方法来返回一个Predicate的非,比如苹果不是红的:
|
|
and
你可能想要把两个Lambda用and方法组合起来,比如一个苹果既是红色又比较重:
|
|
or
你可以进一步组合谓词,表达要么是重(150克以上)的红苹果,要么是绿苹果:
|
|
请注意,and和or方法是按照在表达式链中的位置,从左向右确定优 先级的。因此,a.or(b).and(c)可以看作(a || b) && c。
函数复合
你还可以把Function接口所代表的Lambda表达式复合起来。Function接口为此配 了andThen和compose两个默认方法,它们都会返回Function的一个实例。
andThen方法会返回一个函数,它先对输入应用一个给定函数,再对输出应用另一个函数。 比如,假设有一个函数f给数字加1 (x -> x + 1),另一个函数g给数字乘2,你可以将它们组 合成一个函数h,先给数字加1,再给结果乘2:
你也可以类似地使用compose方法,先把给定的函数用作compose的参数里面给的那个函 数,然后再把函数本身用于结果。比如在上一个例子里用compose的话,它将意味着f(g(x)), 而andThen则意味着g(f(x)):
图3-6说明了andThen和compose之间的区别。
比方说你有一系列工具方法,对用String表示的一封信做文本转换:
|
|
现在你可以通过复合这些工具方法来创建各种转型流水线了,比如创建一个流水线:先加上 抬头,然后进行拼写检查,最后加上一个落款,如图3-7所示。
数学中的类似思想
积分
假设你有一个(数学,不是Java)函数f,比如说定义是
$f (x) = x+10$
那么,(工科学校里)经常问的一个问题就是,画在纸上之后函数下方的面积(把x轴作为基准)。比如对于图3-8所示的区域你会写
在这个例子里, 函数f是一条直线,因此你很容易通过梯形方法(画几个三角形)来算出 面积:
1/2 × ((3 + 10) + (7 + 10)) × (7 – 3) = 60
那么这在Java里面如何表达呢?你的第一个问题是把积分号或dy/dx之类的换成熟悉的编程 语言符号。
确实,根据第一条原则你需要一个方法,比如说叫integrate,它接受三个参数:一个是f, 还有上下限(这里是3.0和7.0)。于是写在Java里就是下面这个样子,函数f是被传递进去的:
integrate(f, 3, 7)
与 Java8 的 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.apply(a),而不是像数学里面写f(a),但Java无 法摆脱“一切都是对象”的思想——它不能让函数完全独立!
小结
以下是你应从本章中学到的关键概念。
- 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>
等通用函数式接口的原始类型特化:IntPredicate、IntToLongFunction等。 - 环绕执行模式(即在方法所必需的代码中间,你需要执行点儿什么操作,比如资源分配 和清理)可以配合Lambda提高灵活性和可重用性。
- Lambda表达式所需要代表的类型称为目标类型。
- 方法引用让你重复使用现有的方法实现并直接传递它们。
- Comparator、Predicate和Function等函数式接口都有几个可以用来结合Lambda表达式的默认方法。