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

:::note alert
この記事でのGoの動作は、Go 1.21 の仕様に基づいています。
1.22 で、for の仕様に変更が入っています。
詳しくは、https://tip.golang.org/doc/go1.22 を参照してください。
:::

# はじめに
次の記事を読んで、JavaScriptのfor文における`let`宣言のループ変数が各ループごとに異なる変数としてバインディングされる動作に違和感を覚えました。

https://qiita.com/twrcd1227/items/e7541a196d6bc1422dac

以下では、違和感を覚えた理由と、調査を進めた結果たどり着いた結論について書いています。

# 結論
結論から言うと、JavaScriptの言語仕様において、`let`はforループに特化されるので、ループ本体のスコープ内でループ変数の作成・値のコピーがされることがわかりました。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/for#初期化ブロックの字句の宣言

> 初期化ブロックの字句の宣言
>
> 初期化ブロックのスコープ効果は、宣言がループ本体の中で行われ、condition部分とafterthought部分でアクセス可能であるかのように理解できます。より正確には、`let`宣言はforループに特化しています。もし初期化が`let`宣言であれば、ループ本体が評価された後、以下のことが毎回行われます。
> 
> 1. 新しい字句スコープが作成され、新しい`let`で宣言された変数が追加されます。
> 2. 前回のループでバインドされた値を用いて、新しい変数が再初期化されます。
> 3. afterthoughtが新しいスコープで評価されます。

# 変数のスコープについて

件の記事では、Go言語の動作が、C言語から派生したプログラミング言語に当てはまるとあります。私はプログラミング言語をC++から覚えたからか、Go言語の動作に違和感はありませんでした。

変数のバインディングはスコープに基づいています。以下のコードでは、goroutine内で参照される変数`i`は、for文のスコープ内の変数です。
```go
// コードをシンプルに保つため、goroutineの終了待ちは割愛
for i := 0; i < 10; i++ {
    go func() {
        fmt.Println(i)
    }()
}
```
この`i`のスコープはループごとのスコープではありません。スコープの範囲がわかりにくいため、スコープの区切りがブレース`{}`で明確になるように書き換えてみます。
```go
{
    i := 0
    for i < 10 {
        go func() {
            fmt.Println(i)
        }()
        i++
    }
}
```
こうすると、変数`i`がループ本体の外で宣言されていることが明確になり、各ループで同じ変数を参照していることがわかりやすくなります。この考え方だと、for文が終了した後にgoroutineが実行時に同じ値となるのは理にかなっています。このfor文の書き方は、C++では`while`構文と同等です。したがって、C++では`for`と`while`で等価な処理を記述できます。

```cpp
// C++
{
    int i = 0;
    while (i < 10) {
        // statement;
        i++;
    }
}
```
このように、`for`と`while`は実質的に同じものという認識がありました。

# JavaScriptでのforとwhileの違い
違和感を覚えたのは次のコードです。
```javascript
for (let i = 0; i < 10; i++) {
  setTimeout(() => console.log(i));
}
// 0から9までが順に出力される
```
`for`と`while`が等価であると考えていたため、`while`に書き換えたコードと`for`のコードが同じ動作をすると思っていましたが、結果は異なりました。
```javascript
{
    let i = 0;
    while (i < 10) {
        setTimeout(() => console.log(i));
        i++;
    }
}
// 10が連続して出力される
```
`while`側の動作を期待していたため、`for`側の動作に違和感を覚えました。これが記事を書くきっかけとなった点であり、`for`と`while`が単純な等価性を持たないことに混乱しました。

ループごとに異なる変数がバインディングされることを考えると、`while`で書くと次のようになります。
```javascript
{
    let _i = 0;
    while (_i < 10) {
        let i = _i; // ループごとのスコープで変数を宣言

        setTimeout(() => console.log(i));
        
        _i++;
    }
}
```
しかし、この場合、変数`i`を変更してもループ変数`_i`に影響はありません。ループ変数に作用することも考えると`for`から`while`への変換は以下のようになるのだろうと思いました。
```javascript
{
    let _i = 0;
    while (_i < 10) {
        let i = _i;
        
        setTimeout(() => console.log(i));
        
        _i = i; // ループ変数を更新
        _i++;
    }
}
```
これで`while`を`for`と等価と思われる動作にすることはできましたが、どうしてループ変数がこのような扱いになるかが分かりませんでした。

# ドキュメントを読み、結論に至る
期待した動作と異なるため、言語仕様を調べたところ、for文の説明に答えがありました。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/for

> 初期化ブロックのスコープ効果は、宣言がループ本体の中で行われ、たまたま condition と afterthought の部分でアクセス可能であるかのように理解することができます。より正確には、let宣言は for ループに特化されます。もし initialization が let 宣言であれば、ループ本体が評価された後、以下のことが毎回行われます。
> 
> 1. 新しい字句スコープが作成され、新しい let が宣言された変数が追加されます。
> 2. 前回の反復処理でバインドされた値を用いて、新しい変数を再初期化します。
> 3. afterthought が新しいスコープで評価されます。

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

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

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

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

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

https://go.dev/wiki/LoopvarExperiment

Go Playgound

https://go.dev/play/p/d8u2KL1UMi1