Go 部落格

修正 Go 1.22 中的 For 迴圈

David Chase 和 Russ Cox
2023 年 9 月 19 日

Go 1.21 包括 for 迴圈範圍的變更預覽,我們計畫在 Go 1.22 中發佈此變更,並移除 Go 最常見的錯誤之一。

問題

如果您撰寫過任何大量的 Go 程式碼,您可能曾錯誤保留迴圈變數的參考,直到其執行完畢,屆時它將採用您不想要的其他值。例如,考慮以下程式

func main() {
    done := make(chan bool)

    values := []string{"a", "b", "c"}
    for _, v := range values {
        go func() {
            fmt.Println(v)
            done <- true
        }()
    }

    // wait for all goroutines to complete before exiting
    for _ = range values {
        <-done
    }
}

這三個建立的 goroutine 都列印同一個變數 v,所以它們通常會列印「c」、「c」、「c」,而不是按順序列印「a」、「b」和「c」。

Go 常見問題輸入「在作為 goroutine 執行的封閉中發生什麼事?」 給出這個範例並說明「當同時使用封閉和並行處理時,可能會造成一些混淆。」

雖然並行處理經常參與其中,但這並非必要。這個範例有相同的問題,但沒有 goroutine

func main() {
    var prints []func()
    for i := 1; i <= 3; i++ {
        prints = append(prints, func() { fmt.Println(i) })
    }
    for _, print := range prints {
        print()
    }
}

此類錯誤已導致許多公司的生產問題,包括 在 Lets Encrypt 公開的文件問題。在那個情況中,迴圈變數的意外捕獲擴散到多個函式,更加難以察覺

// 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
}

這段程式碼的作者清楚了解一般問題,因為他們建立了 k 的複本,但事實證明 modelToAuthzPB 在建立其結果時會對 v 中的欄位使用指標,所以迴圈也需要建立 v 的複本。

已經撰寫工具來識別這些錯誤,但很難分析對變數的參照是否會超出其反覆運算的範圍。這些工具必須在偽負和偽正之間做出選擇。由 go vetgopls 所使用的 loopclosure 分析器會選擇偽負,只會在確定有問題時報告,但會遺漏其他問題。其他查驗器會選擇偽正,指控正確的程式碼是不正確的。我們執行了一則分析,分析了在開源 Go 程式碼中新增 x := x 行的提交,期待找出錯誤修正。反之,我們發現新增了許多不必要的行,這意味著普及的查驗器有顯著的偽正率,但開發人員仍會加入這些行來讓查驗器滿意。

我們發現的其中一對範例特別具有點醒的效果

此差異在一個程式中

     for _, informer := range c.informerMap {
+        informer := informer
         go informer.Run(stopCh)
     }

此差異在另一個程式中

     for _, a := range alarms {
+        a := a
         go a.Monitor(b)
     }

這兩個差異中的一個是錯誤修正;另一個是不必要的變更。除非你更了解相關的類型與函式,否則無法分辨哪個是哪個。

修正

對於 Go 1.22,我們計畫變更 for 迴圈,讓這些變數具備每個反覆運算的範圍,而不是每個迴圈的範圍。此變更將修正以上的範例,讓它們不再是錯誤的 Go 程式;它將結束此類錯誤導致的生產問題;它將消除對提示使用者對其程式碼進行不必要變更的不精確工具的需求。

為了確保與現有程式碼的向下相容性,新的語意僅會套用在具有模組的套件中,而這些模組在他們的 go.mod 檔中宣告 go 1.22 或更高版本。此每個模組的決策提供開發人員對於在整個程式碼庫中漸進更新至新語意的控制。也能使用 //go:build 行在每個檔案的基礎上控制決策。

舊程式碼將繼續表示與目前完全相同的意思:修正套用於新的或已更新的程式碼。這將讓開發人員可以控制特定套件中語義變更的時間。由於我們的向前相容性工作,Go 1.21 將不會嘗試編譯宣告 go 1.22 或後續版本的程式碼。我們在重點版本 Go 1.20.8 和 Go 1.19.13 中加入了一個有相同效果的特例,因此,當 Go 1.22 發布時,根據新語義編寫的程式碼將永遠不會以舊語義編譯,除非人們使用非常舊的不受支援的 Go 版本

預覽修正

Go 1.21 包含縮寫變更的預覽。如果你在環境中設定 `GOEXPERIMENT=loopvar` 來編譯您的程式碼,那麼新的語義將套用至所有迴圈(忽略 go.mod go 列)。例如,要檢查您的測試是否會在套用新迴圈語義至您的套件和所有相依項後依然通過

GOEXPERIMENT=loopvar go test

我們於 2023 年 5 月初在 Google 修補我們的內部 Go 工具鏈,以在所有建構期間強制執行此模式,而在過去四個月內,我們在製作程式碼中沒接獲任何問題的回報。

您也可以嘗試測試程式,以更了解 Go 操場上的語意,這樣做可以在程式的最頂端加入 // GOEXPERIMENT=loopvar 註解,就像這個程式。(這個註解僅適用在 Go 操場中。)

修正錯誤的測試

儘管我們沒有出現製作問題,但為了準備好切換,我們確實必須更正許多有錯誤的測試,它們沒有測試到它們以為它們在測試的東西,就像這個

func TestAllEvenBuggy(t *testing.T) {
    testCases := []int{1, 2, 4, 6}
    for _, v := range testCases {
        t.Run("sub", func(t *testing.T) {
            t.Parallel()
            if v&1 != 0 {
                t.Fatal("odd v", v)
            }
        })
    }
}

在 Go 1.21 中,此測試會通過,因為 t.Parallel 會在整個迴圈完成前封鎖每個子測試,然後並行執行所有子測試。當迴圈完成時,v 始終為 6,因此所有子測試均檢查 6 是否為偶數,所以測試通過。當然,這個測試實際上會失敗,因為 1 不是偶數。修正 for 迴圈會公開這種有錯誤的測試。

為了協助準備此種發現,我們改善了 Go 1.21 中的 loopclosure 分析器的精確度,使得它可以找出並回報這個問題。您可以在 Go 操場中的此程式中看到回報。如果 go vet 在您自己的測試中回報出這種類型的問題,修正它們將會讓您更能準備好迎接 Go 1.22。

如果你遇到其他問題,常見問題有連結到範例和詳細使用說明,這是我們編寫的工具,用來找出是哪個特定的迴圈在新語意套用後導致測試失敗。

更多資訊

有關變更的更多資訊,請參閱設計文件常見問題

下一篇文章:解構類型參數
上一篇文章:Go 中的 WASI 支援
網誌索引