循环体中局部变量的小坑

前几晚看《The Go Progromming Language》时,在 匿名函数 的最后一小节中,发现了一个比较有趣的小坑。 大概的场景为:首先创建一些目录,然后对于每一个目录,分别声明一个匿名函数删除目录,样例代码如下:

1
2
3
4
5
6
7
var rmdirs []func()
for _, dir := range tempDirs() {
    os.MkdirAll(dir, 0755)
    rmdirs = append(rmdirs, func() {
        os.RemoveAll(dir) 
    })
}

大家可以先自行考虑一下,有没有什么问题,效果会是怎样

警告:捕获迭代变量

(颇为中二的标题名字是从书上搬过来的

上面的代码是有问题的,运行后rmdirs中每一个函数的效果都是删除最后一个目录。

为什么没有出现我们预期的效果呢?原因在于循环变量的作用域。

在上面的程序中,for 循环语句引入了新的词法块,循环变量 dir 在这个词法块中被声明。在该循环中生成的所有函数值都共享相同的循环变量。需要注意,函数值中记录的是循环变量的内存地址,而不是循环变量某一时刻的值。以 dir 为例,后续的迭代会不断更新 dir 的值,当删除操作执行时,for 循环已完成,dir 中存储的值等于最后一次迭代的值。这意味着,每次对 os.RemoveAll 的调用删除的都是相同的目录。

正确的代码应该如下修改,通过在块级中声明一个局部临时变量,将其代替循环变量放入匿名函数中。

1
2
3
4
for _, dir := range tempDirs() {
    dir := dir // declares inner dir, initialized to outer dir
    // ...
}

为什么这个修改是可行的?原因很简单,我们在匿名函数内部使用的是一个局部的临时变量,因为是临时的,块级代码结束时,变量地址指向内容很可能要回收,所以不能存储地址,只能够存储变量的值。 那为什么前者的代码匿名函数值中记录的就是变量的内存地址?循环变量不也属于这个词法块吗?还真不太一样。循环变量虽说也是由循环词法块被声明,但是相对于词法块内的代码,它实质上是一个全局变量的地位,它对于代码块的每一次执行都是一样的地位,一样的地址,所以实际存储的是变量的地址。

JavaScript 中的循环变量

当时看到书,我第一时间就想到了 JavaScript 中也有着同样的情况,我之前还遇到过类似的问题:给一系列的控件绑定事件触发函数,每个函数中根据循环变量设定条件,然后出现了相似的问题。当时采用的解决方法是提到了参数处理的,治标不治本。

看到这里后,我马上就用 JavaScript 复现了这个问题

1
2
3
4
5
arr = []
for(i=0;i<5;i++){
    arr.push(()=>{console.log(i)})
}
arr.forEach((f)=>{f()})

结果就如同预期一样,输出了 5 个 4,没有达到预期。然后用跟在 Go 一样的思路,在循环体内部中使用一个临时局部变量代替,运行后出现预期效果。

1
2
3
4
5
6
arr = []
for(i=0;i<5;i++){
    let j=i
    arr.push(()=>{console.log(j)})
}
arr.forEach((f)=>{f()})

注意到这里用的是let而不是var。使用 let 语句声明一个变量,该变量的范围限于声明它的块中。