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/LoopvarExperimentproposal#60078