한국어 | English | 日本語
Webアプリケーションエンジニア (経験8.8年)
技術・開発
engineering
ウェブフロントエンドと バックエンド開発を扱います

JavaScriptエンジンの実行プロセスから理解する「ホイスティング」と「クロージャ」

JavaScriptに初めて触れると、その使いやすさに驚くことがあります。C言語やJavaでは毎回コンパイルが必要でしたが、JavaScriptは今この記事を見ているブラウザの開発者モードのコンソールで直接コーディングできるため、軽快で使いやすい言語であることがわかります。コードはJavaScriptエンジン(特にV8)内でどのように実行されるのでしょうか?これを理解することで、変数と関数の関係であるホイスティング(Hoisting)とクロージャ(Closure)の概念を、単なる暗記ではなく原理として理解できるようになります。
私たちが作成した.jsファイルの実行を担うJavaScriptエンジンとは何かを簡単に見ていき、JavaScriptエンジンがどのように.jsファイルを実行するのかを理解します。その後、これまで暗記してきた「ホイスティング」と「クロージャ」の概念を改めて理解します。

JavaScript

JavaScriptはウェブページの3つの要素のうちの1つです。

JavaScriptは、関数を宣言して呼び出すことで同期的に実行することもできますし、コールバックを通じて特定のイベント時に非同期的に実行させることも可能です。

Chrome、Internet Explorer、Safariなど、さまざまなウェブブラウザがそれぞれ独自のHTML、CSS、JavaScriptエンジンを搭載しています。その中でも代表的なJavaScriptエンジンはChromeで使われているV8であり、本稿でもV8について扱います。ちなみに、ブラウザからJavaScriptエンジンだけを切り離し、非同期イベント処理ライブラリであるlibuvと結合してサーバーとして構築したのがNode.jsです。本当にそれだけです。

インタープリタ言語

JavaScriptはスクリプト言語であり、インタープリテーションを経るため、ブラウザのコンソールで一行一行の結果をすぐに確認できます。他のスクリプトと同様に、一つのファイルにまとめてバッチのように実行させることも可能ですが、その際は短いコンパイルの後、インタープリテーションが行われます。

JavaScriptは.jsファイルを実行する際、まず変数および関数宣言のみをスキャンするJIT(Just-In-Time)コンパイル過程を経てから実行されます。JITコンパイルは、私たちがよく知るC++やJavaのようなコンパイル言語で中間コードを作成するAOT(Ahead-of-Time)コンパイル過程とは異なります。 JavaScriptをインタープリタ言語として学ぶため、コンパイル段階がないと理解しがちですが、厳密にはコンパイル過程が存在します。しかし、コンパイル過程があるという事実だけでJavaScriptをコンパイル言語と呼ぶのは、コンパイル言語の定義とは異なるため、インタープリタ言語と呼ぶ方がより適切です。

V8におけるJITコンパイル

他のプログラミング言語のコンパイルと同様に、V8エンジンもASTの生成、バイトコード変換を通じてJavaScriptをコンパイルします。加えて、繰り返し変換されるバイトコードは毎回コンパイルすると効率が低下するため、キャッシングする独自のV8コンパイラソリューションを備えています。

ASTとバイトコードのキャッシングに関する過程は本稿の趣旨と異なるため、簡単に説明するに留めます。

JavaScriptエンジンとランタイム

プログラミング言語の実行には、実行可能な言語への変換、メモリへのロード、実行を管理するエンジンが当然存在します。JavaScriptエンジンがJavaScriptの実行機に該当し、setTimeoutのようにカーネルを使用するなど、豊かなJavaScript体験のために提供されるWeb APIなどを付加すると、それが私たちが使用するブラウザになります。

JavaScriptエンジン

JavaScriptエンジンは、2つのメモリ構成要素に分かれます。

変数と関数はヒープにロードされ、関数の呼び出し順序はスタックにロードされて、シングルスレッドを通じて順番に実行されます。

