【JavaScript】クロージャーについて(スコープとレキシカルスコープ)

前回に引き続き、勢いのあるうちにまとめようと思いました。

調べてみると、クロージャーとは「状態」、「記法」、「仕組み」、「関数」といろいろありました。

そして分かりにくいもののようです。。

クロージャーはスコープ、主にレキシカルスコープと出てくることが多いようなので、レキシカルスコープについて先に調べます。

レキシカルスコープとは

実はレキシカルスコープはJavaScriptスコープの種類としては、あまり紹介されていません。

まずはスコープについて調べました。

スコープとは

一番理解できた記事です。
JavaScriptのスコープ総まとめ | 第1回 スコープの種類とその基本

JavaScriptにおけるスコープとは、変数がどの場所から参照できるのかを定義する概念です。言い換えれば、変数の有効範囲ということです。同じスコープ上にある変数にはアクセスできますが、スコープが違えば、別々のスコープにある変数にはお互いにアクセスすることができません。

ここで関数の参照範囲についてはいわないのかな?と思いました。

関数とスコープ · JavaScript Primer #jsprimer

スコープとは変数の名前や関数などの参照できる範囲を決めるものです。 スコープの中で定義された変数はスコープの内側でのみ参照でき、スコープの外側からは参照できません。

おそらく関数についてもスコープという呼び出せる参照範囲があるのだと思います。

「スコープの中で定義された変数は...」とあるので、イメージとしては、

  1. コードを解析して、上記のスコープが形成される
  2. 変数・関数の定義をする
    1. を記述した場所によって、その変数・関数の参照範囲(1. で作られたスコープ)が決まる。

ということなのかなと思いました。

なので解析時、もとからある(以下で説明している)「スコープ」と、定義時に決まる変数・関数の「スコープ」、2つの意味で「スコープ」が登場していると考えました。

「スコープ」に慣れれば違和感はないのでしょうが、最初は(今も?)言葉尻が気になって悶々としていました。
...が、たくさんスコープのことを考えていたら馴染んできました。

スコープは大きく分けて2種類、細かく分けると5種類あるそうです(ローカルスコープを除いて5種類)。

そして、上記の5種類に入っていないレキシカルスコープは、そのまま「範囲」を指す言葉ではなさそうです。。

ここでグローバル・関数・ブロックスコープの特徴をまとめます。

グローバルスコープ

グローバルスコープで定義すると、どこからでも参照できる。
letconstデベロッパーツールで見るとスクリプトスコープになるが、var, function は定義時にウィンドウオブジェクトに格納され、デベロッパーツールではグローバルスコープになる。

// 定義:グローバルスコープ
var a = 'global_var';
let b  = 'global_let';

function fn() {
    console.log('fn is called');
}

debugger;

debugger; の行で処理を止めてブレークポイントとします。 この時点で 変数a, b、関数fn のスコープを確認してみます。

たしかに確認できました。
ブレークポイントを使う機会がやってきました。

ここで分かることは、どこで実行するかによって参照できる変数・関数が決まっている、ということです。
なので「各変数・関数のスコープ外で実行しているときはその変数・関数を参照できない」と言えます。

ローカルスコープ

範囲が限定されているスコープ。デベロッパーツールでも「Local」と表示されます。 関数スコープとブロックスコープのどちらかです。

関数スコープ

function(){ }{ } の中。関数スコープで定義された変数・関数は、同じ関数の中からしか参照できない。
さらに入れ子になっている関数から呼び出すことができる。

function fn() {
    // 定義:関数の中
    function fnInner() {
        console.log('fnInner is called');
    }
    fnInner();     // fnInner is called

    function fnInner2() {
        //参照:他の関数の中
        fnInner();
    }
    fnAInner2();     // fnInner is called
}
// 参照:グローバルスコープ
fnInner();    // Uncaught ReferenceError: fnInner is not defined

ここで関数の例えはわかりにくいかもしれませんが...
fn関数の中で定義したfnInner関数は、fn関数の中が参照範囲(スコープ)になる。その外側からはfnInner関数を参照できない。
fnInner2関数の内側もfnInner関数のスコープ内なので、fnInner関数を参照できる。

