前書き
for (var i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i);
}, 100);
}
// 5 5 5 5 5
for (let i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i);
}, 100);
}
// 0 1 2 3 4
この2つのコード、var
とlet
の違いとしてそこそこ有名なものだと思います。
この動作自体に文句はありませんし、自分もそういうもんだと思って使っていたのですが・・・
よくよく考えるとBのコード上では
-
console.log(i)
はlet
で宣言された変数i
を参照している -
let
で宣言された変数i
は、for
文によってインクリメントされている -
console.log(i)
が実行されるのは、for
ループが完了した後である
という処理になっているため、Aのコードと同じ結果になるはずです。
MDNにあるようなlet
の性質だけでは、
この部分の説明がつかなくて気になってしまったというのが発端で、
色々試していた+調べていたらそこそこな量になったのでまとめました。
ただ、知られてなさそうな性質として気になったのが全て
for
と組み合わせた時に表れる性質なので
実用性はそんなにないと思います。
よく知られているであろう性質
ブロックスコープである
{
let a = 1;
console.log(a); // 1
}
console.log(a);
// Uncaught ReferenceError: a is not defined
let
宣言するとスコープがブロック内に制限されるやつです。
どこのサイトでも載ってる基本中の基本。
同一ブロック内での再宣言不可
{
let a = 1;
let a = 2;
}
// Uncaught SyntaxError: Identifier 'a' has already been declared
let
宣言すると、同一スコープで再宣言ができません。
これはvar
での再宣言でも同じくエラーになります。
{
let a = 1;
{
let a = 2;
console.log(a);
}
console.log(a);
}
// 2 1
のように、ブロック内の別ブロックであれば
再宣言ではなく新規の変数宣言扱いになるので、エラーは出なくなります。
これも基本。
for
文のループブロックは、それぞれが別のブロックスコープになる
for (let i = 0; i < 5; i++) {
let a = i;
console.log(a);
}
// 1 2 3 4 5
仮に1つのfor
文における全ループブロックが単一のスコープであれば
let a
での再宣言でエラーが発生しているはずですが、
エラーは発生していないのでそういうことなのです。
この辺りまでは調べればそこそこ出てきます。
巻き上げ的な挙動はあるが、(巻き上げが問題になるようなコードであれば)エラーになる
console.log(a); // Uncaught ReferenceError: Cannot access 'a' before initialization
let a;
undefined の値で始まる var 変数と異なり、 let 変数は定義が評価されるまで初期化されません。変数を宣言より前で参照することは ReferenceError を引き起こします。ブロックの始めから変数宣言が実行されるまで、変数は "temporal dead zone" の中にいるのです。
定義を評価というのが具体的に何を指すのかちょっと曖昧な気もしますが
console.log(a);
// ひとつ上のコードとはちょっと違い、変数が存在しないエラー
// Uncaught ReferenceError: a is not defined
let a;
console.log(a);
// undefined
という挙動から考えると、let
宣言された行に到達=定義を評価と考えて良さそうです。
宣言自体は巻き上がっておらず、ブロック開始時点で宣言前の準備が行われている、という感じですね。
いずれにしても、このエラーが発生するようなコードには構造上の問題があると考えた方が良いと思います。
これを知ってたら中級くらい? 実務上で遭遇することは結構ありそう。
あんまり知られてなさそうな性質
本題です。
冒頭にも書きましたが、以下はfor
文との組み合わせの際に表れる性質です。
「for
文の存在するブロックスコープ」と「for
ループブロックスコープ」の間に、見えないブロックスコープが存在する(ような挙動をする)
{
// スコープ①
for (let i = 0; i < 10; i++) {
// スコープ②
i++;
console.log(i);
}
console.log(i);
}
// Uncaught ReferenceError: i is not defined
エラーが出ていることから、for
文の初期化部はスコープ①ではないことが分かります。
仮にスコープ②であったとしたら、各ループブロック内でのlet i
宣言はブロックごとに個別の変数になるので、
i++
でループ数が減少する説明がつきません。
なのでイメージとしては
{
{
let i;
for (i = 0; i < 10; i++) {
i++;
console.log(i);
}
}
console.log(i);
}
のようなコードの挙動が近しいです。
※このコードはあくまでイメージです。
後述する別の性質を破壊してしまうので、使ってはいけません。
for
文において、ある条件下でのみ「別の変数の参照(のような挙動)」が起こる
発端となった挙動の原因です。
なぜこうなるのか
それらしい記載のあるサイトがあまりなかったのですが
つまり、for (let...)ループが複数回実行され、そのループにクロージャーが含まれている場合(話している猫の例のように)、すべてのクロージャーが同じループ変数をキャプチャするのではなく、各クロージャーがループ変数の異なるコピーをキャプチャします。
とのこと。
発生する条件は異なりますが、関数内でarguments
という変数が自動で使えるようになっていたり、
状況に応じたオブジェクトがthis
に束縛されているのに近しいイメージでしょうか。
確かに
for (let i = 0; i < 5; i++) {
setTimeout(() => {
i *= 2;
console.log(i);
}, 100);
}
// 0 2 4 6 8
と、各クロージャ内でのi
は、それぞれ別のi
として存在しているように見えます。
これなら確かにBの挙動になるのも頷けるのですが・・・
色々試してみる
色々なコードを試してみるうちに、この説明もちょっと違っている気がしてきました。
(5年前の記事なので、当時とパーサの解釈が違う可能性はありますが)
即時関数
for (let i = 0; i < 5; i++) {
(() => {
i++;
console.log(i);
})();
}
console.log('end');
// 1 3 5 end
クロージャ内の処理ではあるものの、普通にfor
文の初期化部のlet i
を参照しちゃっているようです。
関数定義+同期実行
for (let i = 0; i < 5; i++) {
function f() {
i++;
console.log(i);
};
f();
}
console.log('end');
// 1 3 5 end
やってることは即時関数と変わらないので、まあこうなるよなといった印象。
Promise
のexecutor
for (let i = 0; i < 5; i++) {
new Promise(resolve => {
i++;
console.log(i);
});
}
console.log('end');
// 1 3 5 end
Promise
と言えどexecutor
は同期的に実行されるので、まあこうなるよなといった印象。
Promise.then
for (let i = 0; i < 5; i++) {
new Promise(resolve => {
resolve();
})
.then(() => {
i++;
console.log(i);
});
}
console.log('end');
// end 1 2 3 4 5
動いた。
ほぼ即時的な処理であっても非同期処理であればいいんだろうか?
setTimeout
のdelay = 0
for (let i = 0; i < 5; i++) {
setTimeout(()=>{
i++;
console.log(i);
}, 0);
}
console.log('end');
// end 1 2 3 4 5
こっちもPromise.then
と同じような処理だしやはり動いた。
setInterval
for (let i = 0; i < 5; i++) {
let c = 0;
const p = setInterval(()=>{
i += 10;
console.log(i);
if (c++ === 1) {
clearInterval(p);
}
}, 0);
}
console.log('end');
// end 10 11 12 13 14 20 21 22 23 24
同一の関数を複数回実行しても、ちゃんとそれぞれのクロージャごとに個別のi
を参照していそう
ここまでくると、クロージャが非同期処理として実行されることが条件のようにも思えますが・・・
クロージャをfor
文の全ループの完了後に実行
const a = [];
for (let i = 0; i < 5; i++) {
a.push(function() {
i++;
console.log(i);
});
}
console.log('end');
for (let j = 0; j < a.length; j++) {
a[j]();
}
// end 1 2 3 4 5
非同期処理ではないですが、同期処理としての順番を変えたら参照する変数が変わっているようです。
念のため、外部スコープの配列に格納しているせい、という可能性もあるので・・・
const a = [];
for (let i = 0; i < 5; i++) {
a.push(function() {
i++;
console.log(i);
});
a[a.length - 1]();
}
console.log('end');
// 1 3 5 end
let i
参照に戻っているので、特に関係はなさそうです。
新たな参照先が生成されるタイミング
クロージャがそれぞれ個別のi
を生成するタイミングも調べておきます。
for (let i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i);
}, 10);
i++;
}
// 1 3 5
ちょっとわかりづらいですが、
-
let i = 0
(0) - クロージャ定義 (0)
- ループ内最下部の
i++
(1) -
for
文の更新部のi++
(2) - クロージャ定義 (2)
- ループ内最下部の
i++
(3) -
for
文の更新部のi++
(4) - クロージャ定義 (4)
- ループ内最下部の
i++
(5) -
for
文の更新部のi++
(6) -
for
文終了 (6)
()内は そのときのi
の値です。
どうやら、クロージャが定義された瞬間のi
ではなく、
クロージャ定義後のi++
が反映された状態のi
が新たな参照先変数の値になっているようです。
結論
ということで、Bのような動作をする理由は、
1. for
文の初期化部で変数がlet
宣言されている(仮にlet i
とする)
2. for
ループブロック内で関数(クロージャ)が定義されている (仮にf
とする)
3. f
の内部から、i
が参照されている
4. for
ループ処理が完了した後にf
が実行されている
の全ての条件を満たした場合
f
内で参照しているi
の値が、「f
実行時のi
の値」ではなく「f
が定義されたスコープの末端に到達した時点でのi
の値」に束縛される
という性質に当てはまっているコードだから、ということなのではないかと思います。
ちなみに条件の1
についてですが、let
での宣言をfor
文の初期化部以外にしてしまうと
let i;
for (i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i);
}, 10);
}
// 5 5 5 5 5
と、クロージャごとに個別の参照先にならなくなります。
あくまで for
文の初期化部でlet
宣言した時のみ、の性質ですね。
起こりえる問題
for (let i = 0, finished = 0; i < 5; i++) {
setTimeout(() => {
if (++finished === 5) {
console.log('finished');
}
}, Math.random() * 100);
}
for
文の初期化部は独立したスコープになっている、という性質を考えると
for
ループ内からのみアクセスできる変数が欲しいみたいなときに、
for
文の初期化部に宣言しちゃうことがあるかもしれません。
上のコードでは、finished
を「完了した非同期処理の数を表すカウンタ」のつもりで定義しています。
ですが、この性質のせいでfinished
もクロージャごとに個別な存在になってしまい、let finished
はいつまで経っても5になりません。
初期化部で複数の変数を宣言するコードは割と見かけるので、意外とハマる可能性はあるかも?
余談
ずっと for
文で説明してきましたが、for-in
やfor-of
でも同様の挙動になります。
また for-in
とfor-of
では、let
の代わりにconst
を使用することができますが、
この場合もやはり同様の挙動になるようです。
まとめ
なんだかlet
の性質というより、for
文の性質になってるような気がしつつ・・・。
javascript
にもまだまだ知らないこと沢山だなー。