Go 1.22 可能将改变 for 循环变量的语义
几乎世界上每个 Golang 程序员都踩过一遍 for 循环变量的坑,而这个坑的解决方案已经作为实验特性加入到了 Go 1.21 中,并且有望在 Go 1.22 中完全开放。
举个例子,有这么段代码:
var ids []*int
for i := 0; i < 10; i++ {
ids = append(ids, &i)
}
for _, item := range ids {
println(*item)
}
可以试着在 playgound 里面运行下:go.dev/play/p/O8MVGtueGAf
答案是:打印出来的全是 10。
这个结果实在离谱。原因是因为在目前 Go 的设计中,for 中循环变量的定义是 per loop 而非 per iteration。也就是整个 for 循环期间,变量 i
只会有一个。以上代码等价于:
var ids []*int
var i int
for i = 0; i < 10; i++ {
ids = append(ids, &i)
}
同样的问题在闭包使用循环变量时也存在,代码如下:
var prints []func()
for _, v := range []int{1, 2, 3} {
prints = append(prints, func() { fmt.Println(v) })
}
for _, print := range prints {
print()
}
根据上面的经验,闭包 func 中 fmt.Println(v)
,捕获到的 v
都是同一个变量。因此打印出来的都是 3。
在目前的 go 版本中,正常来说我们会这么解决:
var ids []*int
for i := 0; i < 10; i++ {
i := i // 局部变量
ids = append(ids, &i)
}
定义一个新的局部变量, 这样无论闭包还是指针,每次迭代时所引用的内存都不一样了。
这个问题其实在 C++ 中也同样存在: wandbox.org/permlink/Se5WaeDb6quA8FCC。
但真的太容易搞错了,几乎每个 Go 程序员都踩过一遍,而且也非常容易忘记。即使这次记住了,下次很容易又会踩一遍。
甚至知名证书颁发机构 Let’s Encrypt 就踩过一样的坑 bug#1619047。代码如下:
// authz2ModelMapToPB converts a mapping of domain name to authz2Models into a
// protobuf authorizations map
func authz2ModelMapToPB(m map[string]authz2Model) (*sapb.Authorizations, error) {
resp := &sapb.Authorizations{}
for k, v := range m {
// Make a copy of k because it will be reassigned with each loop.
kCopy := k
// 坑在这里
authzPB, err := modelToAuthzPB(&v)
if err != nil {
return nil, err
}
resp.Authz = append(resp.Authz, &sapb.Authorizations_MapElement{Domain: &kCopy, Authz: authzPB})
}
return resp, nil
}
在这个代码中,开发人员显然是很清楚这个 for 循环变量问题的,为此专门写了一段 kCopy := k
。但是没想到紧接着下一行就不小心用了 &v
。
因为这个 bug,Let’s Encrypt 为此召回了 300 万份有问题的证书。
对现有程序的影响
Go 团队目前的负责人 Russ Cox 在 2022 年 10 月份的这个讨论 discussions/56010 里面,提到要修改 for 循环变量的语义,几乎是一呼百应。今年五月份,正式发出了这个提案proposal#60078。
在今年 8 月份发布的 Go 1.21 中已经带上了这个修改。只要开启 GOEXPERIMENT=loopvar
这个环境变量,for 循环变量的生命周期将变成每个迭代定义一次。
但毫无疑问,这是个 break change。如果代码中依赖了这个 for 循环变量是 per loop 的特性,那升级之后就会遇到问题。例如以下代码:
func sum(list []int) int {
m := make(map[*int]int)
for _, x := range list {
// 每次 & x 都是一样,因此一直追加写同一个元素
m[&x] += x
}
// 这个 for 循环只会执行一次,因为 m 的长度一定是 1
for _, sum := range m {
return sum
}
return 0
}
另外,对于程序性能也会有轻微影响, 毕竟新的方案里面将重复分配 N 次变量。对于性能极其敏感的场景,用户可以自行把循环变量提到外面。
同样的改变在 C# 也发生过,并没有出现大问题。
这个方案预计最早在 Go 1.22 就会正式开启了。按照 Go 每年发两个版本的惯例,在 2024 年 2 月份,我们就可以正式用上这个特性,彻底抛弃 x := x
的写法 ~
本文主要内容汇总自 go/wiki/LoopvarExperiment 和 proposal#60078