【JavaScript】いまさらだけどクロージャを理解する

2024-04-16JavaScript

はじめに

JavaScriptにはクロージャというものがあります。恥ずかしながら、このクロージャについて理解が薄かったので、調べてみました。

クロージャとは

クロージャとは以下のようなコードで示されたものです。

function func(x) {
  let val = x;
  return function() {
    val++;
    console.log(val);
  }
}

const f = func(10);
f();
f();
f();

この実行結果は以下のようになります。

11
12
13

f()を実行するたびに関数func()の内部変数valの値がインクリメントされていきます。なぜこのようになるのでしょうか。

MDNのクロージャの項目には、以下のように書いてあります。

クロージャは、組み合わされた(囲まれた)関数と、その周囲の状態(レキシカル環境)への参照の組み合わせです。言い換えれば、クロージャは内側の関数から外側の関数スコープへのアクセスを提供します。

正直なところよくわかりません。「レキシカル環境」ってなんでしょう?
そこで、まず「レキシカル環境」から調べてみます。

レキシカル環境とは

レキシカル環境とは、関数呼び出しの際やスクリプト全体それぞれについて内部的に作成される隠れたオブジェクトのことです。レキシカル環境は2つのオブジェクトからなります。

  1. 環境レコード
  2. 外部のレキシカル環境への参照

環境レコードは、環境内で定義されたすべてのローカル変数をプロパティとして持ちます。たとえば、

let val;
let str;

val = 10;
str = "string";

function func(x) {
    ...
}

func(10);

上記のようなコードであれば、プログラム開始時のレキシカル環境は次のようになります。(イメージ。以下同じ)

const globalLexicalEnvironment = {
  {
    val: <uninitialized>,
    str: <uninitialized>,
    func(x) { ... }
  }, {
    outer: null
  }
}

valstr<uninitialized>(特別な内部状態です)なのは、開始時には解析エンジンはプロパティの存在を知っていますが初期化されていないためです。プログラムが実行され、valstrが定義されると、まずundefinedとなり、代入と同時に値がセットされます。

さらにプログラムが実行され、関数func()が呼び出されると、新たなレキシカル環境が作成されます。

const funcLexicalEnvironment = {
  {
    x: 10
  }, {
    outer: globalLexicalEnvironment
  }
}

このfuncLexicalEnvironmentは不要となった(どこからも参照されなくなった)時点でメモリから削除されます。

スコープチェーン

上記の例で関数func()が以下のように定義されていたとします。

function func(x) {
  let val = 1000;
  console.log(x + val);
  console.log(str);
}

この関数の実行結果は以下のようになります。

1010
string

変数valは関数内で定義されていますが、strは定義されていません。しかしJavaScriptでは問題なく出力できます。これについて、レキシカル環境を用いて考えてみます。

まず、関数実行開始時のレキシカル環境は以下のようになります。

const funcLexicalEnvironment = {
  {
    x: 10,
    val: <uninitialized>
  }, {
    outer: globalLexicalEnvironment
  }
}

funcLexicalEnvironmentは、プロパティとしてvalを持ちますが、strは持ちません。valはこのあと定義され代入されるので、値1000がセットされます。

一方、strは持っていないため、外部のレキシカル環境を参照します。参照される外部レキシカル環境(globalLexicalEnvironment)はプロパティとしてstrを持っており、この値が参照されます。このため、関数内でstrが定義されていなくても値が出力できます。

このように、レキシカル環境を最近に作成されたものから順番にたどっていくことをスコープチェーンといいます。

関数が関数を返す場合

ここで、冒頭に示したクロージャとされるコードについてレキシカル環境を用いて考えてみます。

関数func()実行終了時のレキシカル環境は以下のようになります。

const funcLexicalEnvironment = {
  {
    x: 10,
    val: 10,
    function() { ... }
  }, {
    outer: globalLexicalEnvironment
  }
}

