Go ブログ

Go 1.22 での for ループの修正

David Chase および Russ Cox
2023年9月19日

Go 1.21 には、Go 1.22 で出荷を予定している for ループのスコープの変更プレビューが含まれており、最も一般的な Go の間違いの 1 つを解消します。

問題点

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

作成された 3 つのゴルーチンはすべて同じ変数 v を出力するため、通常は「a」、「b」、「c」の順序で出力するのではなく、「c」、「c」、「c」を出力します。

Go FAQ の「ゴルーチンとして実行されるクロージャはどうなりますか?」のエントリでは、この例を挙げ、「同時実行でクロージャを使用すると混乱が生じる可能性があります。」と述べています。

同時実行がしばしば関係しますが、そうである必要はありません。この例では同じ問題がありますが、ゴルーチンはありません。

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 のコピーを作成したからです。しかし、結果を構築するときに、modelToAuthzPBv のフィールドへのポインタを使用したことが判明したため、ループでも v のコピーを作成する必要がありました。

これらの間違いを特定するためにツールが書かれましたが、変数の参照がそのイテレーションよりも長く続くかどうかを分析するのは困難です。これらのツールは、偽陰性と偽陽性のどちらかを選択する必要があります。go vet および gopls によって使用される loopclosure アナライザーは偽陰性を選択し、問題が確実にある場合にのみ報告しますが、他の問題は見逃します。他のチェッカーは偽陽性を選択し、正しいコードを誤っているとして非難します。オープンソースの Go コードで x := x 行を追加するコミットの分析を実行したところ、バグ修正が見つかると予想していました。代わりに、多くの不必要な行が追加されていることがわかりました。これは、人気のあるチェッカーに重大な偽陽性率があることを示唆していますが、開発者はチェッカーを満足させるためにとにかく行を追加します。

特に啓発的だった例のペアを 1 つ発見しました。

この diff はあるプログラムにありました。

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

そして、この diff は別のプログラムにありました。

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

これらの 2 つの diff の 1 つはバグ修正です。もう 1 つは不必要な変更です。関係する型と関数について詳しく知らない限り、どちらがどちらかはわかりません。

修正

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 ツールチェーンにこのモードを強制するようにパッチを適用しましたが、過去 4 か月間で、本番環境コードの問題の報告は 1 件もありません。

プログラムの先頭に // GOEXPERIMENT=loopvar コメントを含めることで、このプログラムのように、Go playground でテスト プログラムを試して、セマンティクスをより深く理解することもできます。(このコメントは Go playground でのみ適用されます。)

バグのあるテストの修正

本番環境の問題はありませんでしたが、切り替えの準備をするために、次のような、想定していたテストを行っていないバグのあるテストを多数修正する必要がありました。

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 playground のこのプログラムでレポートを確認できます。go vet が独自のテストでこの種の問題を報告している場合は、それらを修正することで Go 1.22 により適切に備えることができます。

他の問題が発生した場合は、FAQに、新しいセマンティクスが適用されたときにテストの失敗を引き起こしている特定のループを特定するために作成したツールの使用に関する例と詳細へのリンクがあります。

詳細情報

変更の詳細については、設計ドキュメントFAQを参照してください。

次の記事: 型パラメータの解剖
前の記事: Go での WASI サポート
ブログ インデックス