Lambda表达式:局部变量需要使用final关键字,实例变量则不需要。

80
在lambda表达式中,局部变量需要是final的,但实例变量不需要。为什么?

请注意,至少在最新版本的Java 1.8编译器中,局部变量只需要是有效的final,因此它们不需要被明确声明为final,但是它们不能被修改。 - Valentin Ruano
阅读了这里的所有答案后,我仍然认为这只是编译器强制执行的规则,旨在最小化程序员错误 - 也就是说,可变局部变量不能被捕获,或者被捕获的局部变量不能被改变,没有技术上的原因。这一点得到了支持,因为可以通过使用对象包装器(因此对象引用实际上是final的,但不是对象本身)来轻松地绕过此规则。另一种方法是创建一个数组,即Integer[] count = {new Integer(5)}。另请参见https://dev59.com/Q1UL5IYBdhLWcg3wYHDD#50457016。 - flow2k
@McDowell,lambda表达式不仅仅是匿名类的语法糖,而是完全不同的构造。 - Pacerier
@flow2k 只要“count”引用不改变,这就可以了,这与更改指向它的值不同。对值的引用必须是final/effectively final,而不是值本身。考虑到这一点和文章http://www.lambdafaq.org/what-are-the-reasons-for-the-restriction-to-effective-immutability/,允许可变引用将产生不良影响是有道理的。 - 01000001
10个回答

64

字段和局部变量之间的根本区别在于,当JVM创建lambda实例时,局部变量被复制。另一方面,可以自由更改字段,因为对它们的更改也会传播到外部类实例(它们的作用域是整个外部类,如Boris在下面指出的那样)。

匿名类、闭包和lambda最容易从变量作用域的角度来思考;想象一下,为所有传递给闭包的局部变量添加了一个复制构造函数。


好的,那么匿名类构造函数不需要复制实例变量,因为它可以直接引用它。很好的解释! - Gerard

26

在项目lambda的文档中,Lambda状态v4的第7节“变量捕获”中提到:

我们的意图是禁止捕获可变局部变量。原因是这种习惯用法:

int sum = 0;
list.forEach(e -> { sum += e.size(); });

本质上,Lambda表达式是串行的;编写此类没有竞争条件的Lambda体相当困难。除非我们愿意在编译时强制执行不能逃出其捕获线程的函数,否则此功能可能会带来更多麻烦。

另一个需要注意的问题是,当您在内部类中访问局部变量时,这些局部变量在内部类的构造函数中被传递。对于非final变量,这种方式无法使用,因为非final变量的值可以在构造后更改。

而对于实例变量,编译器将传递对象的引用,并且对象引用将用于访问实例变量。因此,在实例变量的情况下不需要进行此操作。

PS:值得一提的是,在Java SE7中,匿名类只能访问final局部变量;而在Java SE8中,Lambda以及内部类也可以访问有效的final变量。


20
Java 8实战一书中,这种情况被解释为:
首先,局部变量和实例变量在背后的实现方式有一个关键的区别。实例变量存储在堆上,而局部变量存储在栈上。如果一个lambda能够直接访问局部变量,并且该lambda在线程中使用,则使用lambda的线程可能会在分配变量的线程将其取消分配之后尝试访问该变量。因此,Java将访问自由局部变量实现为访问其副本,而不是访问原始变量。如果局部变量只赋值一次,则没有区别 - 因此有限制。
其次,这个限制也阻止了典型的命令式编程模式(正如我们在后面的章节中所解释的那样,这些模式防止了易于并行化),这些模式会改变外部变量。

1
我真的认为在这一点上《Java 8实战》存在一些问题。如果这里的“局部变量”是指在方法中创建但由lambda访问的变量,并且多线程是通过ForkJoin实现的,那么不同线程将有一个副本,理论上允许在lambda中进行突变,这种情况下它可以被“突变”。但这里的情况不同,lambda中使用的“局部变量”是通过parallelStream实现的并行化,这些“局部变量”由基于lambda的不同线程共享。 - Hearen
因此,第一点实际上是不正确的,不存在所谓的“复制”,它在 parallelStream 中是共享的。而在线程之间共享可变变量就像第二点一样危险。这就是为什么我们会防止这种情况发生,并在Stream中引入内置方法来处理这些情况。 - Hearen

15

为未来的访问者提供一些概念:

总之,编译器应该能够确定地告诉我们,lambda表达式体不会对变量的陈旧副本进行操作

对于局部变量,在没有将变量声明为final或effectively final的情况下,编译器无法确定lambda表达式体是否在使用变量的陈旧副本,因此局部变量应该是final或effectively final。

现在,对于实例字段,当您在lambda表达式中访问实例字段时,编译器将在变量访问中附加一个this(如果您没有明确执行),由于this是 effectively final,因此编译器可以确保lambda表达式体始终具有最新的变量副本(请注意,多线程不在此讨论范围内)。所以,对于实例字段,编译器可以确定lambda表达式体具有实例变量的最新副本,因此实例变量无需是final或effectively final。请参考以下来自Oracle幻灯片的屏幕截图:

enter image description here

另外,请注意,如果您在lambda表达式中访问实例字段并且该表达式在多线程环境中运行,则可能会遇到问题。


