LoginSignup
18
11

More than 3 years have passed since last update.

意外と複雑!for 文のスコープに関するクイズ9問【解説付き】

Last updated at Posted at 2019-10-27

イントロダクション

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 ループは i3 になったときに i < 3false になって終了するので、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)直前であるということである。順を追って考えれば難しくはない。

  1. i0 で初期化される(i1 とする)。
  2. i1 がコピーされる(i2 とする)。i2 = 0
  3. i2 に対してボディが実行され、i2 = 1 になり、start が出力される。
  4. i2 がコピーされ、i3 = 1 ができる。
  5. i++, f = () => console.log(i) が実行され i3 = 2 になる。この () => console.log(i)i は i3 を指す。
  6. ボディが実行され、i3 = 3 になり f が実行され 3 が出力される。
  7. i3 がコピーされ、i4 = 3 ができる。
  8. i++, f = () => console.log(i) が実行され i4 = 4 になる。
  9. ボディが実行され、i4 = 5 になり f が実行され 5 が出力される。
  10. 以下同様

問題 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 が代入/実行されるタイミングが変わっただけである。

  1. i0 で初期化される(i1 とする)。
  2. i1 がコピーされる(i2 とする)。i2 = 0
  3. ボディが実行され、i2 = 1 になり、f の関数の i は i2 を指す
  4. i2 がコピーされ、i3 = 1 ができる。
  5. i++, f() が実行され、i3 = 2 になる。i2 = 1 なので 1 が出力される
  6. ボディが実行され、i3 = 3 になり、f の関数の i は i3 を指す
  7. i3 がコピーされ、i4 = 3 ができる。
  8. i++, f() が実行され、i4 = 4 になる。i3 = 3 なので 3 が出力される
  9. 以下同様

ちゃんとした説明

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 を参照していることが分かるだろう。

for-loop execution context.png

let の場合

let で宣言されたループ変数は for ループがスコープとなるvar のときと比べると一段階スコープが深くなっていることに注意する必要がある。

また、途中でループ変数のコピー (= LE のコピー) が発生する。これは問題 L-2 のような状況で変数が別々でないと不都合が生じるからである。

結論から言うと、let の for ループは次のように実行される。

  1. init
  2. copy
  3. 以下繰り返し
    • 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 が作成され、そこでループ変数が初期化される。
for-let init

  • copy

init で作成された LE がコピーされる。

for-loop copy1.png

  • test

新しい LE にて ループの終了条件がチェックされる。

for-loop test

  • body

ボディが実行される。このコードではボディがブロック {...} になっているため、新しい LE が作られる(勘違いされやすいが、ブロックは for 構文の一部ではない。for ループのボディはステートメントであればなんでもよく、ブロックステートメントである必要はない)。

ブロックの中の2つのステートメントはこの LE で実行される。ここで作られた関数 () => console.log(i) は実行中の LE (緑)へのポインタが張られる。

for-let body

  • copy

ループ変数用の LE がコピーされる。

for-let copy

  • increment

新しい LE でインクリメント式が実行される。

for-loop increment

  • test

条件式が評価される。

for-loop test

  • body

先と同様に、ブロックなので新しい LE が作られ、そこでブロック内のステートメントらが実行される。

for-loop execution context8.png

  • copy

ループ変数の LE がコピーされる。

for-loop execution context9.png

  • increment

インクリメント式が評価される。

context9.png

以下、test-body-copy-increment が繰り返される。

for ループが終了し、3つの setTimeout コールバックが実行される時、下の図のようにそれぞれの i が別々の LE に属する i を参照することになる。

name resolution.png

まとめ

問題 L-3 までは正答できる人も多いと思われるが、L-3 もちゃんと仕組みを説明できる人は少ないだろう。MDN でもこれについての説明は見当たらなかったため、仕様を読んで記事を書くに至った。

重要なのは、let の場合はループ変数用の LE が作られて、反復ごとにコピーされることである。コピーされるタイミングを知らないと答えられないような作為的なコードを問題にしたが、普通のコードを書く上ではそこまで知っておく必要はないだろう。

このような for の複雑な振る舞いについて考えたくない場合は、for-of を使うことをおすすめする。

参考

18
11
1

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
18
11