LoginSignup
5
4

More than 3 years have passed since last update.

`let`にあんまり知られてなさそうな性質が結構あった話

Last updated at Posted at 2020-01-18

前書き

A
for (var i = 0; i < 5; i++) {
    setTimeout(() => {
        console.log(i);
    }, 100);
}

// 5 5 5 5 5
B
for (let i = 0; i < 5; i++) {
    setTimeout(() => {
        console.log(i);
    }, 100);
}

// 0 1 2 3 4

この2つのコード、varletの違いとしてそこそこ有名なものだと思います。

この動作自体に文句はありませんし、自分もそういうもんだと思って使っていたのですが・・・

よくよく考えるとBのコード上では

  1. console.log(i)letで宣言された変数iを参照している
  2. letで宣言された変数iは、for文によってインクリメントされている
  3. 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;

MDN

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

やってることは即時関数と変わらないので、まあこうなるよなといった印象。

Promiseexecutor

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

動いた。
ほぼ即時的な処理であっても非同期処理であればいいんだろうか?

setTimeoutdelay = 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

ちょっとわかりづらいですが、

  1. let i = 0 (0)
  2. クロージャ定義 (0)
  3. ループ内最下部のi++ (1)
  4. for文の更新部のi++ (2)
  5. クロージャ定義 (2)
  6. ループ内最下部のi++ (3)
  7. for文の更新部のi++ (4)
  8. クロージャ定義 (4)
  9. ループ内最下部のi++ (5)
  10. for文の更新部のi++ (6)
  11. 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-infor-of でも同様の挙動になります。

また for-infor-ofでは、letの代わりにconstを使用することができますが、
この場合もやはり同様の挙動になるようです。

まとめ

なんだかletの性質というより、for文の性質になってるような気がしつつ・・・。
javascriptにもまだまだ知らないこと沢山だなー。

5
4
2

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
5
4