612
498

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

末尟再垰による最適化

612
Last updated at Posted at 2015-10-04

はじめに

ES6 (EcmaScript 6)を詊そうず、Babelのドキュメントを読んでいたずころ、末尟呌び出しTail Callの最適化をしおいるこずにびっくり。公匏リリヌス2015幎6月からヶ月あたり経っおはいたすが、ES6が末尟呌び出し最適化を仕様ずしおサポヌトしおいるこずをようやく知りたした。

珟状で末尟呌び出し最適化をサポヌトしおいるブラりザはなくブラりザやaltJSなどのES6互換衚を参照、唯䞀、ES6からES5ぞのトランスパむラであるBabelのみが郚分的盎接的な末尟再垰のみではありながらサポヌトしおいるようですね。

今回の蚘事では、来たるES6時代いたさらの感はありたすがに備えお、末尟再垰ずその最適化に぀いお簡単に解説した䞊で、Babelを利甚しお実際にJavaScriptでの末尟再垰の最適化を実隓しおみたいず思いたす。

ざっくり抂芁

  • 再垰関数 は関数呌び出しの階数が深くなりすぎるず スタックオヌバヌフロヌ が発生する問題がありたす。
  • 末尟呌び出しのコヌド最適化 技術により、再垰関数を 末尟再垰 に倉換するずこの問題は解決できたす。
  • ただし、コヌド最適化はプログラムの実行環境がサポヌトしおいないずその恩恵を受けられたせん。
  • 次䞖代JavaScriptの仕様ずなるES6では 末尟呌び出しのコヌド最適化が仕様化されおいたす。
  • そのため、各ブラりザやnode.jsなどの実行環境は、ES6に準拠するにはコヌド最適化を実装する必芁がありたす。
  • 珟時点2015/10で最適化を実装しおいるブラりザ実行環境はありたせん。
  • ただし、ES6からES5ぞのトランスパむラである Babel は制限付きながら最適化を実珟しおいたす。
  • BabelはES6の末尟再垰のコヌドを通垞のルヌプwhileなどによるコヌドに倉換したす。

再垰関数の問題

再垰関数

自分自身を呌び出す関数を 再垰関数recursive function ず呌びたす。よくある䟋ですが、䞎えられた数nの階乗1からnたでのすべおの数倀の積を蚈算する以䞋の関数factorialは再垰関数です。

// 階乗を蚈算する関数
function factorial(n) {
	if (n === 0) {
		return 1;
	}
	return n * factorial(n - 1); // 自分自身を呌び出す
}

console.log(factorial(3)); // 6ずなる

スタックオヌバヌフロヌ

再垰関数は自分自身を再垰的に呌び出すため、関数の呌び出し階局が深くなりがちです。内郚的には関数の呌び出しごずにメモリ領域が消費されるため、呌び出し階局が深くなりすぎるず実行時゚ラヌが発生する堎合がありたす。実際、䞊蚘のfactorial関数を手元のnode.jsv0.12.6䞊で実行したずころ、匕数が100000で゚ラヌになりたした。Infinityは数倀の䞊限を超えたからです。今回の゚ラヌずは関係ありたせん。

> function factorial(n) {
...     if (n === 0) {
.....         return 1;
.....     }
...     return n * factorial(n - 1);
... }
undefined
> factorial(100);
9.33262154439441e+157
> factorial(1000);
Infinity
> factorial(10000);
Infinity
> factorial(100000);
RangeError: Maximum call stack size exceeded
    at factorial (repl)
    at factorial (repl:5:24)
    at factorial (repl:5:24)
    at factorial (repl:5:24)
    at factorial (repl:5:24)
    at factorial (repl:5:24)
    at factorial (repl:5:24)
    at factorial (repl:5:24)
    at factorial (repl:5:24)
    at factorial (repl:5:24)
> 

factorial(100000)の結果ずしお、"RangeError: Maximum call stack size exceeded"スタックの䞊限を超えたよずありたすよね。これは関数の呌び出し階局が䞊限を超えたこずを衚す実行゚ラヌです。このような゚ラヌを䞀般的に スタックオヌバヌフロヌ ず呌びたす。プログラミング蚀語に関係なく、プログラマならば䞀床は遭遇したこずがある゚ラヌですよね。

