JavaScriptのスコープとクロージャ

Overview

基本的にJavaScriptはECMAScript言語スペックに従っています。スペックから、実行コードと実行コンテキストでスコープに関する動作を確認できます。また重要な概念である1級オブジェクトの関数は、その特徴をスペックの全体的な部分から確認できます。そして、クロージャ(Closure)に対する定義はありません。クロージャはJavaScriptが採用している技術的基盤やコンセプトで、JavaScriptはクロージャを用いてスコープ的特徴と一級オブジェクトとして関数のスペックを実装しています。

スコープ

物や人物に名前をつけて意味を持たせるように、プログラミングでも変数や関数に名前を付与して意味を持たせます。名前がなければ、変数や関数はただ1つのメモリアドレスに過ぎません。そこでプログラムは、「名前:値」の対応表を作り、これを用います。対応表の名前によってコードをより簡単に理解することができ、また名前で値を保存して、参照や修正を行います。

初期プログラミング言語は、プログラム全体で1つの対応表を管理していましたが、これには名前が重複する問題がありました。そこで重複を避けるために、言語別に「スコープ」というルールを作成して定義して、このスコープの規則が言語スペック(Specification)となりました。

JavaScriptも同様に、自分のスコープ規則があります。
JavaScript(ES6)は関数レベルとブロックレベルレキシカルスコープ規則に従います。

スコープレベル

Javascriptは伝統的に関数レベルのスコープに対応しており、少し前までは、ブロックレベルのスコープには対応していませんでした。しかし、最新スペックのES6(ECMAScript 6)からブロックレベルのスコープをサポートし始めました。

関数レベルのスコープ

JavaScriptでvarキーワードで宣言された変数や、関数宣言で作られた関数は、関数レベルのスコープを持ちます。つまり、関数の内部全体で有効な識別子となります。

次のコードは、何の問題もなくblueを出力します。

function foo() {
    if (true) {
        var color = 'blue';
    }
    console.log(color); // blue
}
foo();

もしvar colorがブロックレベルのスコープであれば、colorifステートメントが終了すると破壊され、console.logで誤った参照によりエラーが発生するでしょう。しかし、colorは関数レベルのスコープですので、foo関数の内部のどこからもエラーを発生させずに参照できます。

ブロックレベルのスコープ

ES6のletconstキーワードは、ブロックレベルのスコープ変数を作成します。

function foo() {
    if(true) {
        let color = 'blue';
        console.log(color); // blue
    }
    console.log(color); // ReferenceError: color is not defined
}
foo();

let colorifブロック内で宣言したため、ifブロック内部で参照ができ、その他の領域では無効な参照となりエラーが発生します。

var vs  letconst

ES6が標準化されて、ブロックレベルと関数レベルの両方に対応するようになりました。「You do not know JS」シリーズの著者であるKyle Simpsonは、varletconstが異なるため、必要な状況に合わせて使用する必要があると説明しています。

しかしながら昨今、ES6コードの大部分はvarを使用していません。varは、letconstでも代替ができ、var自体が関数レベルのスコープを持つため、ブロックレベルのスコープに比べて混乱することがあるからです。

レキシカルスコープ

レキシカルスコープ(Lexical scope)は通常、動的スコープ(Dynamic scope)とよく比較されます。
ウィキペディアでは動的スコープとレキシカルスコープを次のように定義しています。

  • 動的スコープ

The name resolution depends upon the program state when the name is encountered which is determined by the execution context or calling context.

  • レキシカルスコープ(静的スコープ[Static scope] または修辞的スコープ[Rhetorical scope])

The name resolution depends on the location in the source code and the lexical context, which is defined by where the named variable or function is defined.

動的スコープは、プログラム実行時に実行コンテキストや呼び出しのコンテキストによって決定されます。またレキシカルスコープは、ソースコードが作成されたその文脈で決定されます。現代のプログラミングでは、ほとんどの言語はレキシカルスコープ規則に従っています。
動的スコープとレキシカルスコープはJavaScriptとPerlを比較して確認できます。以下は、JavaScriptとPerlで同じコードを作成したときに出力される結果です。
Javascriptがレキシカルスコープ規則を用いてglobal, globalを出力し、Perlは動的スコープ規則を用いてlocal, globalを出力しています。(参考までに、Perlでlocalではなく、myキーワードを使うと、変数の有効範囲を制限してJavaScriptのような結果を得られます。)

