LoginSignup
44
40

【JavaScript】let宣言は for ループに特化される

Last updated at Posted at 2024-02-04

この記事での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宣言であれば、ループ本体が評価された後、以下のことが毎回行われます。

  1. 新しい字句スコープが作成され、新しいletで宣言された変数が追加されます。
  2. 前回のループでバインドされた値を用いて、新しい変数が再初期化されます。
  3. 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++ではforwhileで等価な処理を記述できます。

// C++
{
    int i = 0;
    while (i < 10) {
        // statement;
        i++;
    }
}

このように、forwhileは実質的に同じものという認識がありました。

JavaScriptでのforとwhileの違い

違和感を覚えたのは次のコードです。

for (let i = 0; i < 10; i++) {
  setTimeout(() => console.log(i));
}
// 0から9までが順に出力される

forwhileが等価であると考えていたため、whileに書き換えたコードとforのコードが同じ動作をすると思っていましたが、結果は異なりました。

{
    let i = 0;
    while (i < 10) {
        setTimeout(() => console.log(i));
        i++;
    }
}
// 10が連続して出力される

while側の動作を期待していたため、for側の動作に違和感を覚えました。これが記事を書くきっかけとなった点であり、forwhileが単純な等価性を持たないことに混乱しました。

ループごとに異なる変数がバインディングされることを考えると、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++;
    }
}

これでwhileforと等価と思われる動作にすることはできましたが、どうしてループ変数がこのような扱いになるかが分かりませんでした。

ドキュメントを読み、結論に至る

期待した動作と異なるため、言語仕様を調べたところ、for文の説明に答えがありました。

初期化ブロックのスコープ効果は、宣言がループ本体の中で行われ、たまたま condition と afterthought の部分でアクセス可能であるかのように理解することができます。より正確には、let宣言は for ループに特化されます。もし initialization が let 宣言であれば、ループ本体が評価された後、以下のことが毎回行われます。

  1. 新しい字句スコープが作成され、新しい let が宣言された変数が追加されます。
  2. 前回の反復処理でバインドされた値を用いて、新しい変数を再初期化します。
  3. afterthought が新しいスコープで評価されます。

この動作から、先に示したwhileのコードとは異なる動作であることがわかります。ループ本体の後に新しいスコープでafterthoughtが評価されるため、whileで同様の表現をするのは難しそうです。

終わりに

今回の調査は、JavaScriptのfor文におけるletの仕様を理解する良い機会となりました。

補記

ループ処理中の変数の扱いについて調査していたところ、Go言語では仕様変更の提案が行われていることを知りました。Go言語を使用していないため、提案の詳細は知りませんでしたが、ループ変数の扱いに変更があるようです。

1.22で変更が加わりました。

記事に記載したGoのコードも、Go Playgroundで 1.22 以降で異なる結果になることを確認できます。ループごとにループ変数が別変数としてバインディングされるようになっています。

Go Playgound

44
40
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
44
40