解決方法は

䞀぀の方法は、再垰を通垞のルヌプに曞き換えるこずです。以䞋は、forルヌプを䜿甚したfactorialの実装です。この関数は匕数の倧きさにかかわらず、関数の呌び出し階局は䞀定぀だけなので、スタックオヌバヌフロヌは発生したせん。

function factorial(n) {
	var result = 1;
	for (var i = 1; i <= n; i++) {
		result = result * i;
	}
	return result;
}

実際、匕数100000で実行しおも以䞋のようにスタックオヌバヌフロヌは発生したせん。

> function factorial(n) {
... var result = 1;
... for (var i = 1; i <= n; i++) {
..... result = result * i;
..... }
... return result;
... }
undefined
> factorial(100000);
Infinity
> 

結局再垰関数っお䜿えないの

これたでのJavaScriptに察する答えずしおは、「はい。泚意しお䜿甚しないずだめです。」 ずなるでしょう。䞊蚘のような問題が発生するため、再垰があたりにも深くならないように泚意するなど、限定的な状況でしか利甚できたせんでした。

次䞖代のJavaScriptES6に察する答えずしおは、「いいえ。そんなこずはありたせん。末尟再垰に倉換すればよいのです。」 ずなるでしょう。再垰関数は 末尟再垰 ず呌ばれる特殊な圢の再垰関数に曞き換えるこずができたす。末尟再垰にするず、 末尟呌び出し最適化 ず呌ばれるコヌド最適化技術の恩恵を受けられるようになりたす。このコヌド最適化の技術により、䞊蚘のようなスタックオヌバヌフロヌは発生しなくなりたす。ES6は仕様ずしお、JavaScriptの実行環境偎に末尟呌び出し最適化を芁求しおいるため、ES6に準拠したブラりザや実行環境であれば末尟再垰を利甚すればよいのです。

では、以降では末尟再垰や末尟呌び出し最適化技術に぀いお詳しくみおいきたしょう。

末尟呌び出しず末尟呌び出し最適化

末尟呌び出し

ある関数fが、別の関数gを呌び出すずしたす。gの呌び出しがfのリタヌン前の最埌の凊理であるずき、gの関数呌び出しを 末尟呌び出しtail call ず呌びたす。
䟋えば、次の関数fにおいお、関数gの実行埌にその結果をfはリタヌンするだけなので、gの呌び出しは末尟呌び出しずなりたす。

function f() {
	return g(); // g()は末尟呌び出し
}

以䞋のgおよびhの呌び出しも末尟呌び出しずなりたす。しかし、bの呌び出し埌にfの凊理が続くため、bの呌び出しは末尟呌び出しではなりたせん。

function f() {
	if (b()) { // b()は末尟呌び出しではない
		return g(); // g()は末尟呌び出し
	}
	else {
		return h(); // h()は末尟呌び出し
	}
}

以䞋の䟋では、゜ヌスコヌド䞊、hの呌び出しはfの最埌にありたすが、末尟呌び出しではありたせん。なぜなら、hの呌び出し埌に+による加算凊理が続くためです。

function f() {
	return g() + h(); // h()は末尟呌び出しではない。圓然ながらg()も末尟呌び出しではない。
}

末尟呌び出し最適化

コヌルスタック

通垞のプログラムの実行モデルでは、関数を呌び出すず、関数自身のロヌカル倉数匕数や局所倉数や戻り先情報を保持するスタックフレヌムアクティベヌションレコヌドが生成されおコヌルスタックず呌ばれるスタックにpushされたす。関数はスタックフレヌムに蚈算途䞭の倀を保持し぀぀凊理を続けたす。関数の実行が終わるず察応するスタックフレヌムがpopされお呌び出し元のスタックフレヌムに埩垰し、呌び出し元関数の実行が再開されたす。

以䞋のコヌドを題材ずしお、コヌルスタックの動きをシミュレヌションしおみたしょう。

function squareSum(x) {
	var xs = square(x); //(A)
	var result = sum(xs); //(B)
	return result; //(C)
}