こんな感じです。

変数や、関数の引数(仮引数)も同じ関数の内側からのみ参照できます。

ブロックスコープ

{ } の中。オブジェクトやif文、for文などもブロックスコープになります。

{
    // 定義:ブロックスコープ
    var v = 'block_var';
    let l  = 'block_let';
    const c  = 'block_const';

    function fn() {
        console.log('fn is called');
    }

}

// 参照:グローバルスコープ
console.log(v);   // block_var
console.log(l);   // Uncaught ReferenceError: fnInner is not defined
console.log(c);   // Uncaught ReferenceError: fnInner is not defined

fn();   // fn is called

'block_var'と'fn is called' が表示できるのは、変数v関数fnのスコープがグローバルスコープになっている、つまりブロックスコープを作っていないということです。

var と関数の宣言はブロックスコープを無視する、は覚えておこうと思います。

スコープの特性

これらのスコープの種類を踏まえて、JavaScriptのスコープには以下の特性があります。

同一スコープ内では同じ変数名の宣言はできない

varfunction の宣言は例外的にできるそうです。

まずやらないですが、letvar を混ぜた場合でも宣言はできません。

let a = 1;
var a = 2;  // Uncaught SyntaxError: Identifier 'a' has already been declared

エラーの翻訳は「構文エラー: 識別子 'a' は既に宣言されています。」でした。

スコープが異なれば同じ名前の変数が宣言できる

同一スコープ内ではだめ、ということはスコープが異なればOKです。
以下はグローバルスコープ、ブロックスコープ、さらにその中のブロックスコープ、それぞれで同じ変数名(x)で宣言しています。

let x = 1;
{
  let x = 2;
  console.log(x);  // 2

  {
    let x = 3;
    console.log(x);  // 3
  }
}

console.log(x);  // 1

同じ変数名でもスコープごとに別々の変数として扱われます。

注意したいのは以下のような場合です。

let x = 1;
{
  console.log(x);    // エラー(Uncaught ReferenceError: Cannot access 'x' before initialization)

  let x = 2;
}

console.log(x) の値は現在のスコープの中、let x = 2 だから2かとおもいきや、エラーになります。

これはブロックスコープの中で変数の巻き上げ(ホイスティング)が起こるためです。
let の変数宣言の場合は何も代入されないので、このように「初期化前のため参照できません」と言われます。

ホイスティングについてはこちらでまとめました。

宣言はできるだけスコープの最初にしましょう!

変数の参照は同じスコープ→外側 の順

この参照が書かれている現在のスコープから外側のスコープへ定義されている変数・関数をたどっていくことをスコープチェーンと言います。

let x = 1;
{
  let x = 2;
  {
    console.log(x);  // 2
  }
}

{
  {
    console.log(x);  // 1
  }
}

上の例のように、参照しているスコープで変数の定義がされていない場合は、一つ外側のスコープに探しに行きます。それでもなければさらに一つ外...となります。

ここでひとつ脱線ですが、定義(宣言)ではなく代入を考えてみます。

let x = 1;
{
  console.log(x);  // 1
  x = 2;
  
  {
    console.log(x);  // 2
    x = 3;
  }
  console.log(x);  // 3
}

console.log(x);  // 3

この場合は、定義は1行目でのみで行われていて、グローバル変数なので、変数xはブロックスコープからも参照できます。 つまり同一の変数に順に代入しているだけなので、上記のように出力されます。

同じ名前の変数をいろいろなところで宣言するケースもあまりないと思いますが、混乱したときに振り返られるようにしました。

「宣言(宣言したスコープはどこか)、代入・参照(宣言したスコープ内かどうか)」を注意して考えて行こうと思います。

レキシカルスコープとは静的に決まるスコープ

ようやくレキシカルスコープに戻ってきました(本題はクロージャー)。

lexical scope(字句の範囲)」と、翻訳されました。あとは「lexical=語彙的」などです。
(どうしても「歴史カル」と頭で変換されてしまう日本語脳を持っているのは私ぐらいでしょうか。。)

