破解Kotlin—闭包的实现
发表于更新于
字数总计1.2k阅读时长4分钟阅读量
Java限制的原因
首先我们先写一段代码:
1 2 3 4 5 6 7 8 9 | public class Main { public static void main(String[] args) { int tmp = 10; Supplier<Integer> lambda = () -> tmp = 0; System.out.println(tmp.get()); } }
|
这段代码看起来好像没什么问题,但是却根本无法编译,编译器会告诉我们“lambda 表达式中使用的变量应为 final 或有效 final”。
为什么会有这样的限制呢?
其实这是为了“安全”考虑,在继续了解之前,我们要先了解一下在JVM中,变量都存储在哪些地方。
为了简化模型,我们假设JVM讲内存分为两个区域:堆和栈。在JVM中,所有对象(不考虑虚拟机优化)都存储在堆中,类级别的变量(声明在类中的)也跟随对象一同存储在堆中,在堆栈中声明的变量(包含基础类型和对象引用)存储在栈中。例如下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 | class Test { private int example0 = 10; private Object example1 = new Object(); public static void main(String[] args) { int example2 = 5; Test example3 = new Test(); int[] example4 = new int[5]; } }
|
读者不妨先思考一下,这些数据哪些存储在堆中、哪些存储在栈中。
显而易见,example2
和example3
与example4
的引用均存储在栈中,其余全部在堆中。
接下来我们还需要了解JVM的内存管理机制,想要详细了解的可以见:《了解虚拟机之垃圾回收》。
简而言之,就是栈中的数据会随着方法的结束随栈帧一同被释放,而堆中的数据会等待GC去回收。
现在,我们再写一段代码:
1 2 3 4 5 6 7 8 9 10 | public class Main { private Supplier<Integer> lambda; public void text() { int tmp = 10; lambda = () -> tmp = 0; } }
|
有些小伙伴可能已经看出问题了,编译器无法保证lambda
仅在text
中被访问,如果在text
中初始化lambda
,但是在text
结束后再调用lambda.get()
,那么此时tmp
到底是什么呢?这是无法确定的,因为其所在内存已经被释放了,这个tmp
就指向了一段不存在的内存。
简单总结一下就是编译器无法保证lambda执行时其所引用的局部变量是否被销毁,因此如果允许这种操作,就非常容易出现各种奇奇怪怪的问题。
Kotlin的解决方案
那么KT中为什么又允许这种操作呢?到底是如何做到的?我们来看一段代码:
1 2 3 4 5 6 | fun main() { var tmp = 10 val lambda = { tmp = 0 } lambda() println(tmp) }
|
这段代码不仅能够正常编译,并且可以正常运行。这里是tmp
仍然存在的情况,如果lambda
执行时tmp
已经不存在了会怎么样呢?是否会报错?再看一段代码:
1 2 3 4 5 6 7 8 9 | fun main() { val lambda = test() lambda() }
fun test(): () -> Unit { var tmp = 10 return { tmp = 0 } }
|
这段代码同样可以正常编译和运行。
现在我们来揭开这段代码背后的秘密:
1 2 3 4 5 6 | fun main() { var tmp = 10 val lambda = { tmp = 0 } lambda() println(tmp) }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | public final class MainKt { public static final void main() { final Ref.IntRef tmp = new Ref.IntRef(); tmp.element = 10; Function0 lambda = (Function0)(new Function0() { public Object invoke() { this.invoke(); return Unit.INSTANCE; }
public final void invoke() { tmp.element = 0; } }); lambda.invoke(); int var2 = tmp.element; System.out.println(var2); }
public static void main(String[] var0) { main(); } }
|
这时我们可以发现,Kt实际上就是使用了一个语法糖,将tmp
的值封装进了一个容器中,然后lambda
表达式内部通过访问对象来访问tmp
的值。
通用解决方案
到这里一切谜团就解开了,实际上我们在使用Java进行开发时,解决这种问题一般也是采用这样的用法,这里我们列出几个解法:
Atom包装
Java中创建了一系列Atomic类,这些类的目的是提供值的原子操作,但是也能用来当作容器使用:
1 2 3 4 5 6 7 8 9 10 | public class Main { public static void main(String[] args) { AtomicInteger tmp = new AtomicInteger(10); Supplier<Integer> lambda = () -> tmp.set(0); lambda.get(); System.out.println(tmp.get()); } }
|
自行创建容器类
Atomic多多少少会有一点性能损失,实在介意的话也可以自己编写一个容器类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | final class IntWrapper { private int value; public IntWrapper() {} public IntWrapper(int value) { this.value = value; } public int get() { return value; } public void set(int value) { this.value = value; } }
|
数组包装
数组包装是临时写起来比较方便的用法,但是容易混淆代码意图,不建议使用:
1 2 3 4 5 6 7 8 9 10 | public class Main { public static void main(String[] args) { int[] tmp = {10}; Supplier<Integer> lambda = () -> tmp[0] = 0; lambda.get(); System.out.println(tmp[0]); } }
|
破解Kotlin—闭包的实现空 梦 | 山岳库博
更新于 2022-08-05
发布于 2022-08-05