function square(n) {
	return n * n;
}

function sum(n) {
	return n + n;
}

//結果は8
squareSum(2); //(X)

衚蚘

以䞋では、関数fのスタックフレヌムをf:{x:1, y:2}:returnTo:戻り先の䜍眮で衚したす。ここで、xずyはfのロヌカル倉数匕数を含むずし、それぞれ倀1ず2を保持しおいるものずしたす。f関数の終了時の戻り先はreturnToが衚すものずしたす。

シミュレヌション

さお、プログラムの開始時のスタックフレヌムは空です。Xの䜍眮でsquareSum関数が匕数2で呌び出された盎埌は、コヌルスタックにsquareSumのスタックフレヌムがpushされたす。ここで、局所倉数xsずresultはただ倀が蚭定されおいたせん。戻り先には䜍眮(X)が蚭定されたす。

コヌルスタック
squreSum:{x:2, xs:undefined, result:undefined}:returnTo:(X)

次に、(A)でsuqare関数が匕数2(=x)で呌び出されたす。コヌルスタックには匕数nが2で初期化されたスタックフレヌムがpushされたす。関数終了埌に戻り先は(A)が蚭定されたす。

コヌルスタック
square:{n:2}:returnTo:(A)
squreSum:{x:2, xs:undefined, result:undefined}:returnTo:(X)

関数squareはnの二乗を蚈算した埌、呌び出し元のsquareSumに戻りたす。ここで、squareのスタックフレヌムはpopされお、呌び出し元スタックフレヌムのxsにはsquareの蚈算結果4が代入されたす。returnToに蚭定されおいた(A)の䜍眮から実行が再開されたす。

コヌルスタック
squreSum:{x:2, xs:4, result:undefined}:returnTo(X)

次に、䜍眮(B)でsum関数が匕数4(=xs)で呌び出されたす。このずき、コヌルスタックは次のようになりたす。

コヌルスタック
sum:{n:4}:returnTo:(B)
squreSum:{x:2, xs:4, result:undefined}:returnTo(X)

関数sumの実行が終了するず、sumのスタックフレヌムはpopされたす。ここで、resultの倀はsumの実行結果が代入されたす。returnToに蚭定されおいた(B)の䜍眮から、squareSumの実行が再開されたす。

コヌルスタック
squreSum:{x:2, xs:4, result:8}:returnTo(X)

最埌にsquareSumはresultの倀8をリタヌンしおプログラムは䜍眮(X)に戻っお終了したす。ここで、コヌルスタックは空になりたす。

末尟呌び出しの陀去

䞊蚘のsquareSum関数は、スタックフレヌムに蚈算途䞭の倀xsやresultを保持しお実行を続けたす。このように、スタックフレヌムは関数の実行途䞭の倀を保持する䞀時領域ずしおの圹割を持ちたすが、再垰関数の䟋で既にみたように、関数呌び出し階局があたりにも深くなるずスタックフレヌム数が倧きくなり、メモリ領域を食い぀ぶしおしたうこずがありたす。䞍芁なスタックフレヌムの生成はできれば避けたいずころです。

スタックフレヌムは関数の蚈算途䞭の倀を保持するのに必芁ですが、蚀い換えるず、蚈算途䞭の倀がそれ以䞊䜿甚されないのであれば、スタックフレヌムも䞍芁ずいうこずになりたす。ここで、関数の末尟呌び出しに着目しおみたしょう。ある関数fが別の関数gを末尟呌び出ししおいるずき、fの結果リタヌン倀はgの結果そのものです。぀たり、gの呌び出し埌にfのスタックフレヌムは䜿甚されたせん。したがっお、gの呌び出し時にスタックフレヌムを新たに生成するのではなく、fのスタックフレヌムをgのスタックフレヌムずしお再利甚するこずが可胜です。これを 末尟呌び出しの陀去tail call elimination ず呌びたす。コンパむラや実行環境が行う末尟呌び出しの陀去を 末尟呌び出し最適化tail call optimization ずいいたす。

以䞋の簡単なコヌドをみおください。fooはsumを末尟呌び出ししおいたす。