レキシカルスコープは、定義側というより参照側目線の印象を持ちました。

関数内の、変数が参照するスコープは、関数の定義時に決定するというものです。

let x = 'global';

function A() {
  console.log(x);    // 参照先がグローバルスコープで固定
}
A();    // global

function B() {
  let x = 'fnB';
  console.log(x);    // 参照先が現在の関数スコープで固定
  
  A();    // 関数A内のxの参照先は固定されている→global
}
B();    // 関数A内のxの参照先は固定されている→fnB

// 再代入してみると...?
x = 'global2';
A();    // global2

関数A内で参照している変数xはグローバル変数のx、関数B内で参照している変数xは1行上、同じ関数スコープ内のローカル変数のxというのはすぐ分かると思います。

問題は、関数B内で参照している関数A、から参照している変数xです。

関数A内での変数xのスコープは定義時に決まるため、この場合グローバルスコープにある変数が参照されます。

はじめx = 'global' までが固定なのかと思ったのですが、試しに x = 'global2' と再代入して関数Aを実行してみたところ、'global2' が出力されました。

固定されるのはあくまでスコープであることがわかりました。

このレキシカルスコープは、JavaScript以外にもいくつかの言語で使われているそうです。
反対にダイナミックスコープ(動的スコープ)を使う言語もあります。
これはざっくりいうとレキシカルと反対です。参照元の位置でその都度スコープが変わります。

ここまででレキシカルスコープが理解できました。
お次はついにクロージャーについてです。

クロージャーとは

いくつか記事を読んでなんとなくわかってきました。
そのうちの個人的に特に分かりやすかった記事を載せます。

【JavaScriptの基礎】レキシカルスコープとクロージャを理解する | WEMO

関数内部で定義されたローカルな関数がその親関数の実行によって外部スコープへと持ち出される時、その瞬間の環境を記憶したクロージャという特殊なオブジェクトへと変化する。

関数とスコープ · JavaScript Primer #jsprimer

クロージャーとは「外側のスコープにある変数への参照を保持できる」という関数が持つ性質のことです。

[JavaScript] 猿でもわかるクロージャ超入門 2 関数の中の関数 · DQNEO日記

クロージャとは関数である。

イメージ図

クロージャーの例としてよく書かれているコードから、クロージャーを示してみます。

ここでは関数を代入した変数のことを「クロージャー」とします。関数ですね。

代入した関数では、return でその「中の関数」を返しているのがポイントだと思いました。

「中の関数」では、外部で宣言した変数への参照を保持することができます。

なので、繰り返しクロージャーの関数を実行すると(calc())、この上↑の場合は、x が1ずつ増えます。

あるいは、異なる変数に親関数の戻り値を代入すると、それは別々のオブジェクトになります。

それが親関数が関数ファクトリー(Factory)言われる所以だそうです。

その他に覚えておきたいこと

本当は触れておきたかったメモリについても少しメモします。

ガベージコレクション

ガベージコレクションとは、どこからも参照されなくなったデータを不要なデータと判断して自動的にメモリ上から解放する仕組みのことです。

言語によっては「メモリの解放」方法は異なるようです。JSはガベージコレクションという、自動的にデータを削除する仕組みが備わっています。

下記のようなコードでは、関数内の変数は、実行完了時に参照(データ)を破棄されます。
実行の都度、新しい変数xを保管しては破棄しています。

function printX() {
  const x = "X";
  console.log(x);
}

printX();  // X xを定義、そして破棄
printX();  // X xを定義、そして破棄 

一方で、クロージャーの部分で登場した変数xは破棄されることなく保持されます。 内部関数(inner)から参照されているからです。

クロージャーは安全・便利!と多用していると、メモリの問題もあるというお話でした。

さいごに

クロージャーまで書くことができてよかったです。
(※適切な言葉と表現で書けたかどうかは怪しい)

まだ少しパターンの違うコードが出てきたら固まりそうだなと思いますが、結局は見て書いて慣れることかなと思いました。

最後に触れた「メモリ」について書くことが、 前の記事からの最終目標なのですが、果たして。。!?

おしまい。