この記事でのGoの動作は、Go 1.21 の仕様に基づいています。
1.22 で、for の仕様に変更が入っています。
詳しくは、https://tip.golang.org/doc/go1.22 を参照してください。
はじめに
次の記事を読んで、JavaScriptのfor文におけるlet
宣言のループ変数が各ループごとに異なる変数としてバインディングされる動作に違和感を覚えました。
以下では、違和感を覚えた理由と、調査を進めた結果たどり着いた結論について書いています。
結論
結論から言うと、JavaScriptの言語仕様において、let
はforループに特化されるので、ループ本体のスコープ内でループ変数の作成・値のコピーがされることがわかりました。
初期化ブロックの字句の宣言
初期化ブロックのスコープ効果は、宣言がループ本体の中で行われ、condition部分とafterthought部分でアクセス可能であるかのように理解できます。より正確には、
let
宣言はforループに特化しています。もし初期化がlet
宣言であれば、ループ本体が評価された後、以下のことが毎回行われます。
- 新しい字句スコープが作成され、新しい
let
で宣言された変数が追加されます。- 前回のループでバインドされた値を用いて、新しい変数が再初期化されます。
- afterthoughtが新しいスコープで評価されます。
変数のスコープについて
件の記事では、Go言語の動作が、C言語から派生したプログラミング言語に当てはまるとあります。私はプログラミング言語をC++から覚えたからか、Go言語の動作に違和感はありませんでした。
変数のバインディングはスコープに基づいています。以下のコードでは、goroutine内で参照される変数i
は、for文のスコープ内の変数です。
// コードをシンプルに保つため、goroutineの終了待ちは割愛
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
このi
のスコープはループごとのスコープではありません。スコープの範囲がわかりにくいため、スコープの区切りがブレース{}
で明確になるように書き換えてみます。
{
i := 0
for i < 10 {
go func() {
fmt.Println(i)
}()
i++
}
}
こうすると、変数i
がループ本体の外で宣言されていることが明確になり、各ループで同じ変数を参照していることがわかりやすくなります。この考え方だと、for文が終了した後にgoroutineが実行時に同じ値となるのは理にかなっています。このfor文の書き方は、C++ではwhile
構文と同等です。したがって、C++ではfor
とwhile
で等価な処理を記述できます。
// C++
{
int i = 0;
while (i < 10) {
// statement;
i++;
}
}
このように、for
とwhile
は実質的に同じものという認識がありました。
JavaScriptでのforとwhileの違い
違和感を覚えたのは次のコードです。
for (let i = 0; i < 10; i++) {
setTimeout(() => console.log(i));
}
// 0から9までが順に出力される
for
とwhile
が等価であると考えていたため、while
に書き換えたコードとfor
のコードが同じ動作をすると思っていましたが、結果は異なりました。
{
let i = 0;
while (i < 10) {
setTimeout(() => console.log(i));
i++;
}
}
// 10が連続して出力される
while
側の動作を期待していたため、for
側の動作に違和感を覚えました。これが記事を書くきっかけとなった点であり、for
とwhile
が単純な等価性を持たないことに混乱しました。
ループごとに異なる変数がバインディングされることを考えると、while
で書くと次のようになります。
{
let _i = 0;
while (_i < 10) {
let i = _i; // ループごとのスコープで変数を宣言
setTimeout(() => console.log(i));
_i++;
}
}
しかし、この場合、変数i
を変更してもループ変数_i
に影響はありません。ループ変数に作用することも考えるとfor
からwhile
への変換は以下のようになるのだろうと思いました。
{
let _i = 0;
while (_i < 10) {
let i = _i;
setTimeout(() => console.log(i));
_i = i; // ループ変数を更新
_i++;
}
}
これでwhile
をfor
と等価と思われる動作にすることはできましたが、どうしてループ変数がこのような扱いになるかが分かりませんでした。
ドキュメントを読み、結論に至る
期待した動作と異なるため、言語仕様を調べたところ、for文の説明に答えがありました。
初期化ブロックのスコープ効果は、宣言がループ本体の中で行われ、たまたま condition と afterthought の部分でアクセス可能であるかのように理解することができます。より正確には、let宣言は for ループに特化されます。もし initialization が let 宣言であれば、ループ本体が評価された後、以下のことが毎回行われます。
- 新しい字句スコープが作成され、新しい let が宣言された変数が追加されます。
- 前回の反復処理でバインドされた値を用いて、新しい変数を再初期化します。
- afterthought が新しいスコープで評価されます。
この動作から、先に示したwhile
のコードとは異なる動作であることがわかります。ループ本体の後に新しいスコープでafterthoughtが評価されるため、while
で同様の表現をするのは難しそうです。
終わりに
今回の調査は、JavaScriptのfor文におけるlet
の仕様を理解する良い機会となりました。
補記
ループ処理中の変数の扱いについて調査していたところ、Go言語では仕様変更の提案が行われていることを知りました。Go言語を使用していないため、提案の詳細は知りませんでしたが、ループ変数の扱いに変更があるようです。
1.22で変更が加わりました。
記事に記載したGoのコードも、Go Playgroundで 1.22 以降で異なる結果になることを確認できます。ループごとにループ変数が別変数としてバインディングされるようになっています。
Go Playgound