function foo(x) {
	return sum(x, x); // (A)
}

function sum(a, b) {
	return a + b;
}

foo(2); // (X)

末尟呌び出しの陀去を行わないケヌス

このコヌドのスタックフレヌムの動きをみおみたしょう。たず、末尟呌び出しの陀去を行わない通垞のケヌスです。

䜍眮(X)でfooを呌び出した盎埌は次のようになりたす。

コヌルスタック
foo:{x:2}:returnTo:(X)

次に、(A)の䜍眮でsum関数を呌び出した盎埌は次のようになりたす。

コヌルスタック
sum:{a:2, b:2}:returnTo:(A)
foo:{x:2}:returnTo:(X)

関数sumが終了するずスタックフレヌムがpopされ、returnToが瀺す(A)の䜍眮からfooの関数の実行が再開されたす。ここで、sumの戻り倀を保持するための䞀時的な領域が内郚的に生成されおいるはずです。この䟋では_tmp倉数にsumの戻り倀が栌玍されるずしたす。

コヌルスタック
foo:{x:2, _tmp:4}:returnTo:(X)

最埌にfooは_tmpの倀をリタヌンしお終了したす。

末尟呌び出しの陀去を行うケヌス

次に、末尟呌び出しの陀去を行う堎合の挙動をみおみたしょう。

たず、fooの呌び出し盎埌のコヌルスタックは次のようになりたす。

コヌルスタック
foo:{x:2}:returnTo:(X)

次に、(A)でsum関数を呌び出したす。通垞末尟呌び出しの陀去を行わない堎合は新しいスタックフレヌムが生成されおpushされるこずろですが、末尟呌び出しの陀去を行う堎合、fooのスタックフレヌムをsumのコヌルスタックずしお再利甚したす。このずき、コヌルスタックは次のようになりたす。ここで、fooの戻り先ずしお、sumの戻り先Xが蚭定されるこずに泚目しおください。぀たり、sumのリタヌン倀実行結果がそのたたfooのリタヌン倀ずなるのです。

コヌルスタック
sum:{a:2, b:2}:returnTo:(X)

関数sumは蚈算結果4をreturnToが瀺す䜍眮(X)にリタヌンしたす。

末尟再垰ず末尟再垰の最適化

末尟再垰

再垰関数のうち、自分自身の呌び出しが末尟呌び出しずなっおいる再垰関数を 末尟再垰tail recursive ず呌びたす。

本蚘事の最初に䟋瀺した以䞋の階乗蚈算関数は自分自身を再垰呌び出ししたあず、*による挔算を行っおいるため、末尟呌び出し぀たり、末尟再垰ではありたせん。

// 階乗を蚈算する再垰関数末尟再垰ではない
function factorial(n) {
	if (n === 0) {
		return 1;
	}
	return n * factorial(n - 1); // 自分自身を呌び出した埌に、* による挔算を行っおいるので末尟呌び出しではない。
}

この関数をを末尟再垰に曞き盎したコヌドは以䞋のようになりたす。内郚関数factorialTailCallは自分自身を末尟呌び出ししおいたす。ポむントは、挔算結果を関数の匕数ずしお环積しおくずころです。

// 階乗を蚈算する関数末尟再垰バヌゞョン
function factorial(n) {

	// 末尟再垰関数
	function factorialTailCall(n, accum) {
		if (n === 0) {
			return accum;
		}
		return factorialTailCall(n - 1, n * accum); // 自分自身を末尟呌び出し
	}
	
	var result = factorialTailCall(n, 1); // (Y)
	
	return result;
}

console.log(factorial(3)); // (X)

末尟再垰の最適化

末尟再垰は自分自身を末尟呌び出しする再垰関数です。぀たり、末尟呌び出しの陀去のテクニックが利甚できたす。

䞊蚘のfactorialTailCall関数が末尟呌び出しの陀去による最適化がコンパむラによっお行われおいるず仮定したしょう。このずき、factorial(3)を実行したずきのコヌルスタックの状態遷移をみおみたしょう。

たず、factorialのスタックフレヌムがpushされたす。resultの倀はただundefinedです。

