Loading

Lambda与final变量

前言

最近在学Rust,今天突然想到一段Java代码

public class Main {
    public static void main(String []args) {
		int a = 16;
		new Thread(() -> {
            System.out.println(a);
		}).start();
    }
}

这段代码能正确通过编译、运行,并输出16。在学Rust前,这是一个很自然的事,但现在我却完全想不通为啥。

首先,我们知道当你创建了一个线程,它会另开一个调用栈,我们也知道,变量a会被存在方法main的栈中,从另一个栈,如何访问到main的栈帧中的变量呢?

后来我又想到,Java中在lambda表达式和匿名内部类中,如果访问外部变量,则它必须是final的,或者effectively final的,就是代码中它一定不会被改变。下面的例子无法通过编译:

public class Main {
    public static void main(String []args) {
		int a = 16;
        a = 17; // 修改a,a不是effectively final了
		new Thread(() -> {
            // 无法在lambda表达式中使用非final或非effectively final变量
            System.out.println(a);
		}).start();
    }
}

为什么一定限制是final呢?我记得之前学Java的时候,这里就模棱两可,也很少有文章把它解释的真正清楚。今天,我在网络上搜索时,找到了一篇叫:Lambda and final variables的文章,它应该把这件事儿讲清楚了。下面,就是这篇文章的翻译。

介绍

Lambda表达式可以使用其自身所在作用域中的变量,但是,这些变量必须得是final或effectively final的,原因是什么呢?为什么会这样?这是一个有趣的问题,因为它的答案并不明显。

有且只有一个终极答案,那就是:因为这就是Java语言规范所定义的。但是如果你只是这样就满足了,那太无聊了,我更喜欢的答案是,因为lambda并不是闭包。

在下面的内容中,我将讨论什么是final以及effectively final,讨论闭包和lambda的区别,最后,我将讨论我们如何在Java中使用lambda创建闭包。我并不提倡在Java中创建基于lambda表达式的闭包,也不提倡放弃这个想法。

final以及effectively final

当我们使用final关键字定义一个局部变量时,它就是final的。编译器将会要求该变量只能得到一次值,赋值可以发生在定义处,也可以稍晚一些。可以有多个向final变量赋值的行,只要在每次方法调用中最终只有一个实际执行即可。典型的示例是当你定义了一个final变量,但你没有为它赋值,在稍后,有一个if语句在thenelse分支中分别给它赋予不同的值。

不用我说,在创建lambda之前,变量必须被初始化。

如果一个变量不是final,但满足final变量的要求,那么它就是effectively final。它在定义处获取一个值,或者唯一一次获取一个指定值。

Lambda的一生

lambda表达式是一种匿名内部类,但是JVM对它的处理有些不同,它比匿名内部类高效一些,而且它的可读性更强。不管怎么说,从我们的视角来说,我们可以将它想成一个匿名内部类。

public class Anon {

    public static Function<Integer, Integer> incrementer(final int step) {
        return (Integer i) -> i + step;
    }
    public static Function<Integer, Integer> anonIncrementer(final int step) {
        return new Function<Integer, Integer>() {
            @Override
            public Integer apply(Integer i) {
                return i + step;
            }
        };
    }
}

当lambda表达式被创建,JVM创建一个lambda类的实例来实现Function接口。

var inc = Anon.incrementer(5);
assertThat(inc.getClass().getName()).startsWith("javax0.blog.lambdas.Anon$Lambda$");
assertThat(inc.getClass().getSuperclass().getName()).isEqualTo("java.lang.Object");
assertThat(inc.getClass().getInterfaces()).hasSize(1);
assertThat(inc.getClass().getInterfaces()[0]).isEqualTo(java.util.function.Function.class);

JVM会将对象放置在堆上,在某些情况下,编译器可能会识别到对象不会超出方法作用域,在这种情况下,它可能将该对象存储在栈上,我们称这一手段为局部变量逃逸分析,它可以将任何对象放置在栈上,这些对象不能从方法中逃逸,并且会随着方法的返回而消亡。然而,在我们的讨论中,我们可以忽略这个Java环境的高级特性。

lambda在方法中被创建并且存储在堆中,只要有一个引用指向它,它就会一直存在并且不会被回收。如果一个lambda表达式可以引用并使用一个存在于栈中的局部变量,就代表它需要访问一些在方法返回后已经消失的东西,这是不可能的。

这个矛盾有两个解决办法,一个是Java使用的,创建一个该变量值的拷贝,另一个是创建一个闭包。

闭包和Groovy

在谈闭包时,我们将看一个Groovy的例子。选择Groovy的原因是它与Java十分接近。我们会看一个Groovy的例子,为了展示,我们将尽可能的使用Java风格。Groovy或多或少的兼容Java,Java代码可以直接作为一个Groovy源文件被编译,然而实际的语法可能还会有一些细微的差别。

Groovy通过创建一个闭包来解决这个局部变量的访问问题。闭包将功能和环境封闭到一个单一对象中,比如,下面的Groovy代码

class MyClosure {
    static incrementer() {
        Integer z = 0
        return { Integer x -> z++; x + z }
    }
}

