本系列文章以我的个人博客的搭建为线索(GitHub 仓库:Evian-Zhang/evian-blog),记录我在现代化程序设计中的一些笔记。在这篇文章中,我将讨论的是现代语言中的闭包。
函数起名之痛
在现代语言中,函数是一等公民的思想几乎贯彻在了每一个编程语言中,函数应该和变量一样,能够自由地作为参数和返回值在函数间传递。比方说,一个游戏的开发者在写代码的时候发现,有好几种怪物的代码都极其类似,但只有其进行打斗的样子不同。因此,开发者将这几个怪物的代码封装成同一个函数monster
,那么怎样区别这几种怪物打斗的形态呢?只能传递其打斗的函数进去了。
拿Swift为例,在没有闭包之前,开发者可以这么写:
// monster 1
func monster1_fight(with hero: Hero) { }
// monster 2
func monster2_fight(with hero: Hero) { }
func foo() {
let monster1 = monster(monster1_fight)
let monster2 = monster(monster2_fight)
}
将各自打斗的函数传递给monster
就好了。
后来,为了调试的需要,开发者还写了好多如monster1_fight_and_log
, monster1_fight_without_trigger
等奇奇怪怪的函数,大多都是在monster1_fight_and
函数之前加上一些很简单的调试语句。
func monster1_fight(with hero: Hero) { }
func monster1_fight_and_log(with hero: &Hero) {
// log something...
monster1_fight(with: hero)
}
func foo() {
// let monster1 = monster(monster1_fight)
let monster1 = monster(monster1_fight_and_log)
}
众所周知,困扰全世界开发者的两大问题,一是缓存,二是命名。这些用来调试的函数往往都特别简单,而且有时候就只在某个地方用一次,但是程序员却都要给这些函数起名字,而且还要额外再新增函数,导致函数密密麻麻,调试的时候也不方便。
闭包此时就成了开发者的救星。有了闭包之后,开发者可以这么写:
func monster1_fight(with hero: Hero) { }
func foo() {
let monster1 = monster({ hero in
// log something...
monster1_fight(with: hero)
})
}
也就是说,我们不需要给这个函数命名了,直接在传递函数作参数时,新写一个函数,也就是把闭包作为匿名函数来使用,这也是闭包最初级的做法。
捕获
看上去闭包只是作为一个没有名字的函数出现的,那么它还有什么作用呢?事实上,在大多数语言中,闭包作为匿名函数的时候,它的作用往往都相同。但是,当遇到下面的情况时,各个语言的处理方式不同,也就产生了各种各样语言特色的闭包。
我们单看一个普通的函数
func normal_function() {
// do something
}
它的全部信息是否就是这么多呢?函数的名字,函数的参数,函数的函数体,函数的返回值等等。看上去似乎是的,函数的全部信息似乎就只在这三行里。然而,并不是这样。
我们知道,作为一个普通的函数,它可以操作全局变量:
var a = 1
func normal_function() {
a = a + 1
}
如果单看我们刚刚说的函数的三行,a
这个变量的信息并没有包含在内,相反,它作为全局变量,出现在了函数的外部。但是,它也作为重要的一部分,组成了函数的全部信息。
因此,一个函数其实是由函数本身的记录以及其环境组成的。
那么,作为闭包,下面的情况可否出现呢?
func monster1_fight(with hero: Hero) { }
func foo() {
var a = 1
let monster1 = monster({ hero in
a = a + 1
// log something...
monster1_fight(with: hero)
})
}
作为正常人,这种简单的类比推导肯定是对的,闭包必然要支持这种操作,事实上,这种操作也是确实支持的,被称为闭包捕获外部的变量,外部变量作为环境的一部分,也应成为闭包的一部分。
但是一到捕获,各个语言就会有各自的麻烦了。比如说,我们有以下的伪代码:
function outer() {
variable a
return function {
a = a + 1
}
}
如果变量a
是分配在栈上的,那么在函数outer
返回之后,a
已经被释放了,那么在返回的闭包里,如何改变a
的值呢?此时a
的值应该已经没有意义了。
如果变量a
不是通过栈来释放的,而是通过引用计数来释放的,那么闭包应不应该增加对这个变量的引用计数器呢?
闭包捕获的变量的作用域又是什么呢?
这一切都是很麻烦的事,所以我们一个一个来看。
栈内释放问题
对于Rust这类把变量分配在栈上的语言,可以巧妙地通过所有权的设置来解决这个问题。在Rust中,把闭包分为三个trait:Fn
, FnMut
, FnOnce
, 分别代表闭包捕获的是变量的不可变引用,可变引用,以及直接捕获所有权。通过捕获不可变引用或可变引用,局部变量a
的所有权仍然在outer
函数里,仍然会在离开outer
函数体之后被析构。但是,由于返回的闭包捕获了一个生命周期仅在outer
里的变量,所以编译器会拒绝将这样的闭包返回,产生编译错误。而通过捕获所有权,a
的释放就完全交给了闭包自己来做,也就不会产生这样的问题。
引用计数问题
对于Swift这样的语言,闭包是默认使用强引用的,如果要使用弱引用,则需要在闭包前提前声明,比如说我们之前提到的一个例子:
let client = Client()
class Person {
var book: Book?
func fetch() {
client.fetch(completionHandler: { [weak self] book ->
guard let self = self else { return }
self.book = book
})
}
}
这里的[weak self]
就是提前声明捕获的是弱引用。
而对于Python来说,由于没有强弱引用的问题,lambda
(实际上只是函数的语法糖)只是最自然地捕获了变量,并增加了它的引用计数。
捕获对象作用域问题
这个问题是单独为JavaScript相关的。众所周知,JavaScript有令人惊奇的作用域推断手法,以MDN中的代码为例:
function showHelp(help) {
document.getElementById('help').innerHTML = help;
}
function setupHelp() {
var helpText = [
{'id': 'email', 'help': 'Your e-mail address'},
{'id': 'name', 'help': 'Your full name'},
{'id': 'age', 'help': 'Your age (you must be over 16)'}
];
for (var i = 0; i < helpText.length; i++) {
var item = helpText[i];
document.getElementById(item.id).onfocus = function() {
showHelp(item.help);
}
}
}
setupHelp();
我们会发现,这段脚本运行之后,每一个item
当被focus的时候,竟然都显示的是age
相关的help
。这完全违背了我们的常理。这是为什么呢?这是因为,用var
声明的item
作用域其实不是for
循环的循环体,而是setupHelp
这个函数的作用域,这叫做变量提升。因此,这几个闭包所捕获的,实际都是同一个变量item
,只不过它在每次循环的时候分别被赋值为不同的值,最后停留在了age
对应的item
上。
解决这个问题的方法很简单,使用let
就行了:
function showHelp(help) {
document.getElementById('help').innerHTML = help;
}
function setupHelp() {
var helpText = [
{'id': 'email', 'help': 'Your e-mail address'},
{'id': 'name', 'help': 'Your full name'},
{'id': 'age', 'help': 'Your age (you must be over 16)'}
];
for (var i = 0; i < helpText.length; i++) {
let item = helpText[i];
document.getElementById(item.id).onfocus = function() {
showHelp(item.help);
}
}
}
setupHelp();
let
声明的item
被固定在了for
循环的循环体内,所以就ok了。
第二种奇特的情况,就在于JavaScript的奇葩的this
捕获了:
var foo = {
a: 1,
bar: function() {
this.a += 1;
console.log(this.a);
}
};
var a = 3;
foo.bar(); // prints: 2
var bar = foo.bar;
this.bar(); // prints: 4
bar
作为一个闭包,是foo
这个对象的一个字段。然而,foo.bar()
里,是把this
作为foo
, 而在全局的this.bar()
里,this
又成了全局的window
或global
. 这就是JavaScript匿名函数的一个特点:this
永远捕获的是调用者。对于foo.bar()
这个情况来说,是foo
调用的bar
, 所以在bar
内部,this
就指的是foo
; 类似地,全局调用的话,bar
内部的this
指的自然就是全局的window
或global
了。
让这种看似十分混乱不确定的操作变得确定的方法也有,就是直接使用箭头函数:
var foo = {
a: 1,
bar: () => {
this.a += 1;
console.log(this.a);
}
};
var a = 3;
foo.bar(); // prints: 4
var bar = foo.bar;
this.bar(); // prints: 4
箭头函数没有自己的this
,它只会从自己的作用域链的上一层继承this
。这里,bar
在定义时,它的上一层就是foo
, 而foo
的this
自然就是全局的window
或global
了,因此无论是调用foo.bar()
还是this.bar()
,它内部的this
都是全局的window
或global
.