イントロダクション
ES6 で const/let
が追加され、ブロックスコープの変数を宣言できるようになった。これにより for
ループのループ変数は var
ではなく let
で宣言することが多くなったが、実は let
で宣言されたループ変数のスコープは意外なほど複雑である。このあまり知られていない for
文の振る舞いは、非同期関数やクロージャーを用いると浮かび上がってくる。この記事ではそのような問題を取り上げる。
各コードについて、実行したときに何がどの順序で出力されるかを考えてみよう。実行環境はブラウザでも Node.js でも同じ結果になるはずである。
※問題ごとの解説は軽い説明で済ませているが、最後にまとめて for ループの実行の仕組みを説明している。
なお、前回は Promise に関するクイズ13問 という記事を書いたので興味があればぜひどうぞ(今回も setTimeout
が登場するので関係はある)。
問題 V-1: Easy
for (var i = 0; i < 3; i++) {
}
try {
console.log(i)
} catch (e) {
console.log("error")
}
解答
3
var
で宣言したループ変数は、var
である限り function scope であるため、for-loop の前後でもアクセスすることができる。つまり問題の for ループは下のコードと完全に同値である。
var i = 0
for (; i < 3; i++) {
}
for ループは i
が 3
になったときに i < 3
が false
になって終了するので、3
が出力される。
問題 V-2: Normal
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}
解答
3
3
3
これはよく初心者が意図せず書いてしまうコードであり、例えば button
要素の配列の各要素にイベントリスナーの追加をしたいときなどにこのパターンに遭遇する。
ループ変数 i
は一つしか存在せず、3つのコールバック関数 () => console.log(i)
はすべてこの変数を参照している。そしてこれらの関数が実行されるタイミングは for ループが終了したあとなので、i
の値は 3
になっている。
問題 L-1: Easy
for (let i = 0; i < 3; i++) {
}
try {
console.log(i)
} catch (e) {
console.log("error")
}
解答
error
let
で宣言したループ変数は for ループ内でしかアクセスできない。
問題 L-2: Normal
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}
解答
0
1
2
ボディ {...}
の部分が3回実行されるが、毎回別々の変数 i
が作られるというのがポイントである。そのため、3つのコールバック () => console.log(i)
はそれぞれ別々の変数 i
(=0) i
(=1) i
(=2) を参照することになる。
同じ timeout でセットされた setTimeout
コールバックは、セットされた順番で実行されるので、普通に 0 1 2
の順番で出力される。
let
を用いることで問題 V-2 のような現象を回避することができる、ということである。
問題 L-3: Normal
for (let i = 0; i < 10; i++) {
console.log(i)
i += 1
}
解答
0
2
4
6
8
毎回別々の変数 i
が作られはするが、i
の値を途中で変えれば次の i
にも影響する。なぜなら、ボディを実行したあとのループ変数 i
の値がコピーされて新しい i
が作られ、i++
が実行されるからである。
この問題に関しては var
でも同じ結果になる。
問題 L-4: Normal
for (let i = 0; i < 10; i++) {
setTimeout(() => console.log(i), 0)
i += 1
}
解答
1
3
5
7
9
5つのコールバック () => console.log(i)
が作られるが、それぞれ別々の i
を参照しているのはここまでで説明した通りである。それぞれの変数の値は以下のように変わる。
- 1つ目の
i
: 0 → 1 - 2つ目の
i
: 2 → 3 - 3つ目の
i
: 4 → 5 - 4つ目の
i
: 6 → 7 - 5つ目の
i
: 8 → 9
先述の通りコールバックが実行されるのは for ループが実行しおわったあとなので、それぞれ後の値が出力される。
問題 L-5: Hard
let f
for (
let i = (() => {
f = () => console.log(i)
return 0
})();
i < 3;
i++
) { }
f()
解答
0
for ループの構文の制限上 IIFE を使っているが、要は f
に代入すると同時に i
を初期化しているだけである。
この問題のポイントは、ループ変数の初期化のあとにも変数のコピーが発生することである。そのため、ボディは3回実行されるが、実はループ変数 i
は4個作られることになる。
初期化のときに作られた i
の値は for ループが終わった後も 0
であるため、0
が出力される。
問題 L-6: Hard
let f = () => console.log("start")
for (
let i = 0;
i < 10;
i++, f = () => console.log(i)
) {
i += 1
f()
}
解答
start
3
5
7
9
ポイントは、変数のコピーが発生するのは初期化の直後とインクリメント式 i++, f = () => console.log(i)
の直前であるということである。順を追って考えれば難しくはない。
-
i
が0
で初期化される(i1 とする)。 - i1 がコピーされる(i2 とする)。i2 = 0
- i2 に対してボディが実行され、i2 = 1 になり、
start
が出力される。 - i2 がコピーされ、i3 = 1 ができる。
-
i++, f = () => console.log(i)
が実行され i3 = 2 になる。この() => console.log(i)
のi
は i3 を指す。 - ボディが実行され、i3 = 3 になり
f
が実行され3
が出力される。 - i3 がコピーされ、i4 = 3 ができる。
-
i++, f = () => console.log(i)
が実行され i4 = 4 になる。 - ボディが実行され、i4 = 5 になり
f
が実行され5
が出力される。 - 以下同様
問題 L-7: Hard
let f
for (
let i = 0;
i < 10;
i++, f()
) {
i += 1
f = () => console.log(i)
}
解答
1
3
5
7
9
これも L-6 と同じように考えればよい。f
が代入/実行されるタイミングが変わっただけである。
-
i
が0
で初期化される(i1 とする)。 - i1 がコピーされる(i2 とする)。i2 = 0
- ボディが実行され、i2 = 1 になり、
f
の関数のi
は i2 を指す。 - i2 がコピーされ、i3 = 1 ができる。
-
i++, f()
が実行され、i3 = 2 になる。i2 = 1 なので1
が出力される。 - ボディが実行され、i3 = 3 になり、
f
の関数のi
は i3 を指す。 - i3 がコピーされ、i4 = 3 ができる。
-
i++, f()
が実行され、i4 = 4 になる。i3 = 3 なので3
が出力される。 - 以下同様
ちゃんとした説明
Lexical Environment (LE)
実行中の変数の値は、スコープごとに別々のテーブルで保存されている。このテーブル一つ一つを lexical environment と呼ぶ。
筆者の別記事で詳しく説明しているので参考にしていただきたい。クロージャーが実現できるのもこの LE のおかげである。
var
の場合
var
で変数を宣言した場合、話は単純である。下のようにループ変数 i
を宣言した場合、そのスコープは for
の外の変数 outer
と同じである(= 同じ lexical environment に属する)。{ ... }
の部分はブロックなので、そこでまた新しいスコープが必要になり、ループのたびに LE が作成されることになる。
const outer = 0
for (var i = 0; i < 3; i++) {
const inner = 0
setTimeout(() => console.log(i), 0)
}
変数が参照されるとき、LE のチェーン(黒の矢印)をたどって探索が行われる。3つの関数が同じ i
を参照していることが分かるだろう。
let
の場合
let
で宣言されたループ変数は for ループがスコープとなる。var
のときと比べると一段階スコープが深くなっていることに注意する必要がある。
また、途中でループ変数のコピー (= LE のコピー) が発生する。これは問題 L-2 のような状況で変数が別々でないと不都合が生じるからである。
結論から言うと、let
の for ループは次のように実行される。
- init
- copy
- 以下繰り返し
- test (失敗すれば終了)
- body
- copy
- increment
次のコードを用いて、それぞれのステップを説明する。
const outer = 0
for (let i = 0; i < 3; i++) {
const inner = 0
setTimeout(() => console.log(i), 0)
}
- init
現在の LE の下に ループ変数用の新しい LE が作成され、そこでループ変数が初期化される。
- copy
init で作成された LE がコピーされる。
- test
新しい LE にて ループの終了条件がチェックされる。
- body
ボディが実行される。このコードではボディがブロック {...}
になっているため、新しい LE が作られる(勘違いされやすいが、ブロックは for 構文の一部ではない。for ループのボディはステートメントであればなんでもよく、ブロックステートメントである必要はない)。
ブロックの中の2つのステートメントはこの LE で実行される。ここで作られた関数 () => console.log(i)
は実行中の LE (緑)へのポインタが張られる。
- copy
ループ変数用の LE がコピーされる。
- increment
新しい LE でインクリメント式が実行される。
- test
条件式が評価される。
- body
先と同様に、ブロックなので新しい LE が作られ、そこでブロック内のステートメントらが実行される。
- copy
ループ変数の LE がコピーされる。
- increment
インクリメント式が評価される。
以下、test-body-copy-increment が繰り返される。
for ループが終了し、3つの setTimeout
コールバックが実行される時、下の図のようにそれぞれの i
が別々の LE に属する i
を参照することになる。
まとめ
問題 L-3 までは正答できる人も多いと思われるが、L-3 もちゃんと仕組みを説明できる人は少ないだろう。MDN でもこれについての説明は見当たらなかったため、仕様を読んで記事を書くに至った。
重要なのは、let
の場合はループ変数用の LE が作られて、反復ごとにコピーされることである。コピーされるタイミングを知らないと答えられないような作為的なコードを問題にしたが、普通のコードを書く上ではそこまで知っておく必要はないだろう。
このような for の複雑な振る舞いについて考えたくない場合は、for-of を使うことをおすすめする。