一、前言
闭包很早就有,最先接触的可能是前端同学(JavaScript)中对闭包的定义:
函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起构成闭包。
其中包括两个要点:
- 函数
- 周围环境(状态 & 上下文)
很多语言都把函数作为『一等公民』,Kotlin也不例外:
函数是“一等公民”的意思是,函数和变量一样,它是某种类型的实例,可以被赋值,可以被引用。
函数在Kotlin中主要包括三种形式:普通的具名函数
,匿名函数
,lambda表达式
。
周围环境可以理解为函数所处的外部作用域中定义的各个自由变量,这个作用域可能是另一个函数或块级作用域。
二者捆绑在一起构成的闭包使得函数内部可以对外部作用域定义的变量进行访问。
二、闭包(Java)
2.1、Java中不完整的闭包
protected void onCreate(Bundle savedInstanceState) {
...
int num = 0;
btn.setOnClickListener(v -> {
num++;//报错
});
}
上面的写法会被IDE提示:Variable used in lambda expression should be final or effectively final
。
因为根据Java规定:匿名内部类内部,方法或块级作用域内的具名内部类内部使用的外部变量必须是final的。
为什么会有这种规定呢?
因为对于Java闭包中的函数而言,周围环境(外部函数或块级作用域)在执行到结束时就会销毁,这里的周围环境对应与虚拟机栈中的一个栈帧。
而栈帧中的局部变量,在方法返回后就会被虚拟机回收。
正因为如此,Java中的闭包其实并不完整:
从闭包的定义来理解,Java函数中对周围环境中变量的引用无法阻止周围环境的销毁。从Java闭包的实现来看,这种对周围环境中变量的引用只是一种假象,编译器会对使用到的周围环境中的变量拷贝一份副本,而函数内部操作的只是不可见的副本而已。对这份不可见副本的修改不仅毫无意义,还会引起歧义,误以为周围环境中的变量真的被修改。
因此,Java干脆从语言层面禁止了这种修改,也就有了上述规定。
所以,Java中函数不是『一等公民』!
2.2、Java中闭包的变相实现
在 Java 中,匿名类就是代替闭包而存在的!只不过 Java 严格要求所有函数都需要在类里面,所以巧妙的把“声明一个函数”这样的行为变成了“声明一个接口”或“重写一个方法”。匿名类也可以捕获当前环境的 final 局部变量。但和闭包不一样的是,匿名类无法修改捕获的局部变量(final 不可修改)。
Java8 lambda 是进一步接近闭包的特性,lambda 的 JVM 实现是类似函数指针的东西。
Java7 的 lambda 语法糖兼容不是真正的 lambda,它只是简化了匿名类的书写。
同样的 lambda 也只能引用 final 变量。
2.3、绕过 Java 语法限制
这种因Java中不完整闭包导致的限制在实际开发中有诸多不便,因此常见到长度为1的数组这种技巧绕过限制:
protected void onCreate(Bundle savedInstanceState) {
...
int[] num = new int[]{1};
btn.setOnClickListener(v -> {
num[0]++;//正确
});
}
因为数组对象被分配到堆内存中,原来函数所需的,外部环境中的存在于栈帧中的变量到了堆内存中,会因函数内部引用而不被销毁。
实际上,Java中这种会被内部函数所修改的变量,更多时候我们会把它作为一个类的属性,而不是放在函数或块作用域的内部而受到栈帧出栈的影响:
int num = 0;
protected void onCreate(Bundle savedInstanceState) {
...
btn.setOnClickListener(v -> {
num++;//正确
});
}
换个角度看,如果我们把闭包中周围环境(外部函数或块级作用域)的概念扩展一下,把类中定义的属性也做为函数所处的周围环境的话,那么类本身就可以认为是一个广义的闭包。
三、闭包(Kotlin)
对于Kotlin来说,就不存在 Java 的一些限制(需要声明外部变量为 final ),其实现是将函数使用到的周围环境中的变量,包装为一个新类中的属性!
override fun onCreate(savedInstanceState: Bundle?) {
...
var num = 0;
btn.setOnClickListener {
num++ //正确
}
}
使用 jadx 反编译查看(等价于 Java 中的如下代码):
protected void onCreate(Bundle savedInstanceState) {
...
final Ref.IntRef num = new Ref.IntRef();
num.element = 0;
btn.setOnClickListener(v -> {
num.element++;
});
}
函数中访问的外部环境中的变量,都会用Ref中定义的各种类型的包装类进行包装,对其的访问会进行自动的装箱拆箱工作。与长度为1的数组这种技巧一样,将外部环境中的变量引用的位置由栈移入的堆中。
如果我们在一个 kt 文件中编写如下代码:
fun returnFun(): () -> Int {
var count = 0
return { count++ }
}
fun main() {
val function = returnFun()
val function2 = returnFun()
println(function()) // 0
println(function()) // 1
println(function()) // 2
println(function2()) // 0
println(function2()) // 1
println(function2()) // 2
}
分析上面的代码,returnFun返回了一个函数,这个函数没有入参,返回值是Int。
我们可以用变量接收它,还可以调用它。function和function2分别是我创建的两个函数实例。
可以看到,我每调用一次function(),count都会加一,说明count 被function持有了而且可以被修改。
而function和function2的count是独立的,不是共享的。
用 jadx 反编译查看:
public final class ClosureKt {
@NotNull
public static final Function0<Integer> returnFun() {
IntRef intRef = new IntRef();
intRef.element = 0;
return (Function0) new 1<>(intRef);
}
public static final void main() {
Function0 function = returnFun();
Function0 function2 = returnFun();
System.out.println(((Number) function.invoke()).intValue());
System.out.println(((Number) function.invoke()).intValue());
System.out.println(((Number) function2.invoke()).intValue());
System.out.println(((Number) function2.invoke()).intValue());
}
}
被闭包引用的 int 局部变量,会被封装成 IntRef 这个类。这个 IntRef 里面保存着 int 变量,原函数和闭包都可以通过 intRef 来读写 int 变量。
Kotlin 正是通过这种办法使得局部变量可修改。除了 IntRef,还有 LongRef,FloatRef 等,如果是非基础类型,就统一用 ObjectRef 即可。
四、总结
通过以上我们可以总结如下:
- 闭包不是新东西,是把函数作为“一等公民”的编程语言的特性;
- 匿名类是 Java 世界里的闭包,但有局限性,即只能读 final 变量,不能写任何变量;
- Kotlin 的闭包可以捕获上下文的局部变量,并修改它。实现办法是 Kotlin 编译器给引用的局部变量封装了一层引用。
网友评论