每個 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()
}

你可能期望能輸出abc這三個字符 (可能順序不同),但是實際可能輸出的是ccc。這是因爲循環變量的作用域是整個循環,而不是單次迭代,所以在循環體中使用的變量是同一個變量,而不是每次迭代都是一個新的變量。

這個錯誤有時候隱藏很深,即使沒有 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)
 }
}

輸出也大概率是ccc, 因爲給每個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運行上面的程序,會輸出cba這樣的輸出,不再是ccc了。這個特性在 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# 語言就只修改了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

參考資料

[1]

What happens with closures running as goroutines?: https://go.dev/doc/faq#closures_and_goroutines

[2]

#60078: https://github.com/golang/go/issues/60078

[3]

Proposal: Less Error-Prone Loop Variable Scoping: https://go.googlesource.com/proposal/+/master/design/60078-loopvar.md

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ReAUdcByet0_hfKYGEKUwA