レキシカルスコープ規則に従うJavaScriptの関数は、呼び出しスタックとは関係なく、それぞれの(thisを除く)対応表をソースコードに基づいて定義し、実行時にはその対応表を変更しません。(実行時にレキシカルスコープを変更できる方法(evalwith)がありますが、推奨しません。)

入れ子になったスコープ(スコープチェーンまたはスコープバブル)

JavaScriptのスコープはECMAScript言語スペックでレキシカル環境(Lexical environment)と環境レコード(Environment Record)という概念で定義されました。

6.2.5 The Lexical Environment and Environment Record Specification Types
The Lexical Environment and Environment Record types are used to explain the behaviour of name resolution in nested functions and blocks. These types and the operations upon them are defined in 8.1.

簡単に図で表現すると、このようになります。

「名前:値の対応表」が環境レコードと同じで、レキシカル環境は、環境レコードと上位レキシカル環境(Outer lexical environment)への参照からなります。

現在-レキシカル環境の対応表(環境レコード)で変数を参照しない場合は、外部のレキシカル環境を参照して検索することで、ネストスコープが可能となります。ネストスコープナビゲーションは、対応する名前を検索したり、外部のレキシカル環境の参照がnullになるとき、ナビゲーションを停止します。

(参考) ECMA-262 Edition3を見ると、JavaScriptのスコープの特徴は、Scope chain(= list)とActivation Objectなどの概念で説明しました。この説明が全般的に広く知られていますが、次のスペックであるECMA262 Edition5からは、Lexical environmentEnvironment Recordの概念としてスコープを説明しています。

ホイスティング

従来のJavaScriptスコープには、2つの特徴がありました。

  • レキシカルスコープ
  • 関数レベルのスコープ(+ブロックレベルのスコープ-ES6)

では、以下のような状況ではどのような値が出力されるでしょう。

function foo() {
    a = 2;
    var a;
    console.log(a);
}
foo();

2が出力されます。
次はどうなるか考えてみましょう。

function foo() {
    console.log(a);
    var a = 2;
}
foo();

undefinedが出力されました。でたらめな感じに思われるかもしれませんが、実はそこまでおかしいわけではありません。

JavaScriptエンジンはコードを解釈する前に、そのコードを先にコンパイルします。var a = 2;を1つの構文として考えている可能性もありますが、Javascriptを次の2つの構文に分離してみましょう。

  1. var a;
  2. a = 2;

変数の宣言(生成)段階と初期化段階を分けて、宣言の段階でその宣言がソースコードのどこに位置しようとも、当該スコープのコンパイル段階で処理してしまうのです。(言語スペック上で変数はレキシカル環境がインスタンス化され、初期化されるときに生成されます。)したがって、こうした宣言段階がスコープの頂点として、ホイスティングされた作業であると考えられます。

(参考) ブロックスコープであるletもホイスティングです。しかし、宣言前に参照する場合は、undefinedを返却せずにReferenceErrorを発生させる特徴があります。

Temporal dead zone and errors with let
In ECMAScript 2015, let will hoist the variable to the top of the block. However, referencing the variable in the block before the variable declaration results in a ReferenceError. The variable is in a “temporal dead zone” from the start of the block until the declaration is processed.

クロージャ(Closure)

JavaScriptにおいて(言語スペックではない)クロージャの定義は非常に難しい部分です。

クロージャが最初に登場した1964年に発表されたPeter J. Landinの論文、The Mechanical Evaluation of Expressionsを見ると、クロージャを次のように定義しています。

上記に基づいて、現代のプログラミングではクロージャを次のように解釈して定義できそうです。

クロージャ = 関数 + 関数を取り巻く環境(Lexical environment)

関数を取り巻く環境というものが、先ほど説明したレキシカルスコープです。関数を作り、その関数内部のコードがナビゲートするスコープを関数生成当時のレキシカルスコープで固定すると、クロージャになるでしょう。

クロージャがJavaScriptにどのように溶け込んでいったか見てみましょう。

JavaScriptのクロージャ

  • JavaScriptでクロージャは関数が生成された時点で生成される。
    =関数が生成されるとき、その関数のレキシカル環境を包摂(closure)して実行するときに用いる。

