Lambda 表达式是无状态的,但您的程序不必如此
标签: Java
Venkat Subramaniam
发布: 2017-01-03
关于本系列
Java 8 是自 Java 语言诞生以来进行的一次最重大更新 包含了非常丰富的新功能,您可能想知道从何处开始着手了解它。在 本系列 中,作者兼教师 Venkat Subramaniam 提供了一种惯用的 Java 8 编程方法:这些简短的探索会激发您反思您认为理所当然的 Java 约定,同时逐步将新方法和语法集成到您的程序中。
在 Java™ 编程中,我们以不严格地使用术语 lambda 表达式 来表示 lambda 表达式和闭包。但在某些情况下,理解它们的区别很重要。lambda 表达式是无状态的,而闭包是带有状态的。将 lambda 表达式替换为闭包,是一种管理函数式程序中的状态的好方法。
我们在本系列中大量介绍了 lambda 表达式,您应该已经对它们有非常透彻的了解。它们是小巧的匿名函数,接受可选的参数,执行某种计算或操作,而且可能返回一个结果。lambda 表达式也是无状态的,这可能会在您的代码中产生重大影响。
我们首先看一个使用 lambda 表达式的简单示例。假设我们想将一个数字集合中的偶数乘以二。一种选择是使用 Stream
和 lambda 表达式创建一个函数管道,如下所示:
numbers.stream()
.filter(e -> e % 2 == 0)
.map(e -> e * 2)
.collect(toList());
Show moreShow more icon
我们传入 filter
中的 lambda 表达式取代了 Predicate
函数接口。它接受一个数字,如果该数字是偶数,则返回 true
,否则返回 false
。另一方面,我们传递给 map
的 lambda 表达式取代了 Function
函数接口:它接受任何数字并返回该值的两倍。
这两个 lambda 表达式都依赖于传入的参数和字面常量。二者都是独立的,这意味着它们没有任何外部依赖项。因为它们依赖于传入的参数,而且可能还依赖于一些常量,所以 lambda 表达式是无状态的。它们很可爱,也很安静,就像熟睡的婴儿一样。
现在让我们更仔细地看看传递给 map
方法的 lambda 表达式。如果我们希望计算给定值的三倍或四倍,该怎么办?我们可以将常量 2
转换为一个变量(比如 factor
),但 lambda 表达式仍需要一种方式来获取该变量。
我们可以推断,lambda 表达式可以采用与接收参数 e
的相同方式来接收 factor
,如下所示:
.map((e, factor) -> e * factor)
Show moreShow more icon
还不错,但不幸的是它不起作用。方法 map
要求接受函数接口 Function<T, R>
的一个实现作为参数。如果我们传入该接口外的任何内容(比如一个 BiFunction<T, U, R>
), map
不会接受。需要采用另一种方式将 factor
提供给我们的 lambda 表达式。
函数要求变量在限定范围内。因为它们实际上是匿名函数,所以 lambda 表达式也要求引用的变量在限定范围内。一些变量以参数形式被函数或 lambda 表达式接收。一些变量是局部定义的。一些变量来自函数外部,位于所谓的 词法范围 中。
下面是一个词法范围示例。
public static void print() {
String location = "World";
Runnable runnable = new Runnable() {
public void run() {
System.out.println("Hello " + location);
}
};
runnable.run();
}
Show moreShow more icon
在 print
方法中,location
是一个局部变量。但是,Runnable
的 run
方法还引用了一个不是 run
方法的局部变量或参数的 location
。对 Hello
旁边的 location
的引用被绑定到 print
方法的 location
变量。
词法范围 是函数的定义范围。反过来,它也可能是该定义范围的定义范围,等等。
在前面的代码中,方法 run
没有定义 location
或接收它作为参数。run
的定义范围是 Runnable
的匿名内部对象。因为没有将 location
定义为该实例中的字段,所以会继续搜索匿名内部对象的定义范围 — 在本例中为方法 print
的局部范围。
如果 location
不在该范围中,编译器会继续在 print
的定义范围内搜索,直到找到该变量或搜索失败。
现在让我们看看,使用 lambda 表达式重写前面的代码后会发生什么:
public static void print() {
String location = "World";
Runnable runnable = () -> System.out.println("Hello " + location);
runnable.run();
}
Show moreShow more icon
得益于 lambda 表达式,代码变得更简洁,但 location
的范围和绑定没有更改。lambda 表达式中的变量 location
被绑定到 lambda 表达式的词法范围中的变量 location
。严格来讲,此代码中的 lambda 表达式是一个 闭包 。
Lambda 表达式不依赖于任何外部实体;它们是依赖于自身参数和常量的内容。另一方面,闭包既依赖于参数和常量,也依赖于它们的词法范围中的变量。
从逻辑上讲,闭包被绑定到它们的词法范围中的变量。但是,尽管逻辑上讲是这样,但实际上并不总是这么做。有时甚至无法执行这样的绑定。
两个场景可以证明这一点。
首先,下面这段代码将一个 lambda 表达式或闭包传递给一个 call
方法:
class Sample {
public static void call(Runnable runnable) {
System.out.println("calling runnable");
//level 2 of stack
runnable.run();
}
public static void main(String[] args) {
int value = 4; //level 1 of stack
call(
() -> System.out.println(value) //level 3 of stack
);
}
}
Show moreShow more icon
此代码中的闭包使用了来自它的词法范围的变量 value
。如果 main
是在堆栈级别 1 上执行的,那么 call
方法的主体会在堆栈级别 2 上执行。因为 Runnable
的 run
方法是从 call
内调用的,所以该闭包的主体会在级别 3 上运行。如果 call
方法要将该闭包传递给另一个方法(进而推迟调用的位置),则执行的堆栈级别可能高于 3。
您现在可能想知道在一个堆栈级别中的执行究竟如何能获取之前的另一个堆栈级别中的变量 — 尤其是未在调用中传递上下文时。简单来讲就是无法获取该变量。
现在考虑另一个示例:
class Sample {
public static Runnable create() {
int value = 4;
Runnable runnable = () -> System.out.println(value);
System.out.println("exiting create");
return runnable;
}
public static void main(String[] args) {
Runnable runnable = create();
System.out.println("In main");
runnable.run();
}
}
Show moreShow more icon
在这个示例中, create
方法有一个局部变量 value
,该变量的寿命很短:只要我们退出 create
,它就会消失。create
内创建的闭包在其词法范围中引用了这个变量。在完成 create
方法后,该方法将闭包返回给 main
中的调用方。在此过程中,它从自己的堆栈中删除变量 value
,而且 lambda 表达式将会执行。这是结果输出:
exiting create
In main
4
Show moreShow more icon
我们知道,在 main
中调用 run
时,create
中的 value
就会终止。尽管我们可以假设 lambda 表达式中的 value
直接被绑定到它的词法范围中的变量,但该假设并不成立。
可通过一个类比来揭示其中原委。
假设我的办公室离家约 10 英里(使用改进的测量单位的话为 16 公里),而且我早上 8 点出门上班。中午,我有短暂的时间用午餐,但出于健康考虑,我喜欢吃家里烹饪的饭菜。由于休息时间很短,只有在离家时带上午餐,我才能吃上家里的饭菜。
这就是闭包要完成的任务:它们携带自己的午餐(或状态)。
为了讲得更清楚一些,让我们再看看 create
中的 lambda 表达式:
Runnable runnable = () -> System.out.println(value);
Show moreShow more icon
我们编写的 lambda 表达式没有接受任何参数,但需要它的 value
。编译类 Sample
并运行 javap -c -p Sample.class
来检查字节码。您会注意到,编译器为该闭包创建了一个方法,该方法接受一个 int
参数:
private static void lambda$create$0(int);
Code:
0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
3: iload_0
4: invokevirtual #9 // Method java/io/PrintStream.println:(I)V
7: return
}
Show moreShow more icon
现在看看为 create
方法生成的字节码:
0: iconst_4
1: istore_0
2: iload_0
3: invokedynamic #2, 0 // InvokeDynamic #0:run:(I)Ljava/lang/Runnable;
Show moreShow more icon
值 4
存储在一个变量中,然后,该变量被加载并传递到为闭包创建的函数。在本例中,闭包保留着 value
的一个副本。
这就是闭包携带状态的方式。
现在,我们回头看看本文开头的示例。除了计算集合中的偶数值的两倍,如果我们想要计算它们的三倍或四倍,该怎么办?为此,我们可以将原始 lambda 表达式转换为一个闭包。
这是我们之前看到的无状态代码:
numbers.stream()
.filter(e -> e % 2 == 0)
.map(e -> e * 2)
.collect(toList());
Show moreShow more icon
使用闭包而不是 lambda 表达式,代码就会变成:
int factor = 3;
numbers.stream()
.filter(e -> e % 2 == 0)
.map(e -> e * factor)
.collect(toList());
Show moreShow more icon
map
方法现在接受一个闭包,而不是一个 lambda 表达式。我们知道,这个闭包接受一个参数 e
,但它也捕获并携带 factor
变量的状态。
此变量位于该闭包的 词法范围 中。它可以是定义 lambda 表达式的函数中的局部变量;可以作为该外部函数的一个参数传入;可以位于闭包的定义范围(或该定义范围的定义范围等)中的任何地方。无论如何,该闭包将状态从定义该闭包的代码传递到了需要该变量的执行点。
闭包不同于 lambda 表达式,因为它们依赖于自己的词法范围来获取一些变量。因此,闭包可以捕获并携带状态。lambda 表达式是无状态的,闭包是有状态的。可以在您的程序中使用闭包,将状态从定义上下文携带到执行点。
本文翻译自: Using closures to capture state(2017-12-06)