非常に簡単に言えば、テールコール最適化とは何ですか?
より具体的には、適用できる小さなコード スニペットと、適用できない小さなコード スニペットと、その理由の説明を教えてください。
非常に簡単に言えば、テールコール最適化とは何ですか?
より具体的には、適用できる小さなコード スニペットと、適用できない小さなコード スニペットと、その理由の説明を教えてください。
末尾呼び出しの最適化では、呼び出し元の関数は呼び出し先の関数から取得した値を返すだけなので、関数に新しいスタック フレームを割り当てることを回避できます。最も一般的な用途は末尾再帰です。末尾呼び出しの最適化を利用するために作成された再帰関数は、一定のスタック スペースを使用できます。
Scheme は、すべての実装がこの最適化を提供する必要があることを仕様で保証している数少ないプログラミング言語の 1 つです。そのため、Scheme の階乗関数の 2 つの例を次に示します。
(define (fact x)
(if (= x 0) 1
(* x (fact (- x 1)))))
(define (fact x)
(define (fact-tail x accum)
(if (= x 0) accum
(fact-tail (- x 1) (* x accum))))
(fact-tail x 1))
最初の関数は末尾再帰的ではありません。これは、再帰呼び出しが行われると、関数は、呼び出しが返された後に結果を処理するために必要な乗算を追跡する必要があるためです。そのため、スタックは次のようになります。
(fact 3)
(* 3 (fact 2))
(* 3 (* 2 (fact 1)))
(* 3 (* 2 (* 1 (fact 0))))
(* 3 (* 2 (* 1 1)))
(* 3 (* 2 1))
(* 3 2)
6
対照的に、末尾再帰階乗のスタック トレースは次のようになります。
(fact 3)
(fact-tail 3 1)
(fact-tail 2 3)
(fact-tail 1 6)
(fact-tail 0 6)
6
ご覧のように、fact-tail の呼び出しごとに同じ量のデータを追跡するだけで済みます。これは、取得した値を一番上に返すだけだからです。これは、(fact 1000000) を呼び出したとしても、(fact 3) と同じ量のスペースしか必要ないことを意味します。これは非末尾再帰の場合には当てはまらず、そのような大きな値はスタック オーバーフローを引き起こす可能性があります。
簡単な例を見てみましょう: C で実装された階乗関数です。
明らかな再帰的定義から始めます
unsigned fac(unsigned n)
{
if (n < 2) return 1;
return n * fac(n - 1);
}
関数が戻る前の最後の操作が別の関数呼び出しである場合、関数は末尾呼び出しで終了します。この呼び出しが同じ関数を呼び出す場合、末尾再帰です。
fac()
一見末尾再帰のように見えますが、実際にはそうではありません。
unsigned fac(unsigned n)
{
if (n < 2) return 1;
unsigned acc = fac(n - 1);
return n * acc;
}
つまり、最後の操作は乗算であり、関数呼び出しではありません。
fac()
ただし、累積された値を追加の引数として呼び出しチェーンに渡し、最終結果のみを戻り値として再度渡すことにより、末尾再帰に書き換えることができます。
unsigned fac(unsigned n)
{
return fac_tailrec(1, n);
}
unsigned fac_tailrec(unsigned acc, unsigned n)
{
if (n < 2) return acc;
return fac_tailrec(n * acc, n - 1);
}
では、なぜこれが役立つのでしょうか。末尾の呼び出しの後にすぐに戻るため、末尾の位置で関数を呼び出す前に前のスタックフレームを破棄するか、再帰関数の場合はスタックフレームをそのまま再利用できます。
末尾呼び出しの最適化は、再帰コードを次のように変換します。
unsigned fac_tailrec(unsigned acc, unsigned n)
{
TOP:
if (n < 2) return acc;
acc = n * acc;
n = n - 1;
goto TOP;
}
これをインライン化するfac()
と、
unsigned fac(unsigned n)
{
unsigned acc = 1;
TOP:
if (n < 2) return acc;
acc = n * acc;
n = n - 1;
goto TOP;
}
これはと同等です
unsigned fac(unsigned n)
{
unsigned acc = 1;
for (; n > 1; --n)
acc *= n;
return acc;
}
ここでわかるように、十分に高度なオプティマイザーは、末尾再帰を反復に置き換えることができます。これは、関数呼び出しのオーバーヘッドを回避し、一定量のスタック スペースのみを使用するため、はるかに効率的です。
TCO (Tail Call Optimization) は、スマート コンパイラが関数を呼び出し、追加のスタック スペースを必要としないプロセスです。これが発生する唯一の状況は、関数fで実行された最後の命令が関数 g の呼び出しである場合です(注意: gはfである可能性があります)。ここで重要なのは、fがスタック スペースを必要としないことです。単純にgを呼び出して、 gが返すものを返します。この場合、g が実行され、f を呼び出したものに必要な値を返すという最適化を行うことができます。
この最適化により、再帰呼び出しが爆発するのではなく、一定のスタック スペースを使用するようになる可能性があります。
例: この階乗関数は TCOptimizable ではありません。
from dis import dis
def fact(n):
if n == 0:
return 1
return n * fact(n-1)
dis(fact)
2 0 LOAD_FAST 0 (n)
2 LOAD_CONST 1 (0)
4 COMPARE_OP 2 (==)
6 POP_JUMP_IF_FALSE 12
3 8 LOAD_CONST 2 (1)
10 RETURN_VALUE
4 >> 12 LOAD_FAST 0 (n)
14 LOAD_GLOBAL 0 (fact)
16 LOAD_FAST 0 (n)
18 LOAD_CONST 2 (1)
20 BINARY_SUBTRACT
22 CALL_FUNCTION 1
24 BINARY_MULTIPLY
26 RETURN_VALUE
この関数は、return ステートメントで別の関数を呼び出す以外のことを行います。
以下の関数は TCOptimizable です。
def fact_h(n, acc):
if n == 0:
return acc
return fact_h(n-1, acc*n)
def fact(n):
return fact_h(n, 1)
dis(fact)
2 0 LOAD_GLOBAL 0 (fact_h)
2 LOAD_FAST 0 (n)
4 LOAD_CONST 1 (1)
6 CALL_FUNCTION 2
8 RETURN_VALUE
これは、これらの関数のいずれかで最後に発生することは、別の関数を呼び出すことだからです。
おそらく、テール コール、再帰テール コール、テール コールの最適化について私が見つけた最高の高レベルの説明は、ブログの投稿です。
ダン・スガルスキー著。テールコールの最適化について、彼は次のように書いています。
少しの間、次の単純な関数を考えてみましょう:
sub foo (int a) { a += 15; return bar(a); }
では、あなた、またはあなたの言語コンパイラーは何ができるでしょうか? できることは、フォームのコードを
return somefunc();
低レベルの sequence に変換することpop stack frame; goto somefunc();
です。この例では、これは、 を呼び出す前にbar
、foo
それ自体をクリーンアップしてから、サブルーチンとして呼び出すのではなく、 の開始点に対してbar
低レベルのgoto
操作を行うことを意味しbar
ます。Foo
はすでにスタックから消去されているため、開始時には呼び出した人が本当に を呼び出しbar
たように見え、その値を返すと、呼び出し元に返すのではなく、呼び出した人に直接返します。foo
bar
bar
foo
foo
そして末尾再帰では:
末尾再帰は、関数が最後の操作として自分自身を呼び出した結果を返す場合に発生します。末尾再帰は、どこかのランダムな関数の先頭にジャンプするのではなく、自分自身の先頭に goto を実行するだけなので、対処が簡単です。これは非常に単純なことです。
したがって、これは次のとおりです。
sub foo (int a, int b) { if (b == 1) { return a; } else { return foo(a*a + a, b - 1); }
静かに次のようになります:
sub foo (int a, int b) { label: if (b == 1) { return a; } else { a = a*a + a; b = b - 1; goto label; }
この説明で私が気に入っているのは、命令型言語 (C、C++、Java) のバックグラウンドを持つ人にとって、いかに簡潔で簡単に把握できるかということです。
まず、すべての言語がサポートしているわけではないことに注意してください。
TCO は再帰の特殊なケースに適用されます。その要点は、関数で最後に行うことが自分自身の呼び出しである場合 (たとえば、「末尾」の位置から自分自身を呼び出す場合)、これはコンパイラによって最適化され、標準の再帰ではなく反復のように動作するようになります。
ご覧のとおり、通常、再帰中に、ランタイムはすべての再帰呼び出しを追跡する必要があります。これにより、1 つが戻ったときに前の呼び出しから再開できるようになります。(これがどのように機能するかを視覚的に把握するために、再帰呼び出しの結果を手動で書き出してみてください。) すべての呼び出しを追跡するとスペースが必要になります。しかし、TCO では、「最初に戻って、今回だけパラメータ値をこれらの新しい値に変更してください」と言うことができます。再帰呼び出しの後にそれらの値を参照するものがないため、これが可能です。
ここを見て:
http://tratt.net/laurie/tech_articles/articles/tail_call_optimization
おそらくご存じのとおり、再帰関数呼び出しはスタックに大混乱をもたらす可能性があります。スタックスペースがすぐに不足するのは簡単です。テール コールの最適化は、一定のスタック スペースを使用する再帰的なスタイルのアルゴリズムを作成するための方法です。
関数自体に goto ステートメントがないことを確認する必要があります..関数呼び出しが呼び出し先関数の最後のものになるように注意してください。
大規模な再帰ではこれを最適化に使用できますが、小規模では、関数呼び出しを末尾呼び出しにするための命令オーバーヘッドが実際の目的を減らします。
TCO により、関数が永久に実行される可能性があります。
void eternity()
{
eternity();
}