Help us understand the problem. What is going on with this article?

JavaScript の原理:クロージャの真実

More than 3 years have passed since last update.

ECMAScript 5.1 を前提に JavaScript のクロージャの原理をメモっとく。クロージャの真実はこれ。

レキシカル環境で検索しても説明がほとんど出てこない。誰かが説明してくれたらいいのになと思ってたので、厳密さには欠けるかもしれないが記事にしてみることにした。ECMAScript の仕様によって実現されているクロージャを理解する価値はきっとある。

クロージャでカウンターの例

下記のコードはクロージャでカウンターを作る例。加算されていく var n ってどこに存在し続けるのか?この記事ではそういう疑問を解決しよう。

example.js
function createCounter() {
  var n = 0;
  return function() {
    return n++;
  }
}

var count = createCounter();
print(count()); // 0
print(count()); // 1
print(count()); // 2

コードは SpiderMonkey で検証してるので標準出力関数が print だけど、console.log に置き換えればブラウザでも動作するはず。なお、OS X で Homebrew が入ってるなら下記のコマンドで SpiderMonkey をインストールして、コードを実行できる。

$ brew install spidermonkey
$ js example.js

レキシカルスコープとダイナミックスコープ

クロージャの話をすると絶対出てくるレキシカルスコープ(静的スコープ)とダイナミックスコープ(動的スコープ)。クロージャの原理はレキシカルスコープが支えていて、ダイナミックスコープにクロージャはないんだよね。

下記のコードを実行すると 10 が出力される。JavaScript はレキシカルスコープだからね。でも、ダイナミックスコープだったら 20 が出力される。

scope.js
var x = 10;

function foo() {
  print(x); // 10
}

function bar() {
  var x = 20;
  foo();
}

bar();

Perl はレキシカルスコープとダイナミックスコープを使い分けられるので、前述のコードをダイナミックスコープで書いてみる。

scope.pl
$x = 10;

sub foo {
  print $x, "\n"; # 20
}

sub bar {
  local $x = 20;
  foo();
}

bar();

OS X なら下記のコマンドでコードを実行できる。ターミナルに 20 が出力されたはず。

$ perl scope.pl

レキシカルスコープとダイナミックスコープの違いは、コード上で決まるか実行上で決まるかという違い。コード上では関数 foo の定義元にあるのは 10 に初期化された var x になる。実行上では foo の呼出元にあるのは 20 に初期化された var x になる。上記のコードの場合、変数 x をコード上と実行上のどっちの方法で解決しているかで出力が違うことになる。

ECMAScript 5.1 のレキシカルスコープの原理

JavaScript はレキシカルスコープだ。それを JavaScript の仕様 ECMAScript 5.1 ではどうやって実現しているのかを見ていこう。

レキシカル環境

レキシカル環境はコード上の入れ子構造と識別子から変数と関数への関連を保持している。

レキシカル環境は例を見た方が理解しやすい。scope.js のレキシカル環境は下記のオブジェクト図になる。

scope.js
var x = 10;

function foo() {
  print(x); // 10
}

function bar() {
  var x = 20;
  foo();
}

bar();

scope.js のレキシカル環境のオブジェクト図

レキシカル環境は下記のプロパティを持つオブジェクトと考える。

  • record: 環境レコード
  • outer: 外側のレキシカル環境への参照

globalEnv はグローバルなレキシカル環境だ。fooEnv は関数 foo のレキシカル環境で、その外側のレキシカル環境は globalEnv だ。なお、global はいわゆるグローバルオブジェクトだ。例えば、ブラウザー上で実行する JavaScript ならグローバルオブジェクトは window になる。

環境レコードはレキシカル環境の識別子と値を記録する。ただし、グローバル環境の環境レコードはグローバルオブジェクトへの参照になる。var x = 10; がグローバルオブジェクトのプロパティに追加されるのはこのためだ。var x = 20; は barEnv の環境レコードに追加される。(仕様の環境レコードはもう少し複雑なので、この説明は正確ではない。)

実行コンテキスト

実行コンテキストはある時点の実行上の入れ子構造をスタックとして保持している。

実行コンテキストも例を見た方が理解しやすい。scope.js の print(x); 時点での実行コンテキストは下記のオブジェクト図になる。(PlantUML を使いこなせてなくてスタック感は全然表せてないけど……。)

scope.js の print(x); 時点での実行コンテキストのオブジェクト図

実行コンテキストは下記のプロパティを持つオブジェクトと考える。

  • env: レキシカル環境

globalExecCtx はグローバルな実行コンテキストで最初に生成されてスタックに積まれる。globalExecCtx の実行コンテキストで関数 bar が呼び出されたときに barExecCtx は生成されてスタックに積まれる。barExecCtx の実行コンテキストで関数 foo が呼び出されたときに fooExecCtx は生成されてスタックに積まれる。

print(x); を実行するタイミングだと図のように globalExecCtx・barExecCtx・fooExecCtx の3つの実行コンテキストが存在する。関数 foo の実行が終了すると fooExecCtx の実行コンテキストも終了する。

識別子の解決

さあ print(x); の変数 x を解決しよう。

識別子の解決も図から見てみる。実行コンテキストの図の変数 x の解決に利用される参照を矢印にしてみた。

scope.js の print(x); 時点での実行コンテキストのオブジェクト図の変数 `x` の解決に利用される参照を矢印にしたもの

