[JavaScript] クロージャの基本的な仕組みとメモリリークの問題:関数内関数とガベージコレクション

2020 年 5 月 13 日

クロージャとは

「クロージャ」(closure:閉包 へいほう)という語は、言語や人によって定義が微妙に異なる場合がある。

  • 狭義:関数を返す関数
  • 広義:引数以外の変数を実行時の環境ではなく、自身が定義された環境(静的スコープ)において解決する(Wikipediaより)

ここでは、後者について説明する。

JavaScriptにおけるクロージャ

function doSample() {
	var num = 1;
	
	function doInnerFunction() {
		return ++num;
	}
	
	return doInnerFunction();
}

alert(doSample()); // 2

関数内にある関数(doInnerFunction())のスコープにおいて、より上位のスコープにある変数・関数にアクセスできる

JavaScriptでは基本的に関数単位のスコープしかないので、必然的に上位のスコープを持つのも関数となる。それをエンクロージャ(enclosure)と呼ぶ。

関数が多段階の入れ子になっている場合も同様。

function doSample() {
	var num = 1;
	
	function doInnerFunction() {
		var plus = 2;
		
		function doThirdFunction() {
			return ++plus;
		}
		
		return num + doThirdFunction();
	}
	
	return doInnerFunction();
}

alert(doSample()); // 4

例では通常の関数(名前=識別子のついた関数)を宣言しているが、もちろん無名関数の場合も同じ。

クロージャ用データの仕組み

	function doSample() {
		var num = 1;
		var sampleObj = new Object;
		
		function doInnerFunction() {
			sampleObj.newNum = num + 1;
		}
		
		doInnerFunction();
		
		alert(sampleObj.newNum); // 2
	}

クロージャは、上位の関数(エンクロージャ)から内部の下位の関数へ、ローカル変数と関数内関数をまとめたオブジェクトを引数のように受け渡しているとみることもできる。
実際に、下記のようにしても動作としてはほとんど同じ。

	function doSample() {
		var closureObj = new Object;
		closureObj.num = 1;
		closureObj.sampleObj = new Object;
		
		function doInnerFunction(obj) {
			obj.sampleObj.newNum = obj.num + 1;
		}
		
		doInnerFunction(closureObj);
		
		alert(closureObj.sampleObj.newNum); // 2
	}

では、クロージャ用のデータがJavaScriptエンジン内部で形づくられるのはどのタイミングだろうか。

それは、関数内関数がなんらかの操作をされたとき

かならずしも、「doInnerFunction();」のように直接実行されたときに限らないことに注意。

(裏を返せば、関数が定義されていても、それが実行されたり関数オブジェクトとして変数や引数に渡されなければ、つまり宣言されているだけなら、クロージャ用のデータは生成されない)

	var functionArray = [];
	
	function doSample() {
		var num = 1;
		var sampleObj = new Object;
		
		function doInnerFunction() {
			sampleObj.newNum = num + 1;
		}
		
		for (var i = 0; i < 10; ++i) {
			functionArray.push(doInnerFunction);
		}
	}
	
	doSample();

ここで、functionArrayはグローバルオブジェクトのため、「functionArray = null;」のように明示的に中身を空にしないかぎり、データは破棄されない(ガベージコレクションの仕組み)。

そのfunctionArrayにdoInnerFunctionが複数格納されているが、実行はされていない。しかし、それでもクロージャ用のデータは生成されている。

理由は、どのタイミングでfunctionArray内の各doInnerFunctionが実行されるかわからないため、エンジンはあらかじめクロージャ用データをつくっておくしかないから。

メモリリークの問題

よって、functionArray内のデータが破棄されないかぎり、各doInnerFunctionだけでなく、そのそれぞれに結びつけられたクロージャ用のデータ(num、sampleObj)もメモリに残ることになる。

(もしfunctionArrayがdoSample()のローカル変数ならば、その関数の実行が終了した時点で自動的に破棄されるので問題は発生しない)

JavaScriptではPHPとは異なり、クロージャ用のデータは自動的に生成されるため、関数内関数が複数あり、しかもそれらが多段階になっている(=ネストが深くなっている)場合や、まったく別のスコープ(クラスなど)へ関数を受け渡した場合には、どこになんのクロージャ用データが残っているのか判然とせず、メモリリークの問題を引き起こしてしまう。

まとめ

特段必要がないなら、クロージャは無闇に濫用すべきものではない。やや面倒でも、むしろクロージャの仕組みが働かないように工夫したほうがいい(たとえば、関数内関数ではなく、より上のスコープに関数を定義するようにする、など)。

# ActionScript 3.0についてはこちら