본문 바로가기
描く

JavaScriptの変数宣言はletにすべきか 『入門JavaScriptプログラミング』から解説

by 엘리후 2021. 7. 3.

 JavaScriptの変数宣言で馴染み深いvarは、letとconstが追加されたことで使われなくなっていくだろう、と『入門JavaScriptプログラミング』の著者J.D.Isaacksは書いています。再代入が必要ないときはconst、再代入が必要なときはletを使うほうがいいのだと。今回、本書からletについて詳しく紹介します。本書ではconstについても解説していますので、ぜひチェックしてみてください。


本記事は『入門JavaScriptプログラミング』の「LESSON 4 letを使った変数宣言」からの抜粋です。掲載に当たり、一部を編集しています。

letを使った変数宣言

 今回は、次の内容を取り上げる。

letのスコープの仕組みとvarのスコープとの違い

ブロックスコープと関数スコープの違い

letで宣言された変数が巻き上げられる仕組み

 JavaScriptの歴史において、変数は常にvarキーワードを使って宣言されてきた(*1)。ES6では、変数を宣言する手段として、letキーワードとconstキーワードの2つが新たに導入されている(*2)。これらのキーワードを使って宣言された変数の動作は、varで宣言された変数のものとは少し異なっている。letの主な違いは次の2つである。

letで宣言された変数には、異なるスコープルールが適用される。

letで宣言された変数は巻き上げ時の動作が異なる。

1 実際には、非Strictモードでは、var宣言を完全に省略した上で新しい変数を作成することが可能である。ただし、その場合はグローバル変数が作成され、通常は作成者が気づかないうちにひどいバグが紛れ込む。Strictモードでvarが要求されるのは、そのためである。

2 厳密には、constは変数ではなく定数である。

Warming UP!

 次の2つのfor文について考えてみよう。唯一の違いは、1つ目のfor文がvarで宣言されたイテレータを使用しているのに対し、2つ目のfor文がletで宣言されたイテレータを使用していることである。しかし、最終的な結果は大きく異なる。これらのfor文を実行したときの結果はどうなると思うか。

for (var i = 0; i < 5; i++) { setTimeout(function () { console.log(i); }, 1); }; for (let n = 0; n < 5; n++) { setTimeout(function () { console.log(n); }, 1); };

letのスコープの仕組み

 letで宣言された変数はブロックスコープ(block scope)を持つ。つまり、それらの変数にアクセスできるのは、それらの変数が宣言されたブロック(またはサブブロック)の内側だけである。

if (true) { let foo = 'bar'; } console.log(foo); // fooは宣言されたブロックの外側では存在しないため、エラーになる

 これにより、変数がはるかに予測可能なものとなり、宣言されたブロックの外側で使用されたためにバグが紛れ込む、ということがなくなる。ブロック(block)とは、文または関数の本体―――つまり、開きかっこ(f)と閉じかっこ(g)で囲まれた領域のことである。なお、文に紐付けられない独立したブロックを作成する場合にも、これらの波かっこを使用できる(リスト1)。

リスト1:独立したブロックを使って変数をプライベートに保つ

