The Go Blog

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 のコピーを作成したことから、一般的な問題を明確に理解していたようですが、modelToAuthzPB が結果を構築する際に v 内のフィールドへのポインタを使用していたため、ループも v のコピーを作成する必要があったことが判明しました。

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

特に分かりやすい例が2つ見つかりました。

この差分は1つのプログラムにありました。

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

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

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

これら2つの差分のうち、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.modgo 行は無視されます)。たとえば、パッケージとすべての依存関係に新しいループセマンティクスを適用した場合でもテストが合格するかどうかを確認するには、次のようにします。

GOEXPERIMENT=loopvar go test

Google では、2023年5月初旬に、社内の Go ツールチェーンにパッチを適用し、すべてのビルドでこのモードを強制しました。この4ヶ月間、本番コードで問題の報告はゼロでした。

プログラムの先頭に // 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 サポート
ブログインデックス