2
您能提供Oracle幻灯片的来源吗? - flow2k
@hagrawal,您能否详细说明一下关于多线程环境的最终声明?这是针对实例变量在任何时间的实际值吗?因为许多线程同时运行,所以它们可以覆盖实例变量。另外,如果我正确同步成员变量,问题仍然存在吗? - Yug Singh
1
最佳答案我猜应该是这个 ;) - Supun Wijerathne

15
因为实例变量总是通过对某个对象引用的字段访问操作来访问,例如some_expression.instance_variable。 即使您没有通过点符号显式地访问它,例如instance_variable,也会被隐式视为this.instance_variable(如果您在内部类中访问外部类的实例变量,则为OuterClass.this.instance_variable,在幕后是this.<hidden reference to outer this>.instance_variable)。
因此,实例变量从未直接访问,您直接访问的真正“变量”是this(它是“有效地final”,因为它不可分配),或者是某个其他表达式开头的变量。

这个问题的解释很好。 - Sritam Jagadev

10

看起来您询问的是lambda主体可以引用的变量。

根据JLS §15.27.2

在lambda表达式中使用但未声明的任何局部变量、形式参数或异常参数必须声明为final或实际上为final(§4.12.4),否则在尝试使用时将导致编译时错误。

因此,您无需将变量声明为final,只需要确保它们是“实际上为final”的即可。这与匿名类适用相同的规则。


3
是的,但实例变量可以在Lambda表达式中被引用和赋值,这让我感到惊讶。只有局部变量存在“final”限制。 - Gerard
@Gerard 因为实例变量具有整个类的作用域。这与匿名类的逻辑完全相同 - 有很多教程解释这种逻辑。 - Boris the Spider

6

在Lambda表达式中,您可以有效地使用周围作用域的变量。有效意味着不必声明变量为final,但请确保您不会在lambda表达式中更改其状态。

您还可以在闭包中使用此功能,并使用“this”表示封闭对象,但不表示Lambda本身,因为闭包是匿名函数,没有与其关联的类。

因此,当您使用封闭类中的任何字段(比如说private Integer i;),该字段未被声明为final并且不是有效final时,仍将起作用,因为编译器代表您执行了技巧并插入了“this”(this.i)。

private Integer i = 0;
public  void process(){
    Consumer<Integer> c = (i)-> System.out.println(++this.i);
    c.accept(i);
}

5

以下是一个代码示例,我也没有预料到这一点,我原以为无法修改lambda外部的任何内容

 public class LambdaNonFinalExample {
    static boolean odd = false;

    public static void main(String[] args) throws Exception {
       //boolean odd = false; - If declared inside the method then I get the expected "Effectively Final" compile error
       runLambda(() -> odd = true);
       System.out.println("Odd=" + odd);
    }

    public static void runLambda(Callable c) throws Exception {
       c.call();
    }

 }

输出: 奇数=true


4

是的,您可以更改实例的成员变量,但是您不能像处理变量时那样更改实例本身。

就像这样提到的:

    class Car {
        public String name;
    }

    public void testLocal() {
        int theLocal = 6;
        Car bmw = new Car();
        bmw.name = "BMW";
        Stream.iterate(0, i -> i + 2).limit(2)
        .forEach(i -> {
//            bmw = new Car(); // LINE - 1;
            bmw.name = "BMW NEW"; // LINE - 2;
            System.out.println("Testing local variables: " + (theLocal + i));

        });
        // have to comment this to ensure it's `effectively final`;
//        theLocal = 2; 
    }

限制 本地变量 的基本原则涉及 数据和计算的有效性

如果由第二个线程评估的 lambda 被赋予了改变本地变量的能力。即使是从不同的线程读取可变本地变量的值的能力,也会引入需要进行同步或使用volatile以避免读取陈旧数据的必要性。

但我们知道lambda表达式的主要目的

在 Java 平台上,它们使将集合的处理分布到多个线程上更容易。

与局部变量不同,局部实例可以被改变,因为它在全局范围内是共享的。我们可以通过堆栈差异更好地理解这一点:

每当创建一个对象时,它总是存储在堆空间中,而堆栈内存包含对它的引用。堆栈内存仅包含局部原始变量和指向堆空间中对象的引用变量。

因此,总结起来,有两点我认为非常重要:

  1. 很难使实例有效地成为最终,这可能会带来很多无意义的负担(想象一下深度嵌套的类);

  2. 实例本身已经在全局范围内共享,lambda也可以在线程之间共享,因此它们可以正确地协同工作,因为我们知道我们正在处理突变并希望传递这种突变;

平衡点很清楚:如果你知道自己在做什么,就可以轻松地完成,但如果不知道,则默认限制将有助于避免隐蔽的错误。

P.S. 如果实例变异需要同步,则可以直接使用流规约方法,如果实例变异存在依赖问题,则仍然可以在Function中使用thenApplythenCompose进行mapping或类似的方法。


0

首先,本地变量和实例变量在幕后的实现方式有关键区别。实例变量存储在堆中,而本地变量存储在栈中。 如果 Lambda 直接访问本地变量并且 Lambda 在线程中使用,则使用 Lambda 的线程可能会尝试在分配变量的线程释放变量后访问该变量。

简而言之:为了确保另一个线程不会覆盖原始值,最好提供对副本变量的访问权限,而不是原始变量。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接