在 Java 中关闭 - 捕获的值 - 为什么会出现这种意外结果?

Closure in Java - Captured value - why this unexpected result?

我想我在Java脚本中遇到过这种经典情况。

通常程序员希望下面的代码打印"Peter"、"Paul"、"Mary"。

但事实并非如此。谁能准确解释为什么它在 Java 中以这种方式工作?

此Java 8 段代码编译成功并打印3 次"Mary"。

我想这与它如何深入实施有关
但是......这是否表明错误的底层实现?

import java.util.List;
import java.util.ArrayList;

public class Test008 {

    public static void main(String[] args) {
        String[] names = { "Peter", "Paul", "Mary" };
        List<Runnable> runners = new ArrayList<>();

        int[] ind = {0};
        for (int i = 0; i < names.length; i++){ 
            ind[0] = i;
            runners.add(() -> System.out.println(names[ind[0]]));
        }

        for (int k=0; k<runners.size(); k++){
            runners.get(k).run();
        }

    }

}

相反,如果我使用增强的 for 循环(同时添加 Runnable),则会捕获正确的(即所有不同的)值。

for (String name : names){
    runners.add(() -> System.out.println(name));
}

最后,如果我使用经典的 for 循环(同时添加 Runnable),则会出现编译错误(这很合理,因为变量 i 不是最终的或实际上不是最终的)。

for (int i = 0; i < names.length; i++){ 
    runners.add(() -> System.out.println(names[i]));
}

编辑:

我的观点是:为什么没有捕获 names[ind[0]] 的值(我添加 Runnable 时它所具有的值)?我什么时候执行 lambda 表达式应该无关紧要,对吧?我的意思是,好的,在具有增强 for 循环的版本中,我稍后也会执行 Runnables,但 correct/distinct 值是较早捕获的(在添加 Runnables 时)。

换句话说,为什么在捕获值时 Java 不总是具有这种 by value / snapshot 语义(如果我可以这样说的话)?它不会更干净,更有意义吗?

当您创建 lambda 表达式时,您并没有执行它。 它仅在您的第二个 for 循环中执行,当您调用每个 Runnable.

run 方法时

执行第二个循环时,ind[0]包含2,因此run所有方法执行时打印"Mary"。

编辑:

增强的 for 循环的行为不同,因为在该片段中,lambda 表达式持有对 String 实例的引用,而 String 是不可变的。如果将 String 更改为 StringBuilder,则可以使用增强的 for 循环构建一个示例,该循环还打印被引用实例的最终值:

StringBuilder[] names = { new StringBuilder().append ("Peter"), new StringBuilder().append ("Paul"), new StringBuilder().append ("Mary") };
List<Runnable> runners = new ArrayList<>();

for (StringBuilder name : names){
  runners.add(() -> System.out.println(name));
  name.setLength (0);
  name.append ("Mary");
}

for (int k=0; k<runners.size(); k++){
  runners.get(k).run();
}

输出:

Mary
Mary
Mary

我会说代码完全按照你告诉他的去做。

您创建 "Runnable",它在 ind[0] 的位置打印名称。 但是该表达式在您的第二个 for 循环中得到评估。而此时ind[0]=2。所以表达式打印 "Mary"

它工作得很好.. 在调用 println 方法时,ind[0]2,因为您正在使用一个公共变量并在函数调用之前增加它的值。因此它将始终打印 Mary。您可以执行以下操作来检查

    for (int i = 0; i < names.length; i++) {
        final int j = i;
        runners.add(() -> System.out.println(names[j]));
    }

这将根据需要打印所有名称

或在本地声明ind

    for (int i = 0; i < names.length; i++) {
        final int[] ind = { 0 };
        ind[0] = i;
        runners.add(() -> System.out.println(names[ind[0]]));
    }

In other words, why does not Java always have this by value / snapshot semantics (if I may put it this way) when capturing values? Wouldn't it be cleaner and make more sense?

Java lambdas do 具有按值捕获语义。

当你这样做时:

runners.add(() -> System.out.println(names[ind[0]]));

此处的 lambda 捕获两个 indnames。这两个值恰好是对象引用,但对象引用是一个类似于“3”的值。当捕获的对象引用引用可变对象时,事情就会变得混乱,因为它们在 lambda 捕获期间的状态和它们在 lambda 调用期间的状态可能不同。具体来说, ind 所指的数组确实以这种方式发生变化,这就是你的问题的原因。

lambda 不捕获的是表达式 ind[0] 的值。相反,它捕获引用 ind,并且当 lambda 被 调用时 ,执行取消引用 ind[0]。 Lambdas 从它们的词法封闭范围中关闭值; ind 是词法封闭范围内的值,但 ind[0] 不是。我们捕获 ind 并在评估时进一步使用它。

您在这里以某种方式期望 lambda 将对整个堆中可通过捕获的引用访问的所有对象进行完整快照,但这不是它的工作方式——也没有多大意义。

总结:Lambda 按值捕获所有捕获的参数——包括对象引用。但是对象引用和它所引用的对象不是一回事。如果您捕获对可变对象的引用,则该对象的状态可能在调用 lambda 时已更改。

好吧,在 lambda 之前,Java 曾经有更严格的规则来捕获匿名 local/parameter 变量 类。即只允许捕获final个变量。我相信这是为了防止并发混淆——最终变量不会改变。因此,如果您将以这种方式创建的一些实例传递到任务队列中,并让另一个线程执行它,至少您知道这两个线程共享相同的值。 Why are only final variables accessible in anonymous class?

解释了更深层次的原因

现在,使用 lambda 和 Java 8,您可以捕获 "effectively final" 的变量,这些变量未声明 [​​=10=] 但被编译器认为是最终变量到它是如何读和写的。基本上,如果您将 final 关键字添加到有效 final 变量的声明中,编译器就不会抱怨。

解释此行为的另一种方法是查看 lambda 表达式替换的内容:

runners.add(() -> System.out.println(names[ind[0]]));

的语法糖
runers.add(new Runnable() {
    final String[] names_inner = names;
    final int[] ind_inner = ind;
    public void run() {
        System.out.println(names_inner[ind_inner[0]]));
    }
});

变成

// yes, this is inside the main method's body
class Test008 implements Runnable {
    final String[] names_inner;
    final int[] ind_inner;

    private Test008(String[] n, int[] i) {
        names_inner = n; ind_inner = i;
    }

    public void run() {
        System.out.println(names_inner[ind_inner[0]]));
    }
}
runers.add(new Test008(names,ind));

(生成的东西的名称并不重要。那个美元符号只是在 Java 中生成的 methods/classes 的名称中经常使用的字符 - 这就是它被保留的原因。 )

增加了两个内部字段,使得Runnable里面的代码可以看到外面的变量;如果 namesind 在外部代码(main 方法)中定义为 final,则内部字段将不是必需的。这样变量就可以按值传递,正如 Brian Goetz 在他的回答中解释的那样。

数组就像对象,如果将它们传递给修改它们的方法,它们也会在原始位置被修改;剩下的只是 OOP 基础知识。所以这实际上是正确的行为。