ここで、JavaScriptのスタックは他のプログラミング言語と少し異なります。他のプログラミング言語では、関数が実行されるたびに、その関数のローカル変数、パラメータ情報などもすべてコールスタックにロードされます。関数実行に必要なコンテキストをすべて含むことから、コールスタックに格納される各関数のメモリ領域をコンテキストスコープと呼ぶこともあります。それに対し、JavaScriptではコールスタックには関数実行順序のポインタのみをロードし、コンテキストスコープに該当する関数および変数はすべてヒープに格納します。 賢明な方々は気づくかもしれませんが、これはすべての関数の変数がヒープという一つの空間に区別なく集まることを意味し、ホイスティングとクロージャという概念が発生する概念的な起点に該当します。

JavaScriptランタイム

JavaScriptといえば、非同期処理について耳にタコができるほど聞きますが、シングルスレッドがいったいどのように非同期処理を行うのでしょうか?「非同期実行」と「非同期結果の格納」、そして「それを現在のシングルスレッドに持ってくる」のを助ける3つの要素が、以下で説明するJavaScriptランタイムに含まれているからです。

JavaScriptランタイムは、JavaScriptエンジンに以下の3つの構成要素が追加されます。

JavaScriptエンジンの実行プロセス

JavaScriptエンジンは、「JITコンパイル過程」と「実行過程」の2つに分かれてコードを実行します。これまでに読んだり学んだりした内容に基づいて、JavaScriptコードがエンジン内でどのように実行されるかを見ていきましょう。上で説明した用語を使用しますので、用語の理解が不十分な場合は、再度上の内容を確認してください。

コンパイルフェーズ(Compilation Phase)

コンパイルフェーズをわかりやすく言うと、ヒープにロードする過程と言えるでしょう。ヒープは先に説明した通り、関数実行に必要な内部のパラメータ、変数、関数を(スタックにロードする言語とは異なり)JavaScriptエンジンがロードする場所です。JavaScript実行時にどれほど多くの関数が定義され呼び出されるのでしょうか、その多くの関数のパラメータや関数内の変数が「一つのヒープ」に保存されるのでしょうか?では、どのように区別するのでしょうか?

関数が実行される時(後で学ぶ実行フェーズ)、その関数に対するスコープが生成され、関数パラメータおよび関数内変数はこのスコープにコンパイル段階(Compilation Phase)で定義およびロードされます。パラメータ、変数のスコープはすべてコンパイル段階で定義されるため、後に学ぶレキシカルスコープと呼ばれます。

以下のJavaScriptファイルを例に、コンパイル過程を調べてみましょう。

var a = 2;
b = 1;

function f(z) {
  b = 3;
  c = 4;
  var d = 6;
  e = 1;

  function g() {
    var e = 0;
    d = 3 * d;
    return d;
  }

  return g();
  var e;
}

f(1);
  1. JavaScriptの初回実行のために、main()関数のグローバルスコープ(window)領域をヒープに生成します。

JavaScriptを実行すると、最初に実行される関数はmain()であり、windowと呼ばれるグローバルスコープをヒープに作成することから始まりますwindowをグローバル変数の定義に使用した記憶があるでしょう。それが可能なのは、これ自体がグローバルスコープだからです。今後、ヒープにロードされるスコープ領域は以下のように表現します。

# Global Scope (window)
-
-

次に、生成されたグローバルスコープ(window)領域のヒープに、変数、関数宣言をロードします。

  1. var a変数宣言なので、**グローバルスコープ(window)**領域にaをロードします。
  2. b = 1変数代入なので、ヒープ(スコープ)にはロードしません。
# Global Scope (window)
- a =                                           <-- var a = 2;
-
  1. function f(z)関数宣言なので、**グローバルスコープ(window)**領域にfをロードします。
    • 関数ロード時には、f関数のバイトコード(blob)へのポインタ値を一緒にロードします。
