大多数Go程序员都走过的坑盘点解析
作者:晁岳攀(鸟窝) 鸟窝聊技术
循环变量
说起每个程序员必犯的错误,那还得是"循环变量"这个错误了,就连 Go 的开发者都犯过这个错误,这个错误在 Go 的 FAQ 中也有提到
What happens with closures running as goroutines?[1]:
func main() { var wg sync.WaitGroup values := []string{"a", "b", "c"} for _, v := range values { wg.Add(1) go func() { fmt.Println(v) wg.Done() }() } wg.Wait() }
你可能期望能输出a
、b
、c
这三个字符(可能顺序不同),但是实际可能输出的是c
、c
、c
。这是因为循环变量的作用域是整个循环,而不是单次迭代,所以在循环体中使用的变量是同一个变量,而不是每次迭代都是一个新的变量。
这个错误有时候隐藏很深,即使没有 goroutine,也有可能,比如下面的代码,并没有使用额外的 goroutine 和闭包,也是有问题的:
package main import ( "fmt" ) type Char struct { Char *string } func main() { var chars []Char values := []string{"a", "b", "c"} for _, v := range values { chars = append(chars, Char{Char: &v}) } for _, v := range chars { fmt.Println(*v.Char) } }
输出也大概率是c
、c
、c
,因为给每个Char
的字段赋值的是 v 的指针,v 在整个循环中都是一个变量,所以最后的结果都是c
。
Go 团队很早也意识到这个问题了,但是考虑到兼容的问题,大家的容忍程度,那就这样了。每个 Go 程序员都在这里摔一跤,也就长记性了,所以一直没有改变这个设计。我在这里摔了好多跤,以至于我写 for 循环的时候都战战兢兢的,和 Russ Cox 统计的网上的处理一样,不管有无必要,很多时候我都是先把循环变量赋值给一个局部变量,然后再使用,比如下面的代码:
for _, v := range values { v := v wg.Add(1) go func() { fmt.Println(v) wg.Done() }() }
变量只在循环体中使用
今年 5 月份的时候,Russ Cox 忍不住了,提了一个提案#60078[2],提案的内容是在 for 循环中,如果变量只在循环体中使用,那么就会在每次迭代中创建一个新的变量,而不是使用同一个变量。这个提案引起了很多人的关注,很多人都在讨论这个提案,这个提案被接收了,具体提案内容在文档中Proposal: Less Error-Prone Loop Variable Scoping[3]。
如果你使用 Go 1.21, 你可以开始这个功能,使用GOEXPERIMENT=loopvar go run main.go
运行上面的程序,会输出c
、b
、a
这样的输出,不再是c
、c
、c
了。这个特性在 Go 1.22 中会默认开启,不需要设置GOEXPERIMENT
了。还有一两个月才能正式发布 go 1.22,
大家可以使用 gotip 测试:
$ gotip run main.go a b c
不只是for-range
,下面的3-clause
也是同样的问题:
func main() { var ids []*int for i := 0; i < 3; i++ { i = 10 } for _, id := range ids { fmt.Println(*id) } }
Go 1.22 中也会修复这个问题。
对比C#语言
C#语言就只修改了for-range
语句,3-clause
语句就没有修改, Go 两种都做了修改。
但是, 问题就来了哈,像下面的代码,Go 1.22 和以前的代码会一样么?
func main() { var ids []*int for i := 0; i < 3; i++ { i = 10 ids = append(ids, &i) } for _, id := range ids { fmt.Println(*id) } }
如果用 Go 1.21,它会输出11
。如果用 Go 1.22,它会输出10
。原因还是在于这个提案实现后,每次迭代的时候,都会创建一个新的变量,所以ids
中的元素都是指向不同的变量,而不是同一个变量。
看起来打破了向下兼容的承诺,你如果先前就想利用这个 corner case 的话,Go1.22 已经不兼容了。
更进一步,你会发现再执行3-clause
的第三条 clause 的时候,变量已经被重新创建,比如下面的代码:
func main() { for i, p := 0, (*int)(nil); i < 3; println("3rd-clause:", &i, p) { p = &i fmt.Println("loop body:", &i, p) i++ } }
输出:
$gotip run main.go
loop body: 0x14000120018 0x14000120018
3rd-clause: 0x14000120030 0x14000120018 // &i已经变为0x14000120030
loop body: 0x14000120030 0x14000120030
3rd-clause: 0x14000120038 0x14000120030 // &i已经变为0x14000120038
loop body: 0x14000120038 0x14000120038
3rd-clause: 0x14000120040 0x14000120038 // &i已经变为0x14000120040
参考资料
https://github.com/golang/go/issues/60078
https://go.dev/doc/faq#closures_and_goroutines
以上就是大多数Go程序员都走过的坑盘点解析的详细内容,更多关于Go程序员都走过的坑的资料请关注脚本之家其它相关文章!