JavaScriptは、非常に関数指向の言語です。それは私たちに多くの自由を与えてくれます。関数はいつでも作成でき、別の関数の引数として渡され、後でコードのまったく異なる場所から呼び出すことができます。
関数がその外部の変数(「外側の」変数)にアクセスできることは既に知っています。
しかし、関数が作成されてから外部の変数が変更された場合はどうなるでしょうか?関数は新しい値を取得するのでしょうか、それとも古い値を取得するのでしょうか?
また、関数が引数として渡され、コードの別の場所から呼び出された場合、新しい場所で外部変数にアクセスできるでしょうか?
これらのシナリオやより複雑なものを理解するために、知識を広げましょう。
let/const
変数について説明しますJavaScriptでは、変数を宣言する方法は3つあります。let
、const
(現代的なもの)、および var
(過去の残存物)です。
- この記事では、例の中で
let
変数を使用します。 const
で宣言された変数も同様に動作するため、この記事はconst
についても扱います。- 古い
var
にはいくつかの注目すべき違いがあり、それらは記事 古い"var" で説明します。
コードブロック
変数がコードブロック {...}
の中で宣言されている場合、そのブロック内でのみ可視になります。
例:
{
// do some job with local variables that should not be seen outside
let message = "Hello"; // only visible in this block
alert(message); // Hello
}
alert(message); // Error: message is not defined
これを使って、独自のタスクを実行するコードの一部を、それにのみ属する変数で分離することができます。
{
// show message
let message = "Hello";
alert(message);
}
{
// show another message
let message = "Goodbye";
alert(message);
}
既存の変数名で let
を使用する場合、別のブロックがないとエラーが発生することに注意してください。
// show message
let message = "Hello";
alert(message);
// show another message
let message = "Goodbye"; // Error: variable already declared
alert(message);
if
、for
、while
などについては、{...}
で宣言された変数も内部でのみ可視になります。
if (true) {
let phrase = "Hello!";
alert(phrase); // Hello!
}
alert(phrase); // Error, no such variable!
ここで、if
が完了した後、下の alert
は phrase
を認識しないため、エラーになります。
これにより、if
ブランチに固有のブロックローカル変数を作成できるため、これは非常に便利です。
同様のことが for
および while
ループにも当てはまります
for (let i = 0; i < 3; i++) {
// the variable i is only visible inside this for
alert(i); // 0, then 1, then 2
}
alert(i); // Error, no such variable
視覚的には、let i
は {...}
の外側にあります。しかし、for
構文はここで特別です。その内部で宣言された変数は、ブロックの一部とみなされます。
ネストされた関数
関数が別の関数の内部で作成された場合、その関数は「ネストされた」と呼ばれます。
JavaScriptでは、これを簡単に行うことができます。
これを使って、以下のようにコードを整理できます
function sayHiBye(firstName, lastName) {
// helper nested function to use below
function getFullName() {
return firstName + " " + lastName;
}
alert( "Hello, " + getFullName() );
alert( "Bye, " + getFullName() );
}
ここで、ネストされた関数 getFullName()
は利便性のために作成されています。外側の変数にアクセスできるため、フルネームを返すことができます。ネストされた関数はJavaScriptでは非常に一般的です。
さらに興味深いことに、ネストされた関数は返すことができます。新しいオブジェクトのプロパティとして、または結果として単独で返すことができます。その後、別の場所で使用できます。どこで使用しても、同じ外側の変数にアクセスできます。
以下では、makeCounter
は呼び出しごとに次の数を返す「カウンター」関数を作成します。
function makeCounter() {
let count = 0;
return function() {
return count++;
};
}
let counter = makeCounter();
alert( counter() ); // 0
alert( counter() ); // 1
alert( counter() ); // 2
簡単に見えますが、そのコードをわずかに変更したバリエーションには、たとえば、疑似乱数ジェネレーターとして、自動化されたテスト用の乱数値を生成するなど、実用的な用途があります。
これはどのように機能するのでしょうか?複数のカウンターを作成した場合、それらは独立するのでしょうか?ここでは変数に何が起きているのでしょうか?
このようなことを理解することは、JavaScript全体の知識に役立ち、より複雑なシナリオに役立ちます。したがって、少し深く掘り下げてみましょう。
レキシカル環境
詳細な技術的な説明がこれからあります。
低レベルの言語の詳細を避けたいためですが、それらなしでは理解が不十分で不完全になるため、準備をしてください。
明確にするために、説明は複数のステップに分割されています。
ステップ 1. 変数
JavaScriptでは、実行中のすべての関数、コードブロック {...}
、およびスクリプト全体に、レキシカル環境と呼ばれる内部(非表示)の関連オブジェクトがあります。
レキシカル環境オブジェクトは、次の2つの部分で構成されています
- 環境レコード – ローカル変数をプロパティとして(および
this
の値のようなその他の情報)保存するオブジェクト。 - 外部レキシカル環境への参照、つまり外側のコードに関連付けられているものです。
「変数」は、特別な内部オブジェクトである Environment Record
のプロパティにすぎません。「変数を取得または変更する」とは、「そのオブジェクトのプロパティを取得または変更する」ことを意味します。
関数なしのこの単純なコードでは、レキシカル環境は1つしかありません
これは、スクリプト全体に関連付けられた、いわゆるグローバルレキシカル環境です。
上の図では、長方形は環境レコード(変数ストア)を意味し、矢印は外部参照を意味します。グローバルレキシカル環境には外部参照がないため、矢印は null
を指しています。
コードの実行が開始され、続行されるにつれて、レキシカル環境は変化します。
もう少し長いコードを次に示します
右側の長方形は、実行中にグローバルレキシカル環境がどのように変化するかを示しています。
- スクリプトが開始すると、レキシカル環境は宣言されたすべての変数で事前に入力されます。
- 最初は、それらは「未初期化」状態です。それは特別な内部状態であり、エンジンが変数を認識していることを意味しますが、
let
で宣言されるまで参照することはできません。それは、変数が存在しないのとほぼ同じです。
- 最初は、それらは「未初期化」状態です。それは特別な内部状態であり、エンジンが変数を認識していることを意味しますが、
- 次に
let phrase
定義が表示されます。まだ代入がないため、その値はundefined
です。この時点から変数を前方で使用できます。 phrase
に値が代入されます。phrase
の値が変更されます。
今のところ、すべてが単純に見えますね?
- 変数は、現在実行中のブロック/関数/スクリプトに関連付けられた特別な内部オブジェクトのプロパティです。
- 変数を使用した作業は、実際にはそのオブジェクトのプロパティを使用した作業です。
「レキシカル環境」は仕様オブジェクトです。言語仕様で、物事がどのように機能するかを説明するために「理論的に」のみ存在します。コード内でこのオブジェクトを取得して直接操作することはできません。
JavaScriptエンジンは、メモリを節約するために未使用の変数を破棄したり、記述されているように目に見える動作が維持されている限り、他の内部トリックを実行したりして、最適化することもできます。
ステップ 2. 関数宣言
関数も、変数のような値です。
違いは、関数宣言がすぐに完全に初期化されることです。
レキシカル環境が作成されると、関数宣言はすぐに使用可能な関数になります(宣言まで使用できない let
とは異なります)。
そのため、関数宣言として宣言された関数は、宣言自体よりも前に使用することができます。
たとえば、関数を追加するときのグローバルレキシカル環境の初期状態を次に示します
当然ながら、この動作は関数宣言にのみ適用され、関数を変数に代入する関数式、たとえば let say = function(name)...
などには適用されません。
ステップ 3. 内部および外部のレキシカル環境
関数が実行されると、呼び出しの開始時に、呼び出しのローカル変数とパラメーターを格納するために新しいレキシカル環境が自動的に作成されます。
たとえば、say("John")
の場合、次のようになります(実行は、矢印でラベル付けされた行にあります)
関数呼び出し中に、2つのレキシカル環境があります。内部のレキシカル環境(関数呼び出し用)と外部のレキシカル環境(グローバル)です。
- 内部レキシカル環境は、
say
の現在の実行に対応します。単一のプロパティである関数引数name
があります。say("John")
を呼び出したため、name
の値は"John"
です。 - 外部のレキシカル環境は、グローバルレキシカル環境です。そこには
phrase
変数と関数自体があります。
内部のレキシカル環境には、outer
への参照があります。
コードが変数にアクセスしようとすると、最初に内部のレキシカル環境が検索され、次に外部のレキシカル環境、次にさらに外部のレキシカル環境が検索され、グローバルなレキシカル環境まで検索されます。
変数がどこにも見つからない場合、厳格モードではエラーになります(use strict
がない場合、存在しない変数への代入は、古いコードとの互換性のために新しいグローバル変数を作成します)。
この例では、検索は次のように進みます
name
変数については、say
内のalert
は、内部のレキシカル環境ですぐにそれを見つけます。phrase
にアクセスしようとすると、ローカルにphrase
がないため、外部レキシカル環境への参照をたどり、そこでそれを見つけます。
ステップ 4. 関数の返却
makeCounter
の例に戻りましょう。
function makeCounter() {
let count = 0;
return function() {
return count++;
};
}
let counter = makeCounter();
makeCounter()
の各呼び出しの開始時に、この makeCounter
の実行用の変数を格納するために、新しいレキシカル環境オブジェクトが作成されます。
そのため、上の例と同じように、2つのネストされたレキシカル環境があります
異なる点は、makeCounter()
の実行中に、1行だけの小さなネストされた関数、return count++
が作成されることです。まだ実行するのではなく、作成するだけです。
すべての関数は、作成されたレキシカル環境を記憶します。技術的には、ここに魔法はありません。すべての関数には [[Environment]]
という名前の非表示プロパティがあり、関数が作成されたレキシカル環境への参照が保持されます
さて、counter.[[Environment]]
は {count: 0}
のレキシカル環境への参照を持っています。これが、関数がどこで呼び出されたかに関わらず、どこで作成されたかを記憶する方法です。[[Environment]]
参照は、関数作成時に一度だけ設定され、その後は変わりません。
後で counter()
が呼び出されると、その呼び出しのために新しいレキシカル環境が作成され、その外側のレキシカル環境への参照は counter.[[Environment]]
から取得されます。
counter()
の内部のコードが count
変数を探すとき、まず自身のレキシカル環境(ローカル変数がないため空)を検索し、次に外側の makeCounter()
呼び出しのレキシカル環境を検索し、そこで変数を見つけて変更します。
変数は、それが存在するレキシカル環境内で更新されます。
以下は実行後の状態です。
もし counter()
を複数回呼び出すと、count
変数は同じ場所で 2
、3
のように増加します。
プログラミングには一般的に「クロージャ」と呼ばれる用語があり、開発者は通常知っておくべきものです。
クロージャは、外側の変数を記憶し、それにアクセスできる関数です。一部の言語では、それは不可能であったり、そうするために関数を特別な方法で記述する必要があったりします。しかし、上で説明したように、JavaScript では、すべての関数が自然にクロージャです(例外は 1 つだけで、「new Function」構文で説明します)。
つまり、関数は隠された [[Environment]]
プロパティを使って、どこで作成されたかを自動的に記憶し、そのコードは外側の変数にアクセスできます。
面接でフロントエンド開発者が「クロージャとは何か?」という質問を受けた場合、妥当な回答は、クロージャの定義と、JavaScript のすべての関数がクロージャであるという説明、そして技術的な詳細についてのいくつかの言葉([[Environment]]
プロパティとレキシカル環境の仕組み)かもしれません。
ガベージコレクション
通常、レキシカル環境は、関数呼び出しが完了すると、すべての変数とともにメモリから削除されます。これは、それへの参照がないためです。JavaScript オブジェクトと同様に、それは到達可能な間だけメモリに保持されます。
ただし、関数の終了後も到達可能なネストされた関数がある場合、それはレキシカル環境を参照する [[Environment]]
プロパティを持っています。
その場合、レキシカル環境は関数の完了後も到達可能であるため、生き続けます。
例:
function f() {
let value = 123;
return function() {
alert(value);
}
}
let g = f(); // g.[[Environment]] stores a reference to the Lexical Environment
// of the corresponding f() call
もし f()
が何度も呼び出され、結果の関数が保存されると、対応するすべてのレキシカル環境オブジェクトもメモリに保持されることに注意してください。以下のコードでは、それらはすべて 3 つあります。
function f() {
let value = Math.random();
return function() { alert(value); };
}
// 3 functions in array, every one of them links to Lexical Environment
// from the corresponding f() run
let arr = [f(), f(), f()];
レキシカル環境オブジェクトは、(他のオブジェクトと同様に)到達不可能になったときに消滅します。言い換えれば、少なくとも 1 つのネストされた関数がそれを参照している間だけ存在します。
以下のコードでは、ネストされた関数が削除された後、それを取り囲むレキシカル環境(したがって value
)がメモリからクリーンアップされます。
function f() {
let value = 123;
return function() {
alert(value);
}
}
let g = f(); // while g function exists, the value stays in memory
g = null; // ...and now the memory is cleaned up
実際の最適化
見てきたように、理論上は、関数が生きている間は、すべての外側の変数も保持されます。
しかし実際には、JavaScript エンジンはそれを最適化しようとします。エンジンは変数の使用状況を分析し、外側の変数が使用されていないことがコードから明らかな場合は、削除します。
V8 (Chrome、Edge、Opera) の重要な副作用は、そのような変数がデバッグで利用できなくなることです。
以下の例を Chrome で開発者ツールを開いた状態で実行してみてください。
一時停止したら、コンソールで alert(value)
と入力してください。
function f() {
let value = Math.random();
function g() {
debugger; // in console: type alert(value); No such variable!
}
return g;
}
let g = f();
g();
ご覧のとおり、そのような変数はありません!理論上は、アクセスできるはずですが、エンジンがそれを最適化して削除しました。
これは、(時間を費やすものではないにしても)面白いデバッグの問題につながる可能性があります。その1つは、期待されるものではなく、同じ名前の外側の変数が見えてしまうことです。
let value = "Surprise!";
function f() {
let value = "the closest value";
function g() {
debugger; // in console: type alert(value); Surprise!
}
return g;
}
let g = f();
g();
V8 のこの機能を知っておくと役立ちます。Chrome/Edge/Opera でデバッグしていると、遅かれ早かれこれに遭遇することでしょう。
これはデバッガーのバグではなく、むしろ V8 の特別な機能です。おそらくいつか変更されるでしょう。このページで例を実行することで、いつでも確認できます。
コメント
<code>
タグを、複数行の場合は<pre>
タグで囲み、10行を超える場合はサンドボックス (plnkr, jsbin, codepen…) を使用してください。