创建了一个闭包,这很像我们的lambda表达式,它也使用了局部变量z,这个局部变量不是final的,也不是effectively final的,而这里实际发生的是编译器创建了一个类,该类中包含闭包中使用的每一个局部变量,对于每一个变量,以一个属性的形式存在于这个类中。一个新的局部变量引用了一个引用这个新类的一个实例,并且该局部变量使用这个对象和其中的属性的所有引用。这个伴随着“lambda表达式”的对象,就是闭包。

译者:我感觉作者的意思是,每次该方法被调用,一个新的局部变量z就会产生(或者在其它例子中可能是一批新的局部变量),这导致一个类的实例被编译器创建,而这个或者说这批局部变量作为这个类实例中的属性。也许看了后面用Java模拟Groovy编译器的行为那里就理解了。

因为这个对象在堆中,所以只要有一个引用指向它,它就会一直存活。所以只要闭包存活,该对象就是可用的。

这里原文如下:

The object, which holds the described function has one, so this object will be available so long as long the closure is alive.

前半句真没看懂是谁hold谁...索性去掉了

def inc = MyClosure.incrementer();
assert inc(1) == 2
assert inc(1) == 3
assert inc(1) == 4

在测试的执行中很清晰的展示了闭包在每次执行时增加了z的数量。

闭包是具有状态的lambda

Java中的Lambda

Java处理这个问题的方式有些不同,它不创建一个新的合成对象去持有被引用的局部变量,它只是简单的使用变量的值。Lambda看起来是使用了变量,实际上它没有,它只是使用了该变量值的常量拷贝。

当你设计lambda时,有两个选择,我不是决策团队的一员,所以我在这儿写下的东西仅代表我的观点与猜想,但是这可能帮助你理解为什么Java要做这样的决定。一个选择是当lambda创建时复制变量的值,而不关心稍后局部量的改变。这可行吗?毋庸置疑!但是这可读性好吗?在很多情况下并不。如果变量在后面改变了会怎样?lambda会使用改变后的值吗?不,它将使用它复制的那份,已经冻结的值,这和在通常情况下变量如何工作背道而驰。

Java通过需要变量是final或effectively final的来解决这个问题,当使用Lambda时,有不同变量值的这种令人担忧的情况被避免了。

当设计一个语言的元素时,总是要有很多方面的权衡,在一方面,一些构造可能给部分开发者带来更强大的能力,然而,能力越大,责任越大。很多开发者并不足以成熟到能够承受这份责任。

天平的另一端,更简单的构造提供了更少的功能,这可能不能够很优雅的解决一些问题,但也让你无法很轻易地创建出不可读的代码。Java总是走这条路。几乎是在有C语言开始,一场令人疑惑的C竞赛就出现了。谁能用C编写可读性差到爆的代码?从那以后,几乎所有的语言都开始了这种竞赛,除了Java和Perl。在Java中,这个比赛是枯燥的,因为你不能用Java编写模糊的代码,而在Perl中,这种比赛毫无意义。

Java中的闭包

如果你在Java中需要一个闭包,你可以自己创建一个。一个很好的老办法是使用匿名类或者常规类,另一个办法是模拟Groovy编译器的行为,创建一个类来捕获闭包数据。

Groovy编译器为你创建了类来装入局部变量,但是没有什么能阻止你在Java里手动做这件事,如果你想的话。你得做一些重复的工作,移动每个闭包所使用的局部变量到一个类中,作为一个实例属性。

public static Function<Integer, Integer> incrementer() {
    AtomicInteger z = new AtomicInteger(0);
    return x -> {
        z.set(z.get() + 1);
        return x + z.get();
    };
}

在我们的例子中,我们只有一个局部变量z。我们需要一个类来持有一个int,而这个类就是AtomicInteger。实际上该类还或作很多其它的事,它通常在并发执行是一个问题时使用。为此,一些额外花销会轻微的影响性能,我们先忽略。

如果有多于一个局部变量,我们需要为它们创建一个类:

public static Function<Integer, Integer> incrementer() {
    class DataHolder{int z; int m;}
    final var dh = new DataHolder();
    return x -> {
        dh.z++;
        dh.m++;
        return x + dh.z*dh.m;
    };
}

在这个例子中可以看到,为了代码的内聚性,我们甚至可以在一个方法里定义类,这是一个好位置。最终,很容易看出这种方法是有效的。

然而,如果你想用这种方法的话,你应该先仔细想想,Lambda通常应该是无状态的。而当你需要有状态的lambda时,换句话说,当你需要闭包时,Java并不直接支持闭包,所以您应该使用类。

总结

  • 这篇文章讨论了为什么lambda表达式只能访问final和effectively final的局部变量
  • 我们也讨论了不同的语言如何达到这一需求,并且讨论了原因
  • 最后,我们看了Groovy的一个例子,以及如何用Java模拟它

所以,如果任何人问你这个面试问题,为什么一个lambda表达式只能访问final和effectively final的局部变量,你已经知道答案了!因为Java语言定义里就是这么规定的,你也说不听它。(其它一切都是猜测)

你可以在这里找到文章的中的代码。

posted @ 2022-12-29 20:30  yudoge  阅读(180)  评论(1编辑  收藏  举报