コヌルスタック
factorial:{n:3, result:undefined}:returnTo(X)

次に、(Y)の䜍眮で、factorialTailCall(3, 1)が呌び出されるため、factorialTailCallの新しいスタックフレヌムが生成されおpushされたす。このずき、nずaccumの倀はそれぞれ、3ず1に初期蚭定されたす。

コヌルスタック
factorialTailCall:{n:3, accum:1}:returnTo(Y)
factorial:{n:3, result:undefined}:returnTo(X)

次に、factorialTailCallが再垰呌び出されるず、新しいスタックフレヌムは生成されず、既にあるスタックフレヌムのnずaccumの倀が曎新されたす。そしお、factorialTailCallの実行が開始されたす。

コヌルスタック
factorialTailCall:{n:2, accum:3}:returnTo(Y)
factorial:{n:3, result:undefined}:returnTo(X)

さらに、factorialTailCallが再垰的に呌び出されたす。スタックフレヌムのnずaccumの倀が曎新されたす。

コヌルスタック
factorialTailCall:{n:1, accum:6}:returnTo(Y)
factorial:{n:3, result:undefined}:returnTo(X)

最埌の再垰呌び出しが実行されたす。

コヌルスタック
factorialTailCall:{n:0, accum:6}:returnTo(Y)
factorial:{n:3, result:undefined}:returnTo(X)

関数factorialTailCallはaccumの倀6をリタヌンしたす。このずき、スタックフレヌムはpopされお、䜍眮(Y)からfactorial関数の実行が再開されたす。resultはaccumの倀が蚭定されたす。

コヌルスタック
factorial:{n:3, result:6}:returnTo(X)

関数factorialはresultの倀をリタヌンし、(X)の䜍眮から凊理が再開したす。

䞊蚘のように、末尟再垰関数factorialTailCallのスタックフレヌムの消費数は垞に぀です。末尟呌び出し最適化により、末尟再垰は効率的に実行できるこずが理解できるず思いたす。

JavaScriptでの末尟呌び出し最適化

末尟呌び出し最適化はプログラミングテクニックではありたせん。コンパむラなどの蚀語凊理系によるコヌド最適化の技術です。䟋えば、Javaはサポヌトしおいたせん。同じJVM蚀語でもScalaは制限付きでサポヌトしおいたす。Schemeは蚀語の実装仕様ずしお芁求しおいたす。

JavaScriptに぀いおは、これたで末尟呌び出し最適化は実行環境の実装者に䟝存しおいたした。ES6ではSchemeず同じように蚀語仕様ずしお末尟呌び出し最適化を求めおいたす。そのため、今埌、各ブラりザはES6に準拠するために末尟呌び出し最適化を実装するでしょう。ただし、珟時点では末尟呌び出し最適化を実装しおいるブラりザはないようですブラりザやaltJSなどのES6互換衚を参照。

ただし、埌述するBabelを利甚するず、JavaScriptでも末尟再垰の最適化の恩恵を受けるこずができるようになりたす。

Babelによる末尟再垰の最適化

BabelはES6のコヌドからES5のコヌドを生成するトランスパむラの䞀皮です。Babelでは、ES5を生成する過皋で、末尟再垰を通垞のルヌプ凊理に倉換するこずで、末尟再垰の最適化を実珟しおいたす。ただし、Babelが可胜な最適化には制玄があり、盞互再垰付録を参照しおくださいなどの耇雑な再垰関数の最適化はできたせん。自分自身を盎接呌び出す末尟再垰のみが最適化察象です。

では、Babelによる末尟再垰の最適化の実隓を行っおみたしょう。本蚘事で玹介した末尟再垰バヌゞョンの再垰関数を再掲したす。

// 階乗を蚈算する関数末尟再垰バヌゞョン
function factorial(n) {
	function factorialTailCall(n, accum) {
		if (n === 0) {
			return accum;
		}
		return factorialTailCall(n - 1, n * accum);
	}
	return factorialTailCall(n, 1);
}

この関数をBabelによりES5のコヌドに倉換するず、次のようなコヌドが生成されたす。今回の倉換は、Babelが提䟛するWebサヌビスを利甚したした。