# Global Scope (window)
- a =
- f = a pointer on f functions bytecode        <-- function f(z) {

JavaScriptで最初に実行されるmain()関数のコンパイルフェーズはこれで終了です。この関数(main())のコンパイルフェーズが終了すると、すぐにその関数の最初の行に戻り、これまでにロードしたスコープヒープを用いて、実行フェーズ、つまり関数実行を行います。

実行フェーズ(Execution Phase)

先行するコンパイルフェーズでは変数、関数宣言のみが行われましたが、この過程では変数代入が行われます。つまり、以前に変数宣言がどこで行われたかを探し、見つけた宣言に値を代入するという意味です。では、もし変数宣言が見つからなかったらどうなるでしょうか?

スコープチェーン(Scope Chain)

代入しようとする変数に対する宣言が、自分(関数)のスコープヒープで見つからなかった場合、以下の手順を踏みます。

この過程を、変数宣言の存在を連鎖的にスコープで探すという意味で、スコープチェーンと呼びます。 そうして探し回ってもグローバルスコープヒープにまで存在しなかった場合、グローバルスコープヒープに新しい変数を宣言すると同時に代入することになります。結局、グローバル変数となるわけです。


引き続き、実行フェーズの過程を例で説明します。

# Global Scope (window)
- a =
- f = a pointer for f functions bytecode
  1. a = 2変数代入なので、a変数を探して代入します。
    • **グローバルスコープ(window)**領域に変数aが存在します。
# Global Scope (window)
- a = 2                                         <-- var a = 2;
- f = a pointer for f functions bytecode
  1. b = 1変数代入なので、b変数を探して代入します。
    • **グローバルスコープ(window)**領域に変数bは存在しません。
      • そのため、新たにb変数を宣言すると同時に1を代入します。
# Global Scope (window)
- a = 2
- f = a pointer for f functions bytecode
- b = 1                                         <-- b = 1;
  1. f(1)関数呼び出しなので、f()宣言の有無を確認して実行します。
    • **グローバルスコープ(window)**領域に関数f()が存在します。
      • f()関数の実行のためには、再びコンパイルフェーズおよび実行フェーズが必要となるため、
        • ヒープにf()のための新しいローカル実行スコープ領域を生成します。
        • スコープチェーンのために、必ず自分を呼び出した親関数のスコープへのポインタを持ちます。
          • (hidden) A pointer for previous scope
# Global Scope (window)
- a = 2
- f = a pointer for f functions bytecode
- b = 1

# Local Execution Scope for f()                 <-- f(1);
+ (hidden) A pointer for previous scope (= Global Scope (window))
-
-
  1. f(1)関数実行時、新たに生成されたローカル実行スコープ
    • 再びコンパイルフェーズ過程を通じて変数、関数宣言をロードすると、以下のようになります。
# Global Scope (window)
- a = 2
- f = a pointer for f functions bytecode
- b = 1

# Local Execution Scope for function f()
+ (hidden) a pointer for previous scope (= Global Scope (window))
- z =
- d =
- e =
  1. f(1)実行フェーズ過程を終えると
    • 以下のように変数代入および関数g()に対するもう一つのローカル実行スコープが生成されます。
# Global Scope (window)
- a = 2
- f = a pointer for f functions bytecode
- b = 3

# Local Execution Scope for function f()
+ (hidden) a pointer for previous scope (= Global Scope (window))
- z = 1
- d = 6
- e = 1
- c = 4

# Local Execution Scope for function g()
+ (hidden) a pointer for previous scope (= Local Execution Scope for function f())
- e =

g()関数からは、読者自身で書いてみて復習することをおすすめします。(決して面倒だからではありません。)

JavaScript変数の特性

実は、JavaScriptエンジンの実行プロセスを学んだ理由は、プロセス自体を理解するためでもありましたが、以下の内容を説明するための準備でもありました。JavaScriptエンジンの動作によってJavaScript特有のいくつかの特性が生まれましたが、すべて見ていきましょう。

レキシカルスコープ(Lexical Scope)

プログラミング言語は、変数スコープがいつ定義されるかによってスコープの名称が異なります。コンパイル時点であれば**「静的スコープ(Static Scope)」、ランタイム時点であれば「動的スコープ(Dynamic Scope)」と呼びます。JavaScriptでは、パラメータ、変数ともに定義される時**、その位置する関数のスコープにコンパイル段階(原文にあった「実行フェーズ」は誤記と思われる)で帰属するため、**「レキシカルスコープ(Lexical Scope)」**とも呼ばれます。レキシカルの意味は何かを作る、作る時点を指し、パラメータ、変数、関数すべてが作られる時、すなわち定義される時にスコープに従うという点でレキシカルスコープと呼ばれます。 したがって、レキシカルスコープは静的スコープと表現することもできます。

以下の例を見ると、関数b()と変数var num = 1;は、同一のmain()関数のスコープであるグローバルスコープ(window)で定義されます。そのため、関数b()はいつでも変数var num = 1;にアクセスできるのです。

var num = 1;

function b() {
  console.log(num);
}

b();

上記の例は理解しやすいですが、以下の例は単に一つの関数を追加しただけにもかかわらず、一瞬思考停止に陥ることがあります。

var num = 1;

function a() {
  var num = 10;
  b();
}

function b() {
  console.log(num);
}

a();

a()の実行結果は10ではなく1です。理由は、function b()

グローバルスコープで定義された関数b()は、グローバルスコープで定義されたvar num = 1;を参照することになります。一般的に普遍的なプログラミング言語では、ランタイム時のスコープを考えて10だと勘違いしがちなのです。

変数シャドーイング(Variable Shadowing)

レキシカルスコープは自然と、もし同じ名称の変数が定義されていたとしても、最も近い関数のスコープ(Local Execution Scope for function)のみを使用し、現在関数を呼び出した以前の関数に定義されている同じ名称の変数は無視されます。探している変数が近い関数スコープに存在すれば、わざわざスコープチェーンを行う必要がなく、これは最も近い関数スコープの変数以外のものは知る必要もなく、知らせることもないという意味で、**変数シャドーイング(Variable Shadowing)**と呼ばれます。

ホイスティング(Hoisting): 変数、関数

コンパイル段階変数、関数宣言を先に行い、次に実行段階変数代入および関数実行を行うため、変数宣言が変数代入時点よりも下に位置したり、関数宣言部が関数呼び出し部よりも下に位置したりしても、とにかく変数、関数宣言が先に行われたのでエラーなく正常に処理されます。変数、関数宣言がどこで行われようと関係なく、すべて上部に持ち上げられた状態で動作します。という意味で、これを**ホイスティング(Hoisting)**と呼びます。

console.dir(exampleV); // output: undefined
// → 「変数宣言」はされていますが、その時点では値が代入されていません。
console.dir(exampleF); // output: f exampleF(x)
console.log(exampleF(2)); // output: 2

var exampleV = 1;
function exampleF(x) {
  return x;
}

関数を定義する方法は「関数宣言文」と「関数式」に分かれますが、コンパイル過程を正しく理解していれば、どの定義方式にホイスティングが適用され、どちらには適用されないかを知ることができます。

console.dir(functionDeclare); // output: f functionDeclare(x)
console.dir(functionExpression); // output: undefined
// → 「変数定義」はされていますが、その時点では関数が代入されていません。

function functionDeclare(x) {
  return x;
} // 関数宣言文
var functionExpression = function (x) {
  return x;
}; // 関数式

「関数式」の場合、コンパイル過程でvar functionExpression変数のみが宣言されるため、関数はundefinedとなります。

ブロックスコープ(Block Scope): const, let

これまでの1) レキシカルスコープと2) ホイスティングは、私たちが一般的に考える方式とは異なるため、間違いやすい点が多かったです。

  1. 関数レベルスコープ(レキシカルスコープ + スコープチェーン)= 関数外部で宣言したすべての変数にアクセス可能です。
