阅读本文大概需要 6 分钟。
Go 语言是一种简洁、高效、并发友好的编程语言,自 2009 年发布以来,已经吸引了众多开发者的关注和使用。然而,Go 语言也不是完美的,它有一些设计上的缺陷或者不足,其中之一就是循环变量作用域问题。
循环变量作用域问题是指在 Go 语言中,使用 :=
声明的循环变量(如 for i := 0; i < 10; i++
或 for _, v := range slice
中的 i
和 v
)只有一个实例,而不是每次迭代都创建一个新的实例。这会导致循环变量在不同迭代中被意外地共享,从而引发一些难以察觉和调试的错误。
举个例子,下面这段代码的目的是将一个整数切片中的每个元素的地址存入一个指针切片中:
var ids []*int
for i := 0; i < 10; i++ {
ids = append(ids, &i)
}
看起来没什么问题,但实际上这段代码有一个 bug。当这个循环执行完毕后,ids
中包含了 10 个相同的指针,每个指针都指向 i
的值,而 i
的值最终为 10。这是因为 i
变量是整个循环共享的,而不是每次迭代都创建一个新的 i
。&i
在每次迭代中都是相同的地址,而 i
的值在每次迭代中都被覆盖。
通常的解决方法是在循环体内部再声明一个局部变量,将循环变量的值赋给它,然后使用这个局部变量:
var ids []*int
for i := 0; i < 10; i++ {
i := i // 新增这一行
ids = append(ids, &i)
}
这样就可以保证每次迭代都有一个独立的 i
变量,并且其地址也不同。
这个问题也经常出现在使用闭包(closure)捕获循环变量的情况下,例如:
var prints []func()
for _, v := range []int{1, 2, 3} {
prints = append(prints, func() { fmt.Println(v) })
}
for _, print := range prints {
print()
}
这段代码的预期输出是:
1
2
3
但实际输出是:
3
3
3
这是因为所有的闭包都打印同一个 v
变量,而在循环结束时,v
的值被设置为 3。注意这里没有显式地使用 &v
来表示潜在的问题。同样地,解决方法是在循环体内部添加 v := v
。
另一个常见的情况是在使用 t.Parallel
的子测试中:
func TestAllEvenBuggy(t *testing.T) {
testCases := []int{1, 2, 4, 6}
for _, v := range testCases {
t.Run("sub", func(t *testing.T) {
t.Parallel()
if v&1 != 0 {
t.Fatal("odd v", v)
}
})
}
}
这个测试会通过,因为所有四个子测试都检查了 6(最后一个测试用例)是否为偶数。如果我们改变测试用例的顺序或者增加一个奇数测试用例,就会发现测试失败了。
循环变量作用域问题可以说是 Go 语言中最常见和最容易犯的错误之一。几乎每个 Go 程序员都曾经在某个程序中遇到过这个问题。即使是 Go 语言的设计者和贡献者也不例外。例如,在标准库中就有过多次因为这个问题而导致 bug 或者修复 bug 的提交¹²³ 。
这个问题之所以难以避免和发现,有以下几个原因:
针对循环变量作用域问题,Go 社区已经提出了多种改进方案。其中最新和最具影响力的方案是由 David Chase 和 Russ Cox 在 May 2023 提出的《Proposal: Less Error-Prone Loop Variable Scoping》。
该方案提出了将使用 :=
声明的循环变量从每个循环只有一个实例改为每次迭代都创建一个新实例。这样就可以消除不同迭代之间意外共享循环变量的情况,从而修复 #20733 这个 issue。
该方案具体地描述了改进后循环变量作用域问题的语法和语义规则,并给出了一些示例代码来说明改进前后的区别。该方案还分析了改进后可能带来的影响和风险,并提出了一些缓解措施和建议。
该方案目前还处于讨论阶段,并没有确定是否会被采纳和实施。如果被采纳,可能会在未来某个版本(例如 Go 1.22)中生效,并且只对显式声明了新版本兼容性标志(例如 Go1.22)的模块有效。
改进后循环变量作用域问题的优点是显而易见的,它可以避免很多因为意外共享循环变量而导致的 bug,让 Go 语言更加简洁、安全和一致。例如,上面提到的几个例子,在改进后都可以正常工作,而不需要额外的 i := i
或 v := v
语句。
改进后循环变量作用域问题的缺点是可能会引入一些向后不兼容的变化,导致一些已有的代码在新版本中无法编译或者运行。例如,如果有人故意利用了循环变量只有一个实例的特性,来实现一些特殊的逻辑,那么在改进后,这些代码就会失效。例如:
var ids []*int
for i := 0; i < 10; i++ {
ids = append(ids, &i)
if i == 5 {
i = 8 // 跳过 6 和 7
}
}
这段代码在改进前会输出:
0 1 2 3 4 5 8 9
但在改进后会输出:
0 1 2 3 4 5 6 7 8 9
这种情况虽然不常见,但也不是完全不存在。因此,该方案建议在实施改进时,要尽量保持对旧版本代码的兼容性,或者至少提供一些明确的错误提示和迁移指导。
另一个可能的缺点是改进后循环变量作用域问题可能会增加内存分配和垃圾回收的开销,因为每次迭代都会创建一个新的循环变量实例。然而,该方案认为这种开销是可以接受的,因为:
循环变量作用域问题是 Go 语言中一个长期存在且广泛影响的问题,它给 Go 程序员带来了不少困扰和麻烦。为了解决这个问题,Go 社区提出了多种改进方案,其中最新和最具影响力的方案是由 David Chase 和 Russ Cox 在 May 2023 提出的《Proposal: Less Error-Prone Loop Variable Scoping》。
Go 官方的提案提出了将使用 :=
声明的循环变量从每个循环只有一个实例改为每次迭代都创建一个新实例。这样就可以消除不同迭代之间意外共享循环变量的情况,从而修复 #20733 这个 issue。该方案还分析了改进后可能带来的影响和风险,并提出了一些缓解措施和建议。
该提案目前还处于讨论阶段,并没有确定是否会被采纳和实施。如果被采纳,可能会在未来某个版本(例如 Go 1.22)中生效,并且只对显式声明了新版本兼容性标志(例如 Go1.22)的模块有效。
我是 polarisxu,北大硕士毕业,曾在 360 等知名互联网公司工作,10多年技术研发与架构经验!2012 年接触 Go 语言并创建了 Go 语言中文网!著有《Go语言编程之旅》、开源图书《Go语言标准库》等。
坚持输出技术(包括 Go、Rust 等技术)、职场心得和创业感悟!欢迎关注「polarisxu」一起成长!也欢迎加我微信好友交流:gopherstudio