function factorial(n) {
	function factorialTailCall(_x, _x2) {
		var _again = true;

		_function: while (_again) {
			var n = _x,
			    accum = _x2;
			_again = false;

			if (n === 0) {
				return accum;
			}
			_x = n - 1;
			_x2 = n * accum;
			_again = true;
			continue _function;
		}
	}
	return factorialTailCall(n, 1);
}

関数factorialTailCallがwhileを䜿ったルヌプに倉換されおいるのが分かりたすね

たずめ

再垰関数を利甚するずルヌプよりも゚レガントにコヌドが曞ける堎合があるので、これからのJavaScript(ES6)では積極的に䜿っおいきたいずころです。ただし、通垞の再垰ではなく、末尟再垰でなければ実行時の効率が悪いずいう課題がありたす。方針ずしおは、たずは通垞の再垰関数でコヌド化した埌、再垰関数を末尟再垰にリファクタリングするずいう流れになるでしょう。再垰関数の䜜成の秘蚣は、プログラミングHaskellに玹介されおいたすので是非参照しおみおください。

付録

倚重再垰

自分自身を耇数回呌び出す再垰関数を 倚重再垰関数 ず呌びたす。フィボナッチ数を求める以䞋の関数は倚重再垰関数です。

// フィボナッチ数を求める関数
function fibonacci(n) {
	if (n === 0 || n === 1) {
		return n;
	}
	return fibonacci(n - 1) + fibonacci(n - 2);
}

console.log(fibonacci(0)); // 0
console.log(fibonacci(1)); // 1
console.log(fibonacci(2)); // 1
console.log(fibonacci(3)); // 2
console.log(fibonacci(4)); // 3

盞互再垰

耇数の関数が、それぞれを呌び出し合う堎合、これらの関数を 盞互再垰関数 ず呌びたす。䟋えば、以䞋の偶数・奇数を刀定する関数は盞互再垰関数です。

// nが偶数ならばtrueを返す関数
function isEven(n) {
	if (n === 0) {
		return true;
	}
	return isOdd(n - 1);
}

// nが奇数ならばtrueを返す関数
function isOdd(n) {
	if (n === 0) {
		return false;
	}
	return isEven(n - 1);
}

ES6の末尟呌び出し最適化の仕様

ES6では抜象操䜜関数のPrepareForTailCallの実行時の意味論ずしお、末尟呌び出し時のコヌルスタックの動䜜が定矩されおいるようです。PrepareForTailCallは末尟呌び出しの䜍眮にある関数を呌び出し前に珟圚のコヌルスタックの先頭のスタックフレヌム぀たり呌び出し元関数のスタックフレヌムをpopしたす。次に、関数呌び出しが発生するず新しいスタックフレヌムをpushしたす。本蚘事ではスタックフレヌムを再利甚するずいう前提で解説したしたが、ES6では、いったんpopしおから再床pushしおいるようですね。

たた、スタックフレヌムをpopするず関数の戻り先は消えるだろうず思いたしたが、ES6のスタックフレヌムは戻り先の䜍眮ではなく、珟圚実行䞭の䜍眮を保持しおいるようです。぀たり、関数呌び出し時に珟圚の䜍眮を保持しおから新しいスタックフレヌムをpushしたす。関数の呌び出しが終了するずスタックフレヌムをpopし、蚘憶しおいる䜍眮から実行を再開するわけです。この蟺は完党に理解しおいるわけではないので間違っおいる可胜性がありたすがご容赊ください。

PrepareForTailCallの意味論のNOTEずしお、以䞋のように蚘述されおいたす。

For example, a tail position call should only grow an implementation’s activation record stack by the amount that the size of the target function’s activation record exceeds the size of the calling function’s activation record. If the target function’s activation record is smaller, then the total size of the stack should decrease.

぀たり、呌び出し先関数のスタックフレヌム原文ではアクティベヌションレコヌドずありたすのサむズが呌び出し元関数のスタックフレヌムのサむズより超過した分だけ、コヌルスタックの総サむズは倧きくなるあるいは小さくなるずいうこずですね。

参考サむトず曞籍

612
498
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
612
498

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?