var a = 1;

if (true) {
  var a = 2;
}

console.log(a); // 2 - window関数に該当する変数の汚染(まるでグローバル変数のように)
  1. ホイスティング = 宣言(declare)のみがホイスティングされるだけで、代入(assignment)が行われていない場合はundefinedが発生します。
  2. 重複宣言可能 = varvar hello = 1; var hello = 2;のように重複宣言が可能です。1番、2番とこの特性が組み合わさると、本当に頭が痛くなってしまいます。

この絶望的な世界から私たちの脳を救い出すために、金字塔のように降りてきたES6の変数キーワードが、constletです。constletで定義された変数は、従来の関数単位のスコープではなく、ブロックスコープとして定義されます。これはつまり

  1. ブロックレベルスコープ = ブロック内の変数のみにアクセスし、他のスコープの変数を汚染しません。
let b = 1;

if (true) {
  let b = 2;
}

console.log(b); // 1 - let変数はif-block内でのみ有効です。ブロック外の変数を汚染しません。
  1. ホイスティングなし(No Hoisting) = 宣言はされているが代入されていないという意味のundefinedは表示されず、Uncaught ReferenceError: ... is not definedという宣言自体が行われていないことに対するエラーが発生します。
  2. 重複宣言不可能 = 重複宣言を試みるとUncaught SyntaxError: Identifier ... has already been declaredエラーが発生します。

