🦔

Goにおけるループの評価タイミングの違いと動作への影響

2024/09/05に公開

はじめに

Goのrangeループはさまざまな場所で使われる機能ですが、その内部動作を正確に理解していないと、思わぬバグを引き起こす可能性があります。今回はrangeループがイテレーション対象をどのように扱うかに焦点をあてて、クイズを通して解説します。

クイズ: どのようにループが動作するでしょうか?

以下の2つのコードはどのように動作するでしょうか?

Q1:

s := []int{0, 1, 2}
for _, v := range s {
    s = append(s, v)
}
fmt.Println(s)

Q2:

s := []int{0, 1, 2}
for i := 0; i < len(s); i++ {
    s = append(s, 3)
}
fmt.Println(s)

答えと解説

Q1の答え: 3回ループし、終了後 s = [0, 1, 2, 0, 1, 2] となります。
Q2の答え: 無限ループに陥ります。

なぜこのような違いが生じるのか?

rangeループでは、イテレーション対象(この場合はスライス s)の情報がループ開始前に1度だけ評価され、その結果に基づいてループが実行されます。一方、通常のforループでは、条件が毎回評価されるためです。

Q1の詳細な動作

ループ開始前に、range sで与えられた式 sが評価され、その結果(スライスのポインタ、長さ、容量)が一時変数にコピーされます。rangeループではこの一時変数が利用されるため、ループ内で sに要素を追加しても、イテレーション回数には影響しません。したがって、Q1は元の sの長さである3回のループ完了後、正常終了します。

s := []int{0, 1, 2}
for _, v := range s {
    s = append(s, v)
    // 1回目: s = [0, 1, 2, 0]
    // 2回目: s = [0, 1, 2, 0, 1]
    // 3回目: s = [0, 1, 2, 0, 1, 2]
}

一時変数は実際にはどのように管理されているのでしょうか? このrangeの動作をもう少し深掘りしてみてみましょう。

Goのコンパイラが出す中間表現(SSA) を確認してみます。以下のコードとコマンドを使ってSSAファイルを分析しましょう。

package main

func main() {
    s := []int{1, 2, 3}
    for i, v := range s {
        _ = i
        _ = v
    }
}
GOSSAFUNC=main go build -gcflags="-S" main.go

上記のコマンドを用いることでssa.htmlが作成され、コンパイラの各段階でのSSA表現を確認できます。

↓ssa.htmlの一部

この中の一番右側にあるgenssaを見てみましょう。これはgenerate SSAの略で、最終的なコード生成直前の形式でありアセンブリに近い低レベルの表現となっています。

    00000 (3) TEXT main.main(SB), ABIInternal
    00001 (3) FUNCDATA $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
    00002 (3) FUNCDATA $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
v30 00003 (3) MOVD $0, R0       // 1. R0レジスタに0を代入。ループカウンタの初期化
b2  00004 (5) JMP 6             // 2. 6行目にジャンプ
v39 00005 (+5) ADD $1, R0, R0   // 5. R0に1を加え、ループカウンタをインクリメントしステップ2に戻る
v27 00006 (+5) CMP $3, R0       // 3. R0と3を比較する (ループの終了条件)
b4  00007 (5) BLT 5             // 4. 比較結果が小さい場合は、5行目にジャンプ (ループ継続)
b7  00008 (9) RET
    00009 (?) END

上記のSSAを見てみると、ループカウンタの初期化やインクリメントを行っている箇所があるのがわかりますね。また、rangeは内部表現では単純なfor文に変換されていることがわかりました。

このように、rangeに渡された式は実際にループ処理に入る前に1度だけ評価されており、その長さである3が保持されています。各イテレーションではこの長さを参照しているため、sに対してappendしても3回のループで必ず終わるわけです。

Q2の詳細な動作:

forループでは、条件式(i < len(s)) がループの開始時に評価されます。
sに要素が追加されるたびに長さであるlen(s)も大きくなるため、iも毎回増加しますが常にi < len(s)が真となってしまいます。結果として、無限ループに陥るわけです。

s := []int{0, 1, 2}
for i := 0; i < len(s); i++ {
    s = append(s, 3)
    // 1回目: i = 0, s = [0, 1, 2, 3], len(s) = 4
    // 2回目: i = 1, s = [0, 1, 2, 3, 3], len(s) = 5
    // 3回目: i = 2, s = [0, 1, 2, 3, 3, 3], len(s) = 6
    // 以降、iとlen(s)が同じペースで増加し続ける
}

Q1と同様の手順でgenssaを確認してみましょう。

今回はこちらのコードのSSA表現をみます。

package main

func main() {
    s := []int{0, 1, 2}
    for i := 0; i < len(s); i++ {
        s = append(s, 3)
    }
}
    00000 (3) TEXT main.main(SB), ABIInternal
    00001 (3) FUNCDATA $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
    00002 (3) FUNCDATA $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
v26 00003 (+4) STP (ZR, ZR), main..autotmp_4-24(RSP)
v5  00004 (4) MOVD ZR, main..autotmp_4-8(RSP)
v44 00005 (4) MOVD $1, R3
v15 00006 (4) MOVD R3, main..autotmp_4-16(RSP)
v10 00007 (4) MOVD $2, R5
v19 00008 (4) MOVD R5, main..autotmp_4-8(RSP)
v27 00009 (4) MOVD $0, R0                         // ループカウンタの初期化
v30 00010 (4) MOVD $3, R1                         // スライスの初期長さ(3)をR1にセット
v42 00011 (4) MOVD $3, R2                         // スライスの初期容量(3)をR1にセット
v43 00012 (4) MOVD $main..autotmp_4-24(RSP), R4   // スライスのベースアドレスをR4にセット
b2  00013 (5) JMP 18                              // ループの条件チェックへジャンプ
v48 00014 (6) SUB $1, R1, R5
v36 00015 (6) MOVD $3, R6
v52 00016 (6) MOVD R6, (R4)(R5<<3)
v55 00017 (+5) ADD $1, R0, R0                                        // ループカウンタのインクリメント
v59 00018 (+5) CMP R0, R1                                                // i < len(s) の条件チェック。falseなら終了
b4  00019 (5) BLE 32
v35 00020 (+6) ADD $1, R1, R1                     // スライスの長さの更新
v53 00021 (6) CMP R1, R2
b5  00022 (6) BHS 14
v49 00023 (5) MOVD R0, main.i-32(RSP)
v41 00024 (6) MOVD R4, R0
v60 00025 (6) MOVD $type:int(SB), R4
v39 00026 (6) PCDATA $1, $0
v39 00027 (6) CALL runtime.growslice(SB)
v33 00028 (6) MOVD $1, R3
v11 00029 (6) MOVD R0, R4
v18 00030 (5) MOVD main.i-32(RSP), R0
b8  00031 (6) JMP 14
b7  00032 (8) RET
    00033 (?) END

キーとなるポイントに簡単なコメントを残しました。上記を見て分かるように、スライスの長さとループカウンタがレジスタに保持されており、これらが各イテレーションでインクリメントされていることがわかりますね。

このようにlenを評価式に用いたforループでは、各イテレーションでその式が再評価され無限ループになっているわけです。

まとめ

rangeループでは、イテレーション対象の情報がループ開始前に1度だけ評価され、その結果に基づいてループが実行されます。これにより、rangeループは予測可能で安全な動作を提供しますが、同時に柔軟性も制限されます。

ループ内でイテレーション対象を変更する場合は、その影響がループの実行回数には及ばないことを理解しておくことが重要です。状況に応じて、rangeループと通常のforループを適切に使い分けることで、より効果的で安全なコードを書くことができるでしょう。

Discussion