ゆとり日記

心にゆとりを持って生きたいプログラマーの雑記です。気が向いたら書きます。

Go1.22のfor文と変数スコープ

asakusago.connpass.com

asakusa.goで話すLTのネタとして調べていたメモが一定の分量になったので、ブログに書いておくことにした。

for文の中のクロージャ

import "fmt"

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

func main() {
    Print123()
}

いきなりだが、このコードをGo 1.21以下で実行するとどうなるか?

4
4
4

出力はこうなる。

挙動の解説

Go 1.21までは、for文のループでは同一のiを参照する。Print123関数の実行時にはforループが回り終わっているので、クロージャはi = 4を参照してしまうという話。 1 2 3を出力するために用いられるテクニックとして、以下の回避方法がある。

func Print123() {
    var prints []func()
    for i := 1; i <= 3; i++ {
        i := i // この行を追加
        prints = append(prints, func() { fmt.Println(i) })
    }
    for _, print := range prints {
        print()
    }
}

func main() {
    Print123()
}
1
2
3

i := iと書くと期待通りの出力が得られるが、「治ったからいいか」で済ませるのもスッキリしない。せっかくなので、この1行で解決する理由を言語化しておく。

変数のシャドーイング

for文の解説をする前に変数のシャドーイングについて触れておく。

import "fmt"

func main() {
    x := 10
    if x >= 10 {
        fmt.Println(x)
        x := 100
        fmt.Println(x)
    }
    fmt.Println(x)
}
10
100
10

コードを実行すると、このような実行結果が得られる。

if文のブロック内でブロック外のxと同名の変数xを宣言すると、宣言後に記述されているfmt.Printlnが参照するxはif文のブロック内で宣言されたxを参照される挙動に変化する。 その結果、2つめのfmt.Printlnではブロック内で宣言されたxに再代入された100が出力される。

ここでいう変数xのような変数は「シャドーイング変数」(内側のブロックで同じ名前をもつ変数)と呼ばれる。では、先ほどのPrint123関数がシャドーイング変数を利用することで予期した挙動に変わるのは何故か?

Print123の復習

シャドーイング変数を踏まえ、先ほどのコードを見直してみる。

func Print123() {
    var prints []func()
    for i := 1; i <= 3; i++ {
        i := i // クロージャから参照されるiはここに移った
        prints = append(prints, func() { fmt.Println(i) })
    }
    for _, print := range prints {
        print()
    }
}

forループも変数スコープなので、i := iをforループでシャドーイング変数として宣言すると、for文のループでは同一のiを参照していた挙動が変化し、参照先がシャドーイング変数として宣言されたiに移る。ループごとでiを保持するように変化したおかげで、無名関数のfmt.Println(i)の出力結果が1 2 3になる。

他のプログラミング言語ではどうか?

参考情報として、他のプログラミング言語で同様の実装がどう動くかを試す。今回はTypeScriptで試した。

function print123() {
     var prints: (() => void)[] = [];
     for (let i = 1; i <= 3; i++) {
         prints.push(() => {
             console.log(i);
         });
     }
 
     prints.forEach(print => print());
 }
 
 print123(); // 1 2 3 が出力される

ちなみに小ネタとして、for文のletをvarに書き換えると出力結果が変わる。

function print123() {
     var prints: (() => void)[] = [];
     for (var i = 1; i <= 3; i++) { // この行のletをvarに変更
         prints.push(() => {
             console.log(i);
         });
     }
 
     prints.forEach(print => print());
 }
 
 print123(); // 4 4 4が出力される

varで宣言した変数はprint123関数全体がスコープになるが、letで宣言した変数はforループそれぞれにスコープが限定される。スコープが限定された結果、console.logが1 2 3をそれぞれ参照できるようになり、期待通りの挙動を得られるという話。

とはいえ、TypeScriptを使用した開発環境でvarをあえて書きたい場面はないので、この罠を踏み抜く機会はないだろう。

Go1.22での挙動

ここでgolangでの挙動に話を戻す。

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

func main() {
    Print123()
}
1
2
3

最初に記載したコードをGo1.22で動かすと、1 2 3が出力される。Go1.22におけるfor文の変化を公式のドキュメントで見てみる。

In a "for" statement, each iteration has its own set of iteration variables rather than sharing the same variables in each iteration.

https://tip.golang.org/ref/spec#Go_1.22 を見るとこのように書かれていて、日本語に直すと「for文では、各反復で同じ変数を共有せず、各反復で独自の反復変数セットを持つ」となる。つまり、forループの最初に暗黙的なi := iを追加する挙動となっていて、go1.21におけるの挙動とは根本的に異なることがわかる。

こうした小さいコードであれば、単純に「Go1.22使った方がいいじゃん!」となるが、この挙動の差によって既存の処理やテストが壊れる可能性がなくはないので、これからGo 1.22に移行を考えている方は要注意。自分が関わっている製品では大きな影響はなく、依存しているパッケージのアップデート待ち(Go1.22に対応待ち)に時間を取られていた記憶がある。

もし既存のテストが壊れることがあれば、影響のあるループをbisectを利用して特定可能。

参考: https://pkg.go.dev/golang.org/x/tools/cmd/bisect#hdr-Example

パフォーマンスはどうなのか?

func Print123() {
    var prints []func()
    for i := 1; i <= 1000000; i++ {
        // go1.21ではシャドーイング変数 i := i を記載
        prints = append(prints, func() { fmt.Println(i) })
    }
    for _, print := range prints {
        print()
    }
}

func main() {
    start := time.Now()
    Print123()

    since := time.Since(start)
    fmt.Printf("The for loop took: %s\n", since)
}

この環境にてループの回数を100万に増やし、処理時間はtime関数で計測する簡単な比較をしてみた。計測を数十回行ったところ、Go1.21でもGo1.22でも処理は3.12~3.18secの範囲に収まり、目立った有意差はみられなかった。

ちなみにproposalで例に挙がっているコードでもパフォーマンスの有意差は見られなかったらしい。ただ、各々が扱うアプリケーションに求められるパフォーマンス水準や複雑性はそれぞれなので、pprofでボトルネックの有無を計測しておくとより安心できそう。

まとめ

多くの人が踏み抜いた経験があろうfor文ループの挙動を変数スコープの観点で調べてみたメモでした。Go1.22では気にする必要はないものですが、「どういう挙動の変化があったのか」「変化する前の挙動はどういう仕組みだったのか」の観点で見返すと、興味深いトピックに巡り会える機会もあるようです。

参考リンク