constletの登場により、if文やfor文のようなブロックレベル({})単位での変数定義・使用が可能になりました。他の何も汚染しません。

ガーベージコレクション(Garbage Collection)

JavaScriptでは、新しい関数が呼び出されるたびにヒープに関数単位のコンテキストスコープが生成されることが、もう頭に叩き込まれていることでしょう。コンテキストスコープは関数呼び出しの間のみ有効であり、その関数の呼び出しが終了すれば、その関数のスコープはヒープから削除されます。これをメモリクリーンアップの意味でガーベージコレクションと呼びます。JavaScriptファイルの実行がすべて終わると、最初に呼び出されたmain()関数も終了し、これに伴いグローバルスコープ(Window)も消滅します。このように、JavaScriptは単純に関数(ポインタ)の到達可能性(Reachability)に基づいてガーベージコレクションを実行します。 SwiftやJavaが参照カウント戦略によるガーベージコレクションを実行するのと異なり、マークアンドスイープという単純な戦略を採用していることだけを知っていれば十分です。

クロージャ(Closure)

Java言語では、クラスを通じて変数をprivateで宣言し、カプセル化(Encapsulation)を実現します。外部からクラス内の変数へのアクセスを禁じ、変数の変更はすべてpublicで公開された関数を通じてのみ可能にします。オブジェクト指向プログラミング(OOP)だけでなく、ドメイン駆動設計(DDD)における必須概念がカプセル化ですが、残念ながらJavaScriptのクラスはJavaのクラスと異なり、カプセル化をサポートしていません。もちろん、_プレフィックスが付いた変数を暗黙的にプライベート変数と判断する慣習がありましたが、結局オブジェクトをコンソールに出力するとすべて見えてしまうため意味がありません。また、最近のJavaScriptではクラス変数の前に#プレフィックスを付けると疑似的なプライベートのように動作することが分かりましたが、#変数名として変数が定義されるため、これもObject.getOwnPropertySymbols()を通じてすべて見えてしまい、根本的な解決策ではありません。

この点、JavaScriptではクロージャを使用すれば解決できます。関数が定義されるスコープによって、その関数が参照できる変数が決定されるレキシカルスコープを活用し、「関数の定義」自体を返せばよいのです。これにより、カプセル化を実現できます。まずカプセル化を説明する前に、クロージャをどのように定義するのかについて見ていきましょう。

var closureTest = function () {
  return function () {
    console.log("This is innerFunction.");
  };
};

あるいは

var closureTest = function () {
  function innerFunction() {
    console.log("This is innerFunction.");
  }
  return innerFunction;
};

このように関数の定義を返す方式をクロージャと呼びます。

関数が定義されるレキシカルスコープで定義された変数は、その定義された関数からアクセス可能です。という点を活用し、プライベートにしたい変数をクロージャ関数定義の内部に定義すればよいのです。これを通じてカプセル化を実現できます。

var closureTest = function () {
  var cannotBeAccessedFromOuter = "This is innerFunction.";

  return function () {
    console.log(cannotBeAccessedFromOuter);
  };
};

var closure = closureTest();
closure(); // output: This is innerFunction.
console.log(closure.cannotBeAccessedFromOuter); // output: undefined

プライベート変数は「非公開変数」とも呼ばれます。上記の例は非公開変数が固定値でしたが、これを変更可能な状態として定義することもできます。Javaのオブジェクト指向プログラミングに忠実な方式のコード作成技法です。また、追加で関数を一つだけでなく複数定義してみましょう。クロージャの説明で本当に数えきれないほど引用されるカウンター関数を定義してみます。クロージャは常に関数を返すものと思われがちですが、以下のようにオブジェクトを返すこともできます。