const globalLexicalEnvironment = {
  {
    function func(x) { ... },
    f: funcLexicalEnvironment.function()
  },
  {
    outer: null
  }
}

このように、グローバルなレキシカル環境globalLexicalEnvironmentはプロパティfの値として、関数func()内で作成された無名関数への参照を持ちます。このとき、作成された無名関数はそれが作成されたレキシカル環境への参照[[Environment]]を隠しプロパティとして持っています。

これにより、レキシカル環境funcLexicalEnvironmentはによりglobalLexicalEnvironment.fを通して参照されるため、関数func()の実行が終了してもメモリから削除されず、funcLexicalEnvironment.valは生き続けます。そのため、f()が呼び出されるたびにvalの値がインクリメントされていきます。

ここで改めて、MDNのクロージャの項目を見てみます。

クロージャは、組み合わされた(囲まれた)関数と、その周囲の状態(レキシカル環境)への参照の組み合わせです。言い換えれば、クロージャは内側の関数から外側の関数スコープへのアクセスを提供します。

まさに「(関数内で作成された)関数」と「関数が作成されたレキシカル環境」の組み合わせとなっています。冒頭で挙げた例がクロージャということになります。

参考

クロージャの使用例

クロージャの使い方として、変数や関数の隠蔽があります。ちょうどクラスにおけるプライベートメンバと同じようなものを実装する事ができます。MDNの例がわかりやすいので、これをそのまま持ってきます。

const makeCounter = function () {
  let privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment() {
      changeBy(1);
    },

    decrement() {
      changeBy(-1);
    },

    value() {
      return privateCounter;
    },
  };
};

const counter1 = makeCounter();
const counter2 = makeCounter();

console.log(counter1.value()); // 0.

counter1.increment();
counter1.increment();
console.log(counter1.value()); // 2.

counter1.decrement();
console.log(counter1.value()); // 1.
console.log(counter2.value()); // 0.

最初に定義される無名関数は、内部で変数privateCounterと関数changeBy()を持ちます。そして、戻り値として、関数increment(), decrement(), value()を持つオブジェクトを返します。

定義された関数は、makeCounter()によって呼び出され、変数counter1counter2にそれぞれ別のオブジェクトとして代入されます。

関数内で定義された変数privateCounterと関数changeBy()は、関数の外から直接アクセスできませんが、オブジェクトに定義された関数increment(), decrement(), value()を通してアクセスできます。

makeCounter()実行直後のレキシカル環境は以下のようになります。

const counter1LexicalEnvironment = {
  {
    privateCounter: 0,
    changeBy(val) { ... },
    {
      increment() { ... },
      decrement() { ... },
      value() { ... }
    }
  }, {
    outer: globalLexicalEnvironment
  }
}

const counter2LexicalEnvironment = {
  {
    privateCounter: 0,
    changeBy(val) { ... },
    {
      increment() { ... },
      decrement() { ... },
      value() { ... }
    }
  }, {
    outer: globalLexicalEnvironment
  }
}

const globalLexicalEnvironment = {
  {
    makeCounter: function() { ... }
    counter1: counter1LexicalEnvironment.{
      increment() { ... },
      decrement() { ... },
      value() { ... }
    },
    counter2: counter2LexicalEnvironment.{
      increment() { ... },
      decrement() { ... },
      value() { ... }
    }
  }, {
    outer: null
  }
}

counter1, counter2それぞれで別のレキシカル環境が作成されることに注意します。各レキシカル環境へは、オブジェクトを通じて参照されます。このため、各レキシカル環境は関数の実行が終了しても存在し続け、プロパティprivateCounterもメモリに残ります。

まとめ

JavaScriptにおけるクロージャについて調べてまとめてみました。「レキシカル環境」というものを考えることによって、今まで理解の薄かったクロージャの動作に対する理解を深めることができました。

クロージャを使うと、変数や関数を隠蔽することができ、保守性の高いプログラムが書けます。

JavaScript

Posted by izadori