したがって、概念的にJavaScriptのすべての関数はクロージャですが、実際に私たちは、JavaScriptのすべての関数をすべてクロージャとは呼んでいません。

次の例でクロージャをもう少し正確に把握できるでしょう。

function foo() {
    var color = 'blue';
    function bar() {
        console.log(color);
    }
    bar();
}
foo();

bar関数は私たちが言うクロージャではないでしょうか?

barfooの中に属するため、fooスコープを外部スコープ(outer lexical environment)参照で保存します。そしてbarは自分のレキシカルスコープチェーンを通じてfoocolorを正確に参照するでしょう。

しかしクロージャではありません。私たちが呼ぶクロージャとは少し距離があります。barfoo内部で定義され実行されただけで、fooしか出力しないため、クロージャとは呼びません。

代わりに、次のコードは私たちが実際に呼ぶクロージャを表します。

var color = 'red';
function foo() {
    var color = 'blue'; // 2
    function bar() {
        console.log(color); // 1
    }
    return bar;
}
var baz = foo(); // 3
baz(); // 4
  1. barcolorを探して出力する関数であると定義する。
  2. barはouter environment参照でfooのenvironmentを保存する。
  3. barglobalbazという名前で取得する。
  4. globalからbaz(=bar)を呼び出す。
  5. barは自分のスコープでcolorを検索する。
  6. 存在しない。自分のouter environmentを参照して検索する。
  7. outer environmentであるfooのスコープを検索する。colorを参照すると、値はblueとなっている。
  8. これにより、blueが出力される。

これがまさしくクロージャです。

重要な部分は、2〜4と7です。barは、自分が作成したレキシカルスコープから抜け出してglobalでbazという名前で呼び出され、スコープナビゲーションは現在実行スタックと関係ないfooを経て実行されます。 bazbarで初期化するときは、すでにbarouter lexical environmentfooに決定した後です。このために、barの生成と直接的な関連しないglobalからいくら呼び出されても、依然としてfooからcolorを見つけるのです。このbar(またはbaz)のような関数を、私たちはクロージャと呼びます。

改めて強調すると、JSのスコープはレキシカルスコープ、つまり名前の範囲はソースコードが作成されたその文脈で決定されます。

さらに、fooのレキシカル環境のインスタンスは、foo();の実行が終了した後、GCが回収すべきですが、実際はそうではありません。前述したように、barは外部のレキシカル環境であるfooのレキシカル環境を引き続き参照しており、このbarbazを依然として参照しているからです。( baz(=bar) -> foo

 

有名なループクロージャ

function count() {
    var i;
    for (i = 1; i < 10; i += 1) {
        setTimeout(function timer() {
            console.log(i);
        }, i*100);
    }
}
count();

このコードは、1、2、3、… 9を0.1秒ごとに出力することが目標ですが、結果としては10が9回出力されました。なぜでしょう?

timerはクロージャに、いつ、どこで、どのよう呼び出されるのか、常に上位スコープであるcountiに要請します。そしてtimerは0.1秒後に呼び出されます。ところが、最初の0.1秒の間に、すでにi10になりました。そしてtimerは0.1秒周期で呼び出されるたびに、常にcountiを検索します。結局timerはすでに10なってしまったiのみ出力することになるのです。

では、意図したとおり1〜9の順番で出力したい場合は、どうすればよいでしょうか?

  1. 新しいスコープを追加して反復する度に個別の値を保存する方法
  2. ES6で追加されたブロックスコープを利用する方法

このように2つの方法があるでしょう。

次のコードは、元の意図通りに動作します。

1.新しいスコープを追加して反復する度に個別の値を保存する方法

function count() {
    var i;
    for (i = 1; i < 10; i += 1) {
        (function(countingNumber) {
            setTimeout(function timer() {
                console.log(countingNumber);
            }, i * 100);
        })(i);
    }
}
count();

2.ES6で追加されたブロックスコープを利用する方法

function count() {
    'use strict';
    for (let i = 1; i < 10; i += 1) {
        setTimeout(function timer() {
            console.log(i);
        }, i * 100);
    }
}
count();

Reference

TOAST Meetup 編集部

TOASTの技術ナレッジやお得なイベント情報を発信していきます
pagetop