let read, write; { // 独立したブロックを開く let data = {}; // dataは実質的にプライベート変数 write = function (key, val) { data[key] = val; } read = function (key) { return data[key]; } } // 独立したブロックを閉じる write('message', 'Welcome to ES6!'); read('message'); // "Welcome to ES6!" console.log(data); // ブロックの外側でdataを参照しているのでエラーになる

 リスト1では、readとwriteはブロックの外側で宣言されているが、それらの値はdataが宣言されているブロックの内側で代入されている。このため、readとwriteはdata変数にアクセスできる。ただし、このブロックの外側でdataにアクセスすることはできないため、dataはreadとwriteが内部データの格納に使用できるプライベート変数になる。

 通常、letで宣言された変数のスコープは特定のブロック(その変数が宣言されたブロック)となる。ただし、このルールには、forループに関する例外が1つある。for句の内側において、letで宣言された変数のスコープは、forループのブロックになるのである。

for (let i = 0; i < 5; i++) { // i はfor 句で宣言されている console.log(i); // i のスコープはfor ループのブロック } // for ループの外側ではi を参照できないため、エラーになる console.log(i);

ブロックスコープのletが推奨されるのはなぜか

 varで宣言された変数は関数スコープ(function scope)を持つため、その変数には宣言された関数のどこからでもアクセスできる。

(function() { if (true) { var foo = 'bar'; } console.log(foo); // foo が宣言されたif 文の外側でfoo を参照 }());

 これは開発者をずっと悩ませてきた問題であり、誤った思い込みがバグにつながる。リスト2に示す典型的な例を見てみよう。

リスト2:スコープの問題が存在するが、それは何か?

<ul> <li>one</li> <li>two</li> <li>three</li> <li>four</li> <li>five</li> </ul> ... <script type="javascript"> var items = document.querySelectorAll('li'); for (var i = 0; i < 5; i++) { var li = items[i]; li.addEventListener('click', function() { alert(li.textContent + ':' + i); }); }; </script>

 document.querySelectorAllは標準的なWeb APIメソッドであり、指定されたクエリとマッチするDOMノードをすべて選択できるようにする。addEventListenerはイベントリスナを登録できるDOMノードのメソッドである。リスト2のコードは、各リストアイテムにイベントリスナを割り当て、アイテムがクリックされたときにそのアイテムのテキストとインデックスを表示するように見える。つまり、最初のリストアイテムがクリックされた場合は、one:0が表示されるはずである。実際はどうなるかというと、どのリストアイテムがクリックされたかに関係なく、常に同じfive:5が表示される(*3)。というのも、このコードにはバグがあるからだ。関数レベルのスコープのせいで、このバグは多くの開発者に噛みついてきた。

3 訳注:環境によっては、five:5ではなく、文字列部分のみ(one、twoなど)が表示されることがある。

 バグがどこにあるかわかっただろうか。これらの変数のスコープはfor文ではないため、イテレーションごとに同じ変数が使用される。何が起きているのか順番に確認してみよう。

変数iが値0で宣言される。

forループの1回目のイテレーションが実行される。

- iは0、liは1つ目のリストアイテム。

forループの2回目のイテレーションが実行される。

- iは1、liは2つ目のリストアイテム。

forループの3回目のイテレーションが実行される。

- iは2、liは3つ目のリストアイテム。

forループの4回目のイテレーションが実行される。

- iは3、liは4つ目のリストアイテム。

forループの5回目のイテレーションが実行される。

- iは4、liは5つ目のリストアイテム。

iが5に加算されたところで、forループが終了する。

 つまり、forループが完了した後、iには5、liには5つ目のリストアイテムが設定されている。forループがテキストをすぐに表示していたとしたら、バグが発現することはなかっただろう。しかし、alert文はイベントリスナの中で設定されており、イベントリスナはforループが完了するまで呼び出されない。このため、いずれかのイベントが発現し、iとli.textContent の値が表示されるときには、それらの値は5と"five"に設定されている。

 これまでは、変数をプライベートに保つために、独立したブロックを使ってコードにスコープを適用した。通常、このようなスコープはIIFEを使って作成する。

(function () { var foo = 'bar'; }()); console.log(foo); // ReferenceError

 IIFEを使ってリスト1を書き換えた場合は、次のようになるだろう。

var read, write; (function () { var data = {}; write = function write(key, val) { data[key] = val; } read = function read(key) { return data[key]; } }()); write('message', 'Back in ES5 land.'); read('message'); // "Back in ES5 land." console.log(data); // dataはスコープを外れているのでエラーになる

 このコードはスコープを作成するために関数を作成して呼び出している。このため、ぱっと見てはるかに複雑そうに見えるし、理解するのが難しいように思える。スコープを作成する手段として関数を使用するのはやりすぎに思えなくもないが、ES6がリリースされるまでは、これが唯一の選択肢だった。ブロックスコープによって状況は一変しており、IIFEは独立したブロックに置き換えればよくなっている。

クイックチェック1


 次のコードが定義されている場合、コンソールには何が出力されるでしょう。

var words = ["function", "scope"]; for(var i = 0; i < words.length; i++) { var word = words[i]; for(var i = 0; i < word.length; i++) { var char = word[i] console.log('char', i, char); } }

クイックチェック1の答え


 1つ目の単語"function"は処理されるが、2つ目の単語"scope"は処理されない。なぜなら、内側のループが完了したときには、iの値が7になっているため、2つ目の単語を処理せずに外側のループが終了するからである。技術的には、この問題を解決するには、変数iを両方ともvarではなくletで宣言すればよい。ただし、これは変数のシャドーイング(variable shadowing)と呼ばれるもので、一般に悪い作法であると見なされている。このため、内側のループの変数には別の名前を使用することをお勧めする。

letによる変数の巻き上げの仕組み

 letおよびvarで宣言された変数には、巻き上げ(hoisting)という振る舞いがある。つまり、変数が宣言されたスコープの内側では、変数がスコープ全体(letではブロック全体、varでは関数全体)を消費する。この振る舞いは、変数が宣言されたスコープに関係なく適用される。

if (condition) { // ------ myData のスコープはここから----- doSomePrework(); /* その他のコード*/ let myData = getData(); /* その他のコード*/ doSomePostwork(); // ------ ここまで----- }

 つまり、コードのこのセクションでは、myData変数はスコープ内にあり、まだ宣言される前であってもアクセスできる。これは本当であり、同じ名前の別の変数がスコープのすぐ外側に存在する場合は、やや直観に反する結果になる。次の例について考えてみよう。

// ------ 外側のmyDataのスコープはここから----- let myData = getDefaultData(); if (condition) { // ------ 内側のmyDataのスコープはここから----- doSomePrework(myData); // <-- どちらのmyData? /* その他のコード*/ let myData = getData(); /* その他のコード*/ doSomePostwork(); // ------ 内側のmyDataのスコープはここまで----- } // ------ 外側のmyDataのスコープはここまで-----

 この場合、実際にはmyData変数が2つ存在する。1つはif文の内側でのみ存在する。もう1つの変数のスコープはif文を含んでいる。これがやっかいな問題であることは直観的にわかる。doSomePrework関数が呼び出された時点では内側のmyDataが宣言されていないため、「外側のmyDataとそのデフォルト値がdoSomePrework関数に渡される」と考えたとしてもおかしくないからだ。しかし、そうはならない。内側のmyDataはそのスコープ全体を消費するため、doSomePrework関数に渡されるのはこちらのmyDataである。この変数は巻き上げられ、宣言される前であっても使用される。

 変数が宣言される前にスコープ内となるこの「巻き上げ」という概念は、実際には新しいものではない。letは変数をブロックの先頭へ巻き上げ、varは関数の先頭へ巻き上げる。しかし、それよりも重要なのは、letで宣言される変数が宣言される前に実際にアクセスされたらどうなるかである。

 letで宣言される変数が、宣言される前にスコープ内で実際にアクセスされた場合は、参照エラー(ReferenceError)となる。対照的に、varで宣言される変数には、スコープ内であれば宣言される前であってもアクセスできるが、その値は常に未定義となる。letで宣言された変数にその宣言よりも前にアクセスできるものの、実際にアクセスするとエラーになる領域(ゾーン)は、TDZ(TemporalDeadZone)と呼ばれる。つまり、TDZとは、変数が宣言される前にスコープ内となる領域のことである。TDZでの変数参照はすべて参照エラーとなる。

{ console.log(foo); // foo はまだ宣言されていないため、エラーになる let foo = 2; }

 次のコードについて考えてみよう。このコードを実行すると、コンソールに何が出力されるだろうか。

let num = 10; function getNum() { if (!num) { let num = 1; } return num; } console.log( getNum() );

 答えが10であると考えた場合は、正解である。ただし、見逃しやすいものの、ここで起きていることはそれだけではない。この例を少し変更すれば、何が起きているのかがわかる。

let num = 0; function getNum() { if (!num) { let num = 1; } return num; } console.log( getNum() );

 何が出力されると思っただろうか。答えが1なら、不正解である。なぜだろうか。

let num = 0; function getNum() { if (!num) { // ------ 内側のnumのスコープはここから----- let num = 1; // 新しいlet変数を1の値で宣言 // ------ 内側のnumのスコープはここまで----- } return num; // 新しいlet変数はスコープを外れたので、この変数は0のまま } console.log( getNum() );

 この問題を修正するには、numに1を代入するときにletを取り除けばよい。

let num = 0; function getNum() { if (!num) { num = 1; } return num; } console.log( getNum() );

クイックチェック2


 次のコードが定義されている場合、コンソールには何が出力されるでしょう。

{ console.log('My lucky number is', luckNumber); let luckNumber = 2; console.log('My lucky number is', luckNumber); }

クイックチェック2の答え


 1つ目のconsole.log文でエラーになるため、何も出力されない。エラーになるのは、まだ宣言されていない変数にアクセスしようとするからである。

今後はvarの代わりにletを使用すべきか

 これは議論を呼ぶ問題である。「イエス」と考える開発者もいれば、「ノー」と考える開発者もいる。白状すると、筆者は前者である(*4)。

4 ただし、次のレッスンで説明するように、varの場合は、letよりもconstのほうが望ましいことが多い。

 varを使用する論拠は、変数が関数のルートで宣言される場合、その変数のスコープが関数全体であることを表明するためにvarを使用すべきである、というものである。この意見には賛同しかねる。筆者が思うに、これは今後も関数スコープで考えていきたいという開発者の願望の表れであり、ブロックスコープを受け入れる必要がある。letに関して言えば、関数もブロックの1つにすぎない。letがif文の内側で宣言されるとしても、for、while、あるいは他のブロックレベルの文の内側で宣言されるとしても、異なる種類の宣言が必要になることはない。それなのに、関数を特別扱いする必要がはたしてあるだろうか。

 関数のルートで宣言されたletのスコープはvarと同じだが、巻き上げの方法は異なる。変数宣言よりも前に変数にアクセスした場合、varは未定義になるが、letは例外をスローする。筆者の考えでは、未定義よりも例外のほうがよい振る舞いである。なぜなら、まだ宣言されていない変数を使用すると、微妙なバグにつながるからである。また、varを使用するのがよい考えであることについて納得のいく理由が見つかった試しもない。このことも、筆者がvarを使用しなくなった理由の1つである。

 ただし、varを完全にletに置き換えるのには反対である。既存のコードでvarを検索してletに置き換える際にはくれぐれも注意してほしい。既存のコードベースに対する全体的な変更はエラーのもとである。

まとめ

 このレッスンでは、letを使って変数を宣言する方法と、varで宣言された変数との違いについて説明した。

letで宣言された変数はブロックスコープを使用する。

ブロックスコープは、変数がスコープ内となるのは、その変数が宣言されたブロックの内側だけであることを意味する。

varで宣言された変数は関数スコープを使用する。

関数スコープは、変数がスコープ内となるのは、その変数が宣言された関数の内側全体であることを意味する。

var変数とは異なり、let変数をその宣言よりも前に参照することはできない。

練習問題

 このレッスンの内容を理解できたかどうか確認してみよう。

 Q1:次のコードによって作成される関数は、ある範囲の値を含んだ配列を生成する。このコードは、変数が宣言されているコンテキストの外側でアクセスされるのを阻止するために、varといくつかのIIFEを使用している。

関数全体をカバーするIIFEにより、DEFAULTSTARTとDEFAULTSTEPが隠蔽される。

別のIIFEにより、tmpがif文から抜け出すことは不可能となる。

さらに別のIIFEにより、forループの外側でiにアクセスすることは不可能となる。

 letを使ってこのコードを書き換え、これらのIIFEをすべて不要にしてみよう。

(function (namespace) { var DEFAULT START = 0; var DEFAULT STEP = 1; var range = function (start, stop, step) { var arr = []; if (!step) { step = DEFAULT STEP; } if (!stop) { stop = start; start = DEFAULT START; } if (stop < start) { (function () { // 値を入れ替える var tmp = start; start = stop; stop = tmp; }()); } (function () { var i; for (i = start; i < stop; i += step) { arr.push(i); } }()); return arr; } namespace.range = range; }(window.mylib));

回答

{ let DEFAULT START = 0; let DEFAULT STEP = 1; window.mylib.range = function (start, stop, step) { let arr = []; if (!step) { step = DEFAULT STEP; } if (!stop) { stop = start; start = DEFAULT START; } if (stop < start) { // 値を入れ替える let tmp = start; start = stop; stop = tmp; } for (let i = start; i < stop; i += step) { arr.push(i); } return arr; } }

댓글