var counter = function () {
  var count = 0;

  return {
    increase: function (number) { // numberパラメータを追加
      count += number;
    },
    decrease: function (number) { // numberパラメータを追加
      count -= number;
    },
    show: function () {
      console.log(count);
    },
  };
};

var counterClosure = counter();
counterClosure.increase(1);
counterClosure.show(); // output: 1
counterClosure.decrease(1);
counterClosure.show(); // output: 0

counterClosure内のcountプライベート変数を変更できる方法は関数を呼び出す方法しかなく、見るためにも関数を通じてのみ見ることができます。これはつまり、count変数がincrease()decrease()show()関数を通せばどこからでもアクセス可能であるという意味でもあります。これはすなわち、count変数の**到達可能性(Reachability)は常に開かれており、JavaScriptエンジンがガーベージコレクション(Garbage Collection)**をいつ実行すべきか全く分からないという意味でもあります。

したがって、counterClosureオブジェクトは、実行がすべて終わってもメモリから削除されません。count変数がいつでも使用できる状態にあるということは、count変数がcounter関数内に存在しますが、グローバルに参照されているという意味でもあります。これにより、counter関数が定義されたグローバルスコープが閉じられるまでは存在し続け、メモリリークが発生することになります。

クロージャは、このようにガーベージコレクションされないという致命的な欠点を持っていますが、解決のためにはガーベージコレクションされるようにするには、counterClosure変数にnull値を代入して、変数と関数双方への参照を削除(到達可能性を削除)すればよいでしょう。

counterClosure = null;

他の方法としては、再利用性のない関数の場合はIIFE(Immediately Invoked Function Expression)を使用すればよいでしょう。

(function hello() { ... })();
(function makePrivateFunc() {
  const message = "private data";
  const privateFunc = function () {
    console.log(`${message} can also be implemented through the IIFE`);
  };
  privateFunc();
})();

このように、JavaScriptエンジンの動作は「コンパイル - 実行」の2つの過程で成り立っていること、そして関数の実行(コンテキストスコープ)と変数の定義(レキシカルスコープ)について簡単に見てきました。さらに詳細に入ると限りなく複雑になりますが、ウェブアプリケーション開発者としては、これらの概念を知っていれば実開発や面接で大きな問題はないだろうと思います。


  1. let, const の違い
  2. var, let, const の違い ⏤ 変数宣言と代入、ホイスティング、スコープ
  3. 関数のスコープ(Scope)
  4. JavaScript VM internals, EventLoop, Async and ScopeChains
JavaScriptエンジンの実行プロセスから理解する「ホイスティング」と「クロージャ」
Author
Aaron
Posted on
Licensed Under
CC BY-NC-SA 4.0
CC BY-NC-SA 4.0
同じカテゴリーの関連記事
最新記事
LLMフィルターが奪う会話の筋肉とコミュニケーション様式
会話における無礼さを濾過し、洗練された回答を生成するLLMツールが日常化した現代において、私たちは本当に思慮深い会話をしているのだろうか?リアルタイムのコミュニケーションにおける数多くの失敗を通じて磨かれるべき会話能力が、外部ツールに依存することで退化している現象と、それがもたらす社会的な不安や世代間の行動様式の変化について考察する。
シニア採用における年俸交渉の最適なタイミングと戦略
年俸交渉は単なる数字の交換ではなく、心理的な駆け引きとタイミングが重要です。本稿では、企業側にとって、候補者が計算的な態度を取りがちな最終合格後よりも、採用プロセスの初期段階から段階的に交渉を進めることが、なぜより効率的であり、率直な情報の共有に繋がるのかを考察します。
法治主義の限界と人間の多様性
全ての人間の行為を単一の法体系で規制できるという信念は、傲慢であるかもしれない。この記事は、中世の階層的な統制から脱却し、現代の無限の自由を手に入れた人類が直面する法治主義の逆説と、多様性という名のもとに深化する社会的強制力と他者への悪魔化現象を鋭く分析する。
토스트 예시 메세지