Go 1.22中for迴圈語義變得不同了

banq發表於2024-03-07


Go 1.22修改了for迴圈的語義!

具體來說,只有在迴圈中宣告瞭迴圈變數的 for 迴圈的語義發生了變化。

例如,在下面這段程式碼中,前兩個迴圈的語義沒有變化,但後兩個迴圈的語義發生了變化(從 Go 1.21 到 1.22)。

Go 1.21:
for k, v = range aContainer {...}
 for a, b, c = f(); condition; statement {...}

Go 1.22:
 for k, v := range aContainer {...}
for a, b, c := f(); condition; statement {...}

注意,上述黑體 斜體部分是不同,原來的"="變成了":=",在等於號前面加了冒號。

前兩個迴圈Go 1.21沒有宣告各自的迴圈變數,但後兩個迴圈Go 1.22宣告瞭。
這就是區別所在。

  • 前兩個迴圈的Go 1.21語義沒有改變。
  • 但是後兩個迴圈Go 1.22語義改變了

案例1
讓我們來看一個簡單的 Go 程式,它經歷了從 Go 1.21 到 Go 1.22 的語義變化(和行為變化):

<font>//demo1.go<i>
package main

func main() {
    c, out := make(chan int), make(chan int)

    m := map[int]int{1: 2, 3: 4}
    for i, v := range m {
        go func() {
            <-c
            out <- i+v
        }()
    }

    close(c)

    println(<-out + <-out)
}

我們可以安裝多個 Go 工具鏈版本來檢查輸出結果。在這裡,我使用GoTV tool 工具來(方便地)選擇 Go 工具鏈版本。

輸出:
$ gotv 1.21. run demo1.go
[Run]: $HOME/.cache/gotv/tag_go1.21.7/bin/go run demo1.go
14
$ gotv 1.22. run demo1.go
[Run]: $HOME/.cache/gotv/tag_go1.22.0/bin/go run demo1.go
10

行為差異顯而易見:

  • 在 Go 1.22 之前,它列印 14;
  • Go 1.22 之後,則列印 10。

造成這種差異的原因是

  • 在 Go 1.22 之前,for 迴圈中使用的每個新宣告的迴圈變數在執行迴圈的過程中被所有迭代共享。因此,最終兩個新建立的 goroutines 中使用的 i 和 v 迴圈變數的值都是 3 4。
  • 自 Go 1.22 起,for 迴圈中使用的每個新宣告的迴圈變數在每次迭代開始時都會被例項化為一個獨特的例項。換句話說,它現在是按迭代作用域的。因此,兩個新建立的 goroutine 中使用的 i 和 v 迴圈變數的值分別是 1 2 和 3 4。(1+2) + (3+4) 得到 10。

在 1.22 版之前,為了得到與新語義相同的結果,程式中的迴圈應改寫為

    for i, v := range m {
        i, v := i, v <font>// 增加這一行<i>
        go func() {
            <-c
            out <- i+v
        }()
    }

在新的語義下,新增的一行變得沒有必要了。

案例2
同樣,下面的程式也經歷了從 Go 1.21 到 Go 1.22 的語義/行為變化:

<font>// demo2.go<i>
package main

func main() {
    c, out := make(chan int), make(chan int)

    for i := 1; i <= 3; i++ {
        go func() {
            <-c
            out <- i
        }()
    }

    close(c)

    println(<-out + <-out + <-out)
}

輸出:
$ gotv 1.21. run demo2.go
[Run]: $HOME/.cache/gotv/tag_go1.21.7/bin/go run demo2.go
12
$ gotv 1.22. run demo2.go
[Run]: $HOME/.cache/gotv/tag_go1.22.0/bin/go run demo2.go 
6

更改的影響
我個人認為,將 for-range 迴圈改為 for-range 迴圈的理由是充分的。for-range 迴圈的新語義變得更加直觀。這一改動隻影響 for k, v := range ... {...} 迴圈,其中的 := 符號強烈暗示迴圈變數是按迭代作用域的。沒有引入任何影響。這種變化的影響幾乎是正面的。

另一方面,在我看來,改為 for;; 迴圈的理由並不充分。提議者提出的主要理由是為了與 for-range 迴圈保持一致(它們都是 for 迴圈)。但是,如果認為下面 alike 迴圈中的迴圈變數是按迭代作用域的,那就完全不直觀了。

for a, b, c := anExpression; aCondition; postStatement {
    ... <font>// loop body<i>
}

在迴圈執行過程中,a, b, c := anExpression 語句只被執行一次,因此直觀地說,迴圈變數在迴圈執行過程中只被顯式例項化一次。

新的語義使得迴圈變數在每次迭代時都被例項化,這意味著必須有一些隱式程式碼來完成這項工作。
的確如此。Go 1.22+ 規範說:
每次迭代都有自己單獨宣告的變數。第一次迭代使用的變數由 init 語句宣告。之後每次迭代使用的變數都是在執行 post 語句之前隱式宣告的,並初始化為前一次迭代的變數值。

從 Go 1.22 開始,上面的迴圈實際上等價於下面的虛擬碼(抱歉,新的語義很難解釋得清楚和完美。沒有一篇 Go 官方文件能成功實現這一目標。在此,我已經盡力了):

{
    a_last, b_last, c_last := anExpression
    pa_last, pb_last, pc_last = &a_last, &b_last, &c_last
    first := true
    for {
        a, b, c := *pa_last, *pb_last, *pc_last
        if first {
            first = false
        } else {
            postStatement
        }
        if !(aCondition) {
            break
        }
        pa_last, pb_last, pc_last = &a, &b, &c
        ... <font>// loop body<i>
    }
}

哇,好多神奇的隱式程式碼。對於一種提倡顯式的語言來說,這實在令人尷尬。
隱式往往會帶來意想不到的驚喜,這並不令人驚訝。

更多點選標題

最後的話
總的來說,我認為 for-range 迴圈的新語義的影響是積極的,而 for;; 迴圈的新語義的影響是消極的。這只是我的個人觀點。

對於引入的神奇隱含性,for;;迴圈的新語義可能需要在編寫程式碼時花費額外的除錯時間,在某些情況下還需要在程式碼審查和理解時花費額外的認知精力。

for;; 迴圈的新語義可能會在現有程式碼中引入潛在的效能下降和資料競爭問題,需要仔細審查並進行可能的調整。根據具體情況,這些問題可能會被及時發現,也可能不會被及時發現。

在我看來,for;;迴圈的新語義所帶來的好處既少又小,而缺點則更為突出和嚴重

Go 1.22 中引入的語義變化大大降低了保持向後相容性的門檻。
這是一個糟糕的開端。

相關文章