Lispの学習を始めている間に、末尾再帰という用語に出くわしました。正確にはどういう意味ですか?
Lispの学習を始めている間に、末尾再帰という用語に出くわしました。正確にはどういう意味ですか?
回答:
最初のN個の自然数を追加する単純な関数を考えます。(例sum(5) = 1 + 2 + 3 + 4 + 5 = 15
)。
以下は、再帰を使用する簡単なJavaScript実装です。
function recsum(x) {
if (x === 1) {
return x;
} else {
return x + recsum(x - 1);
}
}
を呼び出した場合recsum(5)
、JavaScriptインタープリターはこれを評価します。
recsum(5)
5 + recsum(4)
5 + (4 + recsum(3))
5 + (4 + (3 + recsum(2)))
5 + (4 + (3 + (2 + recsum(1))))
5 + (4 + (3 + (2 + 1)))
15
JavaScriptインタープリターが合計の計算作業を実際に開始する前に、すべての再帰呼び出しが完了する必要があることに注意してください。
次に、同じ関数の末尾再帰バージョンを示します。
function tailrecsum(x, running_total = 0) {
if (x === 0) {
return running_total;
} else {
return tailrecsum(x - 1, running_total + x);
}
}
を呼び出した場合に発生する一連のイベントを次に示しますtailrecsum(5)
(tailrecsum(5, 0)
デフォルトの2番目の引数のため、これは事実上です)。
tailrecsum(5, 0)
tailrecsum(4, 5)
tailrecsum(3, 9)
tailrecsum(2, 12)
tailrecsum(1, 14)
tailrecsum(0, 15)
15
末尾再帰の場合、再帰呼び出しの評価ごとに、running_total
が更新されます。
注:元の回答ではPythonの例を使用していました。Pythonインタープリターは末尾呼び出しの最適化をサポートしていないため、これらはJavaScriptに変更されました。ただし、末尾呼び出しの最適化はECMAScript 2015仕様の一部ですが、ほとんどのJavaScriptインタープリターはそれをサポートしていません。
tail recursion
、末尾呼び出しを最適化しない言語でどのように達成できるか混乱しています。
では、伝統的な再帰、典型的なモデルは、あなたが最初にあなたの再帰呼び出しを行い、その後、あなたは再帰呼び出しの戻り値を取り、結果を計算していることです。この方法では、すべての再帰呼び出しから戻るまで、計算の結果は得られません。
で末尾再帰は、最初に計算を実行し、その後、あなたは次の再帰的ステップにあなたの現在のステップの結果を渡し、再帰呼び出しを実行します。これにより、最後のステートメントはの形式になり(return (recursive-function params))
ます。基本的に、特定の再帰ステップの戻り値は、次の再帰呼び出しの戻り値と同じです。
この結果、次の再帰ステップを実行する準備ができたら、現在のスタックフレームは必要なくなります。これにより、最適化が可能になります。実際、適切に作成されたコンパイラを使用すると、末尾再帰呼び出しでスタックオーバーフロースニッカーが発生することはありません。現在のスタックフレームを次の再帰的なステップに再利用するだけです。Lispがこれを行うと確信しています。
重要な点は、末尾再帰は基本的にループと同等であることです。それはコンパイラの最適化だけの問題ではなく、表現力に関する基本的な事実です。これは両方の方法で行われます。フォームのどのループでも実行できます
while(E) { S }; return Q
ここでE
、およびQ
は式でS
あり、一連のステートメントであり、末尾再帰関数に変換します
f() = if E then { S; return f() } else { return Q }
もちろん、E
、S
、およびQ
いくつかの変数の上にいくつかの興味深い値を計算するために定義する必要があります。たとえば、ループ関数
sum(n) {
int i = 1, k = 0;
while( i <= n ) {
k += i;
++i;
}
return k;
}
末尾再帰関数と同等です
sum_aux(n,i,k) {
if( i <= n ) {
return sum_aux(n,i+1,k+i);
} else {
return k;
}
}
sum(n) {
return sum_aux(n,1,0);
}
(より少ないパラメーターを持つ関数による末尾再帰関数のこの「ラッピング」は、一般的な関数イディオムです。)
else { return k; }
次のように変更できますreturn k;
この本からの抜粋Luaのでのプログラミングを示し、適切な末尾再帰を作成する方法(Luaの中で、あまりにもlispに適用されるべきである)、それは良いでしょう、なぜ。
末尾呼び出し [末尾再帰]はコールに扮後藤の一種です。末尾呼び出しは、関数が最後のアクションとして別の関数を呼び出すときに発生するため、他に何もする必要はありません。たとえば、次のコードでは、への呼び出し
g
は末尾呼び出しです。function f (x) return g(x) end
f
呼び出し後はg
、他に何もする必要はありません。このような状況では、プログラムは、呼び出された関数が終了したときに呼び出し関数に戻る必要はありません。したがって、末尾呼び出しの後、プログラムは呼び出し元の関数に関する情報をスタックに保持する必要はありません。...適切な末尾呼び出しはスタックスペースを使用しないため、プログラムが作成できる「ネストされた」末尾呼び出しの数に制限はありません。たとえば、次の関数を任意の数値を引数として呼び出すことができます。スタックがオーバーフローすることはありません:
function foo (n) if n > 0 then return foo(n - 1) end end
...先に述べたように、テールコールは一種のgotoです。そのため、Luaでの適切な末尾呼び出しの非常に便利なアプリケーションは、ステートマシンのプログラミング用です。このようなアプリケーションは、各状態を関数で表すことができます。状態を変更するには、特定の関数に移動(または呼び出し)します。例として、単純な迷路ゲームを考えてみましょう。迷路にはいくつかの部屋があり、それぞれ最大4つのドア(北、南、東、西)があります。各ステップで、ユーザーは移動方向を入力します。その方向にドアがある場合、ユーザーは対応する部屋に行きます。それ以外の場合、プログラムは警告を出力します。目標は、最初の部屋から最後の部屋に移動することです。
このゲームは、現在の部屋が状態である典型的な状態機械です。部屋ごとに1つの機能でこのような迷路を実装できます。テールコールを使用して、ある部屋から別の部屋に移動します。4つの部屋がある小さな迷路は次のようになります。
function room1 () local move = io.read() if move == "south" then return room3() elseif move == "east" then return room2() else print("invalid move") return room1() -- stay in the same room end end function room2 () local move = io.read() if move == "south" then return room4() elseif move == "west" then return room1() else print("invalid move") return room2() end end function room3 () local move = io.read() if move == "north" then return room1() elseif move == "east" then return room4() else print("invalid move") return room3() end end function room4 () print("congratulations!") end
つまり、次のような再帰呼び出しを行うと、
function x(n)
if n==0 then return 0
n= n-2
return x(n) + 1
end
これは、再帰呼び出しが行われた後もその関数で実行する必要がある(1を加える)ため、末尾再帰ではありません。非常に高い数値を入力すると、スタックオーバーフローが発生する可能性があります。
通常の再帰を使用して、各再帰呼び出しは別のエントリを呼び出しスタックにプッシュします。再帰が完了すると、アプリは各エントリを元に戻す必要があります。
末尾再帰を使用すると、言語によっては、コンパイラがスタックを1つのエントリに縮小できるため、スタックスペースを節約できます...大きな再帰クエリは、実際にスタックオーバーフローを引き起こす可能性があります。
基本的に、テール再帰は反復に最適化できます。
言葉で説明するのではなく、例を示します。これは階乗関数のSchemeバージョンです:
(define (factorial x)
(if (= x 0) 1
(* x (factorial (- x 1)))))
これは末尾再帰的な階乗のバージョンです:
(define factorial
(letrec ((fact (lambda (x accum)
(if (= x 0) accum
(fact (- x 1) (* accum x))))))
(lambda (x)
(fact x 1))))
最初のバージョンでは、factへの再帰呼び出しが乗算式に渡されるため、再帰呼び出しを行うときに状態をスタックに保存する必要があります。末尾再帰バージョンでは、再帰呼び出しの値を待機している他のS式はありません。さらに処理する必要がないため、状態をスタックに保存する必要はありません。原則として、Schemeの末尾再帰関数は一定のスタックスペースを使用します。
list-reverse
プロシージャは一定のスタックスペースで実行されますが、ヒープ上にデータ構造を作成して拡張します。ツリートラバーサルは、追加の引数でシミュレートされたスタックを使用できます。等
末尾再帰とは、再帰的アルゴリズムの最後の論理命令の最後にある再帰的呼び出しを指します。
通常、再帰では、再帰呼び出しを停止して呼び出しスタックのポップを開始するベースケースがあります。古典的な例を使用するために、階乗関数はLispよりもCっぽいですが、尾部再帰を示します。再帰呼び出しは、基本ケースの条件を確認した後に発生します。
factorial(x, fac=1) {
if (x == 1)
return fac;
else
return factorial(x-1, x*fac);
}
階乗への最初の呼び出しfactorial(n)
はfac=1
(デフォルト値)であり、nは階乗が計算される数値です。
else
は「ベースケース」と呼ぶステップですが、複数の行にまたがっています。私はあなたを誤解していますか、それとも私の仮定は正しいですか?テール再帰は1つのライナーにのみ有効ですか?
factorial
例は、古典的な単純な例にすぎません。
2つの関数を比較する簡単なコードスニペットを次に示します。1つ目は、指定された数の階乗を見つけるための従来の再帰です。2番目は、末尾再帰を使用します。
非常にシンプルで直感的に理解できます。
再帰関数が末尾再帰であるかどうかを判断する簡単な方法は、基本ケースで具体的な値を返すかどうかです。つまり、1やtrueなどは返されません。メソッドパラメーターの1つのバリアントを返す可能性が高いです。
もう1つの方法は、再帰呼び出しに追加、算術、変更などがないかどうかを確認することです。つまり、純粋な再帰呼び出しにすぎません。
public static int factorial(int mynumber) {
if (mynumber == 1) {
return 1;
} else {
return mynumber * factorial(--mynumber);
}
}
public static int tail_factorial(int mynumber, int sofar) {
if (mynumber == 1) {
return sofar;
} else {
return tail_factorial(--mynumber, sofar * mynumber);
}
}
私が理解する最善の方法tail call recursion
は、最後の呼び出し(または末尾呼び出し)が関数自体である再帰の特殊なケースです。
Pythonで提供される例の比較:
def recsum(x):
if x == 1:
return x
else:
return x + recsum(x - 1)
^再帰
def tailrecsum(x, running_total=0):
if x == 0:
return running_total
else:
return tailrecsum(x - 1, running_total + x)
^テール再帰
一般的な再帰バージョンでわかるように、コードブロックの最後の呼び出しはx + recsum(x - 1)
です。したがって、recsum
メソッドを呼び出した後、別の操作がありx + ..
ます。
ただし、末尾再帰バージョンでは、コードブロックの最後の呼び出し(または末尾呼び出し)tailrecsum(x - 1, running_total + x)
は、最後の呼び出しがメソッド自体に対して行われ、その後の操作は行われないことを意味します。
ここで見られるような末尾再帰はメモリを増大させないため、この点は重要です。基礎となるVMが末尾位置でそれ自体を呼び出す関数(関数で評価される最後の式)を見つけると、現在のスタックフレームが削除されます。 Tail Call Optimization(TCO)として知られています。
NB。上記の例は、ランタイムがTCOをサポートしていないPythonで記述されていることに注意してください。これはポイントを説明するための例にすぎません。TCOは、Scheme、Haskellなどの言語でサポートされています
Javaでは、フィボナッチ関数の末尾再帰実装の可能性があります。
public int tailRecursive(final int n) {
if (n <= 2)
return 1;
return tailRecursiveAux(n, 1, 1);
}
private int tailRecursiveAux(int n, int iter, int acc) {
if (iter == n)
return acc;
return tailRecursiveAux(n, ++iter, acc + iter);
}
これを標準の再帰的な実装と比較してください。
public int recursive(final int n) {
if (n <= 2)
return 1;
return recursive(n - 1) + recursive(n - 2);
}
iter
にacc
ときをiter < (n-1)
。
以下は、末尾再帰を使用して階乗を行うCommon Lispの例です。スタックレスの性質により、めちゃくちゃ大きな階乗計算を実行できます...
(defun ! (n &optional (product 1))
(if (zerop n) product
(! (1- n) (* product n))))
そして、楽しみのためにあなたは試すことができます (format nil "~R" (! 25))
つまり、末尾再帰では、関数の最後のステートメントとして再帰呼び出しが行われるため、再帰呼び出しを待つ必要がありません。
つまり、これは末尾再帰です。つまり、N(x-1、p * x)は、forループ(階乗)に最適化できることをコンパイラが理解できる関数の最後のステートメントです。2番目のパラメーターpは、中間の積の値です。
function N(x, p) {
return x == 1 ? p : N(x - 1, p * x);
}
これは、上記の階乗関数を記述する非再帰的な方法です(ただし、一部のC ++コンパイラはとにかく最適化できる場合があります)。
function N(x) {
return x == 1 ? 1 : x * N(x - 1);
}
しかし、これはそうではありません:
function F(x) {
if (x == 1) return 0;
if (x == 2) return 1;
return F(x - 1) + F(x - 2);
}
「Tail Recursionの理解– Visual Studio C ++ –アセンブリビュー」という長い記事を書いた
これは、tailrecsum
前述の関数のPerl 5バージョンです。
sub tail_rec_sum($;$){
my( $x,$running_total ) = (@_,0);
return $running_total unless $x;
@_ = ($x-1,$running_total+$x);
goto &tail_rec_sum; # throw away current stack frame
}
これは、末尾再帰に関するコンピュータプログラムの構造と解釈からの抜粋です。
反復と再帰を対比すると、再帰的プロセスの概念と再帰的プロシージャの概念を混同しないように注意する必要があります。プロシージャを再帰的に説明するときは、プロシージャ定義がプロシージャ自体を(直接的または間接的に)参照するという構文上の事実を指します。しかし、プロセスを、たとえば線形再帰的なパターンに従うと説明するときは、プロシージャの記述方法の構文ではなく、プロセスがどのように進化するかについて話しています。fact-iterなどの再帰的な手順を反復プロセスの生成と呼ぶのは、気が遠くなるかもしれません。ただし、プロセスは実際には反復的です。その状態は3つの状態変数によって完全にキャプチャされ、インタープリターはプロセスを実行するために3つの変数のみを追跡する必要があります。
プロセスとプロシージャの違いがわかりにくい理由の1つは、一般的な言語(Ada、Pascal、Cを含む)のほとんどの実装が、再帰的なプロシージャの解釈でメモリ量が増えるように設計されていることです。説明されているプロセスが原則として反復的な場合でも、プロシージャコールの数 結果として、これらの言語は、do、repeat、until、for、whileなどの特別な目的の「ループ構造」に頼ることによってのみ、反復プロセスを記述できます。Schemeの実装はこの欠陥を共有していません。反復プロセスが再帰的プロシージャで記述されている場合でも、定数プロセスで反復プロセスを実行します。このプロパティを持つ実装は、末尾再帰と呼ばれます。 末尾再帰の実装では、通常のプロシージャコールメカニズムを使用して反復を表現できるため、特別な反復構成は構文糖としてのみ役立ちます。
再帰関数はそれ自体を呼び出す関数です
これにより、プログラマは最小限のコードを使用して効率的なプログラムを作成できます。
欠点は、適切に記述しないと、無限ループやその他の予期しない結果を引き起こす可能性があることです。
Simple Recursive関数とTail Recursive関数の両方を説明します
単純な再帰関数を書くために
与えられた例から:
public static int fact(int n){
if(n <=1)
return 1;
else
return n * fact(n-1);
}
上記の例から
if(n <=1)
return 1;
ループを終了するときの決定要因です
else
return n * fact(n-1);
実際に行われる処理です
わかりやすいように、1つずつ作業を中断してみましょう。
実行すると内部で何が起こるか見てみましょう fact(4)
public static int fact(4){
if(4 <=1)
return 1;
else
return 4 * fact(4-1);
}
If
ループが失敗するのでelse
ループに戻り、戻る4 * fact(3)
スタックメモリには、 4 * fact(3)
n = 3を代入
public static int fact(3){
if(3 <=1)
return 1;
else
return 3 * fact(3-1);
}
If
ループは失敗するので、else
ループします
それで戻る 3 * fact(2)
`` `4 * fact(3)` `を呼び出したことを思い出してください
の出力 fact(3) = 3 * fact(2)
これまでのところ、スタックは 4 * fact(3) = 4 * 3 * fact(2)
スタックメモリには、 4 * 3 * fact(2)
n = 2を代入
public static int fact(2){
if(2 <=1)
return 1;
else
return 2 * fact(2-1);
}
If
ループは失敗するので、else
ループします
それで戻る 2 * fact(1)
電話したことを覚えている 4 * 3 * fact(2)
の出力 fact(2) = 2 * fact(1)
これまでのところ、スタックは 4 * 3 * fact(2) = 4 * 3 * 2 * fact(1)
スタックメモリには、 4 * 3 * 2 * fact(1)
n = 1を代入
public static int fact(1){
if(1 <=1)
return 1;
else
return 1 * fact(1-1);
}
If
ループは真です
それで戻る 1
電話したことを覚えている 4 * 3 * 2 * fact(1)
の出力 fact(1) = 1
これまでのところ、スタックは 4 * 3 * 2 * fact(1) = 4 * 3 * 2 * 1
最後に、fact(4)= 4 * 3 * 2 * 1 = 24の結果
末尾再帰はなり
public static int fact(x, running_total=1) {
if (x==1) {
return running_total;
} else {
return fact(x-1, running_total*x);
}
}
public static int fact(4, running_total=1) {
if (x==1) {
return running_total;
} else {
return fact(4-1, running_total*4);
}
}
If
ループが失敗するのでelse
ループに戻り、戻るfact(3, 4)
スタックメモリには、 fact(3, 4)
n = 3を代入
public static int fact(3, running_total=4) {
if (x==1) {
return running_total;
} else {
return fact(3-1, 4*3);
}
}
If
ループは失敗するので、else
ループします
それで戻る fact(2, 12)
スタックメモリには、 fact(2, 12)
n = 2を代入
public static int fact(2, running_total=12) {
if (x==1) {
return running_total;
} else {
return fact(2-1, 12*2);
}
}
If
ループは失敗するので、else
ループします
それで戻る fact(1, 24)
スタックメモリには、 fact(1, 24)
n = 1を代入
public static int fact(1, running_total=24) {
if (x==1) {
return running_total;
} else {
return fact(1-1, 24*1);
}
}
If
ループは真です
それで戻る running_total
の出力 running_total = 24
最後に、fact(4,1)= 24の結果
末尾再帰とは、関数が関数の最後(「末尾」)で自分自身を呼び出す再帰関数のことで、再帰呼び出しの戻り後に計算は行われません。多くのコンパイラーは、再帰呼び出しを末尾再帰呼び出しまたは反復呼び出しに変更するように最適化しています。
数値の階乗を計算する問題を考えてみましょう。
簡単なアプローチは次のとおりです。
factorial(n):
if n==0 then 1
else n*factorial(n-1)
factorial(4)を呼び出すとします。再帰ツリーは次のようになります。
factorial(4)
/ \
4 factorial(3)
/ \
3 factorial(2)
/ \
2 factorial(1)
/ \
1 factorial(0)
\
1
上記の場合の最大再帰深度はO(n)です。
ただし、次の例を検討してください。
factAux(m,n):
if n==0 then m;
else factAux(m*n,n-1);
factTail(n):
return factAux(1,n);
factTail(4)の再帰ツリーは次のようになります。
factTail(4)
|
factAux(1,4)
|
factAux(4,3)
|
factAux(12,2)
|
factAux(24,1)
|
factAux(24,0)
|
24
ここでも、最大の再帰の深さはO(n)ですが、どの呼び出しもスタックに余分な変数を追加しません。したがって、コンパイラはスタックを廃止できます。
Tail Recursionは、通常の再帰と比較してかなり高速です。祖先の呼び出しの出力は、トラックを維持するためにスタックに書き込まれないため、高速です。ただし、通常の再帰では、すべての祖先がスタックに書き込まれた出力を呼び出して追跡します。
末尾再帰関数は戻っているが、再帰関数呼び出しを行う前に、最後の操作は、それがない再帰関数です。つまり、再帰的な関数呼び出しの戻り値がすぐに返されます。たとえば、コードは次のようになります。
def recursiveFunction(some_params):
# some code here
return recursiveFunction(some_args)
# no code after the return statement
末尾呼び出しの最適化または末尾呼び出しの削除を実装するコンパイラーおよびインタープリターは、再帰的コードを最適化してスタックのオーバーフローを防止できます。コンパイラーまたはインタープリターがテールコール最適化を実装していない場合(CPythonインタープリターなど)、この方法でコードを記述しても追加の利点はありません。
たとえば、これはPythonの標準の再帰的階乗関数です。
def factorial(number):
if number == 1:
# BASE CASE
return 1
else:
# RECURSIVE CASE
# Note that `number *` happens *after* the recursive call.
# This means that this is *not* tail call recursion.
return number * factorial(number - 1)
そして、これは階乗関数の末尾呼び出し再帰バージョンです:
def factorial(number, accumulator=1):
if number == 0:
# BASE CASE
return accumulator
else:
# RECURSIVE CASE
# There's no code after the recursive call.
# This is tail call recursion:
return factorial(number - 1, number * accumulator)
print(factorial(5))
(これはPythonコードですが、CPythonインタープリターは末尾呼び出しの最適化を行わないため、このようにコードを配置してもランタイム上の利点はありません。)
階乗の例に示すように、末尾呼び出しの最適化を利用するには、コードを少し読みにくくする必要がある場合があります。(たとえば、基本ケースは少し直感的ではなくなり、accumulator
パラメーターは一種のグローバル変数として効果的に使用されます。)
しかし、末尾呼び出しの最適化の利点は、スタックオーバーフローエラーを防ぐことです。(再帰的なアルゴリズムの代わりに反復的なアルゴリズムを使用することでこれと同じ利点を得ることができることに注意します。)
スタックオーバーフローは、呼び出しスタックにプッシュされたフレームオブジェクトが多すぎる場合に発生します。フレームオブジェクトは、関数が呼び出されると呼び出しスタックにプッシュされ、関数が戻ると呼び出しスタックからポップされます。フレームオブジェクトには、ローカル変数や、関数が戻ったときに戻るコード行などの情報が含まれています。
再帰関数がリターンせずに再帰呼び出しを多くしすぎると、呼び出しスタックがフレームオブジェクトの制限を超える可能性があります。(数はプラットフォームによって異なります。Pythonでは、デフォルトで1000フレームオブジェクトです。)これにより、スタックオーバーフローエラーが発生します。(ねえ、それがこのウェブサイトの名前の由来です!)
ただし、再帰関数が最後に行うことは、再帰呼び出しを行い、その戻り値を返すことである場合、現在のフレームオブジェクトを呼び出しスタックに残す必要がある理由はありません。結局のところ、再帰的な関数呼び出しの後にコードがない場合は、現在のフレームオブジェクトのローカル変数に依存する必要はありません。したがって、現在のフレームオブジェクトをコールスタックに保持するのではなく、すぐに取り除くことができます。この結果、コールスタックのサイズが大きくならないため、スタックオーバーフローが発生しなくなります。
コンパイラーまたはインタープリターは、末尾呼び出し最適化をいつ適用できるかを認識できるようにするための機能として、末尾呼び出し最適化を備えている必要があります。それでも、再帰関数のコードを並べ替えて末尾呼び出しの最適化を利用している可能性があります。可読性のこの潜在的な低下が最適化に値するかどうかは、あなた次第です。
末尾呼び出しの再帰と非末尾呼び出しの再帰の主な違いのいくつかを理解するために、これらの手法の.NET実装を調査できます。
これは、C#、F#、およびC ++ \ CLIでのいくつかの例を含む記事です。C#、F#、およびC ++ \ CLI での尾の再帰における冒険。
C#は末尾呼び出しの再帰を最適化しませんが、F#は最適化します。
原理の違いには、ループとラムダ計算が関係しています。C#はループを考慮して設計されていますが、F#はラムダ計算の原理から構築されています。ラムダ計算の原理に関する非常に優れた(そして無料の)本については、Abelson、Sussman、およびSussmanによる「コンピュータプログラムの構造と解釈」を参照してください。
F#での末尾呼び出しについては、非常に優れた紹介記事として、F#での末尾呼び出しの詳細な紹介を参照してください。最後に、非末尾再帰と末尾呼び出し再帰(F#の場合)の違いを説明する記事を次に示します。Fシャープでの末尾再帰と非末尾再帰。
C#とF#の間の末尾呼び出し再帰の設計の違いについて読みたい場合は、C#とF#での末尾呼び出しオペコードの生成を参照してください。
C#コンパイラが末尾呼び出しの最適化を実行できない条件を知りたい場合は、この記事「JIT CLR末尾呼び出しの条件」を参照してください。
再帰は、それ自体を呼び出す関数を意味します。例えば:
(define (un-ended name)
(un-ended 'me)
(print "How can I get here?"))
末尾再帰は、関数を終了する再帰を意味します。
(define (un-ended name)
(print "hello")
(un-ended 'me))
終わりのない関数(Schemeの専門用語ではプロシージャ)が最後に行うことは、自分自身を呼び出すことです。別の(より有用な)例は次のとおりです。
(define (map lst op)
(define (helper done left)
(if (nil? left)
done
(helper (cons (op (car left))
done)
(cdr left))))
(reverse (helper '() lst)))
ヘルパープロシージャでは、左側がnilでない場合に最後に行うのは、それ自体を呼び出すことです(cons何かとcdr何かの後)。これは基本的にリストのマッピング方法です。
末尾再帰には、インタープリター(または言語とベンダーに依存するコンパイラー)が最適化して、whileループと同等のものに変換できるという大きな利点があります。実際のところ、Schemeの伝統では、ほとんどの "for"と "while"ループは末尾再帰の方法で行われます(私の知る限り、forとwhileはありません)。
この質問には多くの素晴らしい答えがあります...しかし、「尾の再帰」、または少なくとも「適切な尾の再帰」を定義する方法について、別の方法を試してみるしかありません。つまり、プログラム内の特定の式のプロパティと見なす必要がありますか?それとも、それをプログラミング言語の実装のプロパティとして見るべきですか?
後者の見方については、Will Clingerによる古典的な論文「Proper Tail Recursion and Space Efficiency」(PLDI 1998)があり、「適切なテール再帰」をプログラミング言語実装のプロパティとして定義しています。定義は、実装の詳細を無視できるように構築されています(コールスタックが実際にランタイムスタックを介して表されるか、フレームにヒープが割り当てられたリンクリストを介して表されるかなど)。
これを達成するために、それは漸近分析を使用します:通常見られるようなプログラム実行時間ではなく、プログラム空間使用量の。このように、ヒープに割り当てられたリンクリストとランタイムコールスタックのスペース使用量は、漸近的に同等になります。そのため、そのプログラミング言語の実装の詳細(実際にはかなり重要な詳細ですが、特定の実装が「プロパティテール再帰的」であるという要件を満たしているかどうかを判断しようとすると、かなり混乱する可能性があります。 )
この論文は、いくつかの理由で注意深く検討する価値があります。
プログラムの末尾式と末尾呼び出しの帰納的定義を提供します。(そのような定義、およびそのような呼び出しが重要である理由は、ここに示されている他のほとんどの回答の主題のようです。)
これらの定義は、テキストのフレーバーを提供するためだけです。
定義1 Core Schemeで書かれたプログラムの末尾表現は、帰納的に次のように定義されます。
- ラムダ式の本体はテール式です
(if E0 E1 E2)
がテール式の場合、E1
およびE2
はテール式です。- 他にテール式はありません。
定義2 Aの末尾呼び出しは、プロシージャ呼び出しであるテール式です。
(末尾再帰呼び出し、または論文で述べられているように、「自己末尾呼び出し」は、プロシージャがそれ自体が呼び出される末尾呼び出しの特殊なケースです。)
これは、各マシンが同じ観察可能な行動があるコア制度、評価するための六つの異なる「マシン」のための正式な定義が提供除くための漸近それぞれがであることを宇宙の複雑性クラスを。
たとえば、それぞれに1.スタックベースのメモリ管理、2。ガベージコレクションはテールコールなしで、3。ガベージコレクションとテールコールは、次のようなさらに高度なストレージ管理戦略を続けます。 4. "evlis tail recursion"。環境は、tail呼び出しの最後の部分式引数の評価全体で保存される必要はありません。5。クロージャーの環境を、そのクロージャーの自由変数だけに減らします。6. AppelとShaoによって定義された、いわゆる「セーフフォースペース」セマンティクス。
マシンが実際に6つの異なる空間複雑度クラスに属していることを証明するために、比較対象のマシンのペアごとに、1つのマシンで漸近的な空間爆発を公開し、他のマシンでは公開しないプログラムの具体例を示します。
(今私の回答を読んで、クリンジャー紙の重要なポイントを実際に捕捉できたかどうかはわかりませんが、残念ながら、今この回答を作成するためにこれ以上の時間を割くことができません。)
多くの人々はすでにここで再帰を説明しました。Riccardo Terrellの著書「.NETでの並行性、並行および並列プログラミングの最新パターン」から、再帰がもたらすいくつかの利点についていくつか考えてみます。
「関数型再帰は、状態の変化を回避するため、FPで反復する自然な方法です。各反復中に、新しい値がループコンストラクターに渡されて更新(変更)されます。さらに、再帰関数を構成して、プログラムをよりモジュール化したり、並列化を利用する機会を導入したりできます。」
また、末尾再帰に関する同じ本の興味深いメモもいくつかあります。
末尾呼び出し再帰は、通常の再帰関数を、リスクや副作用なしに大量の入力を処理できる最適化されたバージョンに変換する手法です。
注最適化としての末尾呼び出しの主な理由は、データの局所性、メモリ使用量、およびキャッシュ使用量を改善することです。末尾呼び出しを行うことにより、呼び出し先は呼び出し元と同じスタックスペースを使用します。これにより、メモリの負荷が軽減されます。同じメモリが後続の呼び出し元に再利用され、古いキャッシュラインを削除して新しいキャッシュライン用のスペースを空けるのではなく、キャッシュ内にとどまることができるため、キャッシュがわずかに改善されます。