識別子の解決手順は単純だ。(仕様の識別子の解決はもう少し複雑なので、この説明は正確ではない。)

  1. 現在の実行コンテキストのレキシカル環境を現在のレキシカル環境にする
  2. 現在のレキシカル環境の環境レコードに対象の識別子が存在するか?
    • 存在する:その識別子の値を返す
    • 存在しない:現在のレキシカル環境の外側のレキシカル環境を現在のレキシカル環境にして2を再実行する

print(x); の場合は下記のようになる。

  1. 現在の実行コンテキスト fooExecCtx のレキシカル環境 fooEnv を現在のレキシカル環境にする
  2. 現在のレキシカル環境 fooEnv の環境レコードに対象の識別子 x が存在しないので、現在のレキシカル環境 fooEnv の外側のレキシカル環境 globalEnv を現在のレキシカル環境にする
  3. 現在のレキシカル環境 globalEnv の環境レコード(グローバルオブジェクト)に対象の識別子 x が存在するので、x の値である 10 を返す。

ECMAScript 5.1 のクロージャの原理

JavaScript のクロージャの原理を説明しよう。

JavaScript のすべての関数はクロージャ

JavaScript のすべての関数はクロージャだ。どんな関数定義によって生成される関数オブジェクトでも、関数が定義されたレキシカル環境への参照(スコープ)を持つからだ。前項の図に関数オブジェクトを反映させると下記のようになる。

scope.js の print(x); 時点での実行コンテキストのオブジェクト図に関数オブジェクトを追加

これですべての準備ができた。クロージャでカウンターの例の原理を明らかにしよう。

クロージャでカウンターの例の原理

クロージャでカウンターの例のコードを再掲する。

example.js
function createCounter() {
  var n = 0;
  return function() {
    return n++;
  }
}

var count = createCounter();
print(count()); // 0
print(count()); // 1
print(count()); // 2

var count = createCounter(); 時点での実行コンテキストのオブジェクト図は下記のようになる。

var count = createCounter(); 時点での実行コンテキストのオブジェクト図

関数 createCounter の終了直前の実行コンテキストのオブジェクト図は下記のようになる。関数 createCounter の実行コンテキストとレキシカル環境が生成されている。そして、注目してほしいのは 関数 createCounter 内の無名関数が関数オブジェクト anonymousFunc として生成されているところ。

関数 `createCounter` の終了直前の実行コンテキストのオブジェクト図

print(count()); 時点での実行コンテキストのオブジェクト図は下記のようになる。関数オブジェクト anonymousFunc が変数 count で参照できるようになる。

`print(count());` 時点での実行コンテキストのオブジェクト図

変数 count を関数として呼び出した後の return n++; 時点での実行コンテキストのオブジェクト図は下記のようになる。

anonymousEnv の outer が createCounterEnv になっているのは、anonymousFunc の scope を anonymousEnv を生成するときに outer に設定するから。関数の実行コンテキストではレキシカル環境の「外側のレキシカル環境」に関数オブジェクトの「スコープ」を設定するという仕組みになっている。これまでのオブジェクト図を見返してもそうなってるはずだ。

`count` を関数として呼び出した後の `return n++;` 時点での実行コンテキストのオブジェクト図

変数 n を解決しよう。

`count` を関数として呼び出した後の `return n++;` 時点での実行コンテキストのオブジェクト図の変数 `n` の解決に利用される参照を矢印にしたもの

最初の print(count()); が終了した時点での実行コンテキストのオブジェクト図は下記のようになる。createCounterEnv の n が1になっているよね。

最初の `print(count());` が終了した時点での実行コンテキストのオブジェクト図

これがクロージャの原理だ。

createCounter を2回実行する

クロージャでカウンターの例のコードで関数 createCounter を2回実行してみる。もちろん別のカウンターが生成される。

example2.js
function createCounter() {
  var n = 0;
  return function() {
    return n++;
  }
}

var count = createCounter();
print(count()); // 0
print(count()); // 1
print(count()); // 2

var count2 = createCounter();
print(count2()); // 0
print(count2()); // 1
print(count2()); // 2

これはなぜなのかも念のため考えてみる。と言っても、実行コンテキストのオブジェクト図をみればすぐに解るはず。

print(count2()); 時点での実行コンテキストのオブジェクト図は下記のようになる。

`print(count2());` 時点での実行コンテキストのオブジェクト図

これですべての謎は解けたかな。

補足事項

本記事では下記を条件にすることで ECMAScript 5.1 の仕様を省略しているところがある。

  1. strict モードコードのみ対象とする
  2. eval コードは対象外とする
  3. try 文 catch 節は対象外とする
  4. 1と3を前提に実行コンテキストの LexicalEnvironment と VariableEnvironment を同一とみなして env の名前で参照する

参考文献

ECMA-262 Edition 5.1を読む
http://www.ecma-international.org/ecma-262/5.1/
http://dmitrysoshnikov.com/ecmascript/es5-chapter-3-1-lexical-environments-common-theory/
http://dmitrysoshnikov.com/ecmascript/es5-chapter-3-2-lexical-environments-ecmascript-implementation/

takuya0301
Mikatus 株式会社の VP Engineering 兼デザイングループグループリーダー。最近は Event Storming と Lagom に興味がある。
https://takuya0301.github.io
mikatus
税理士・会計事務所向けクラウドシステムの企画、開発、提供事業を行っております。
https://www.mikatus.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした