web-dev-qa-db-ja.com

C ++での末尾再帰

誰かが私にC++で簡単な末尾再帰関数を見せてもらえますか?

テール再帰がさらに優れているのはなぜですか?

末尾再帰以外にどのような再帰がありますか?

58
neuromancer

単純な末尾再帰関数:

unsigned int f( unsigned int a ) {
   if ( a == 0 ) {
      return a;
   }
   return f( a - 1 );   // tail recursion
}

末尾再帰は基本的に次の場合です。

  • 再帰呼び出しは1つしかありません
  • その呼び出しは関数の最後のステートメントです

また、優れたコンパイラーが再帰を削除してループに変換できるという意味を除いて、「より良い」わけではありません。これはより高速であり、スタック使用量を確実に節約します。 GCCコンパイラはこの最適化を実行できます。

61
anon

C++の末尾の拒否は、Cまたは他の言語と同じに見えます。

void countdown( int count ) {
    if ( count ) return countdown( count - 1 );
}

末尾再帰(および一般に末尾呼び出し)では、末尾呼び出しを実行する前に呼び出し元のスタックフレームをクリアする必要があります。プログラマーにとって、末尾再帰はループに似ており、returngoto first_line;。ただし、コンパイラは実行中の処理を検出する必要があります。検出されない場合は、追加のスタックフレームが残っています。ほとんどのコンパイラはこれをサポートしますが、通常、ループまたはgotoを記述する方が簡単でリスクも少なくなります。

非再帰的な末尾呼び出しは、ランダムな分岐(他の関数の最初の行へのgotoなど)を有効にすることができます。これは、よりユニークな機能です。

C++では、returnステートメントのスコープ内に非自明なデストラクタを持つオブジェクトは存在できないことに注意してください。関数の終りのクリーンアップでは、呼び出し先が呼び出し元に戻る必要があり、末尾呼び出しがなくなります。

また、(任意の言語で)末尾再帰では、各ステップで関数の引数リストを介してアルゴリズムの状態全体を渡す必要があることに注意してください。 (これは、次の呼び出しが始まる前に関数のスタックフレームを削除するという要件から明らかです。ローカル変数にデータを保存することはできません。) 。

int factorial( int n, int acc = 1 ) {
    if ( n == 0 ) return acc;
    else return factorial( n-1, acc * n );
}
41
Potatoswatter

末尾再帰は、末尾呼び出しの特殊なケースです。テールコールとは、呼び出された関数から戻ったときに実行する必要のある操作がないことをコンパイラが確認できる場所です。つまり、呼び出された関数の戻りを独自に変換します。コンパイラは多くの場合、いくつかのスタック修正操作を実行してから、呼び出される関数の最初の命令のアドレスに(呼び出しではなく)ジャンプを実行できます。

いくつかのリターンコールを排除することに加えて、このことの素晴らしい点の1つは、スタックの使用量も削減できることです。一部のプラットフォームまたはOSコードでは、スタックが非常に制限される場合があり、デスクトップのx86 CPUなどの高度なマシンでは、このようにスタック使用量を減らすとデータキャッシュのパフォーマンスが向上します。

末尾再帰では、呼び出された関数は呼び出し元の関数と同じです。これはループに変換できます。これは、上記のテールコール最適化のジャンプとまったく同じです。これは同じ関数(呼び出し先と呼び出し元)であるため、ジャンプの前に実行する必要があるスタックの修正が少なくなります。

以下は、コンパイラがループに変換するのがより難しい再帰呼び出しを行う一般的な方法を示しています。

int sum(int a[], unsigned len) {
     if (len==0) {
         return 0;
     }
     return a[0] + sum(a+1,len-1);
}

これは非常に単純なので、多くのコンパイラーがおそらくとにかく理解することができますが、ご覧のように、呼び出された合計からの戻り値が数値を返した後に発生する必要がある追加があるため、単純な末尾呼び出しの最適化は不可能です。

あなたがした場合:

static int sum_helper(int acc, unsigned len, int a[]) {
     if (len == 0) {
        return acc;
     }
     return sum_helper(acc+a[0], len-1, a+1);
}
int sum(int a[], unsigned len) {
     return sum_helper(0, len, a);
}

テール呼び出しである両方の関数の呼び出しを利用できます。ここで、sum関数の主な仕事は、値を移動し、レジスタまたはスタック位置をクリアすることです。 sum_helperはすべての計算を行います。

質問でC++について言及したので、それについていくつかの特別なことを述べます。 C++は、Cにはないいくつかのことを隠しています。これらのデストラクタのうち、テールコールの最適化を妨げる主なものがあります。

int boo(yin * x, yang *y) {
    dharma z = x->foo() + y->bar();
    return z.baz();
}

この例では、bazからの呼び出し後にzを破棄する必要があるため、bazの呼び出しは実際には末尾呼び出しではありません。 C++の規則は、次のように、呼び出し中に変数が必要ない場合でも、最適化をより困難にする可能性があると考えています。

int boo(yin * x, yang *y) {
    dharma z = x->foo() + y->bar();
    int u = z.baz();
    return qwerty(u);
}

ここでqwertyから戻った後、zを破棄する必要がある場合があります。

もう1つのことは、暗黙的な型変換です。これはCでも発生する可能性がありますが、C++ではより複雑で一般的です。例えば:

static double sum_helper(double acc, unsigned len, double a[]) {
     if (len == 0) {
        return acc;
     }
     return sum_helper(acc+a[0], len-1, a+1);
}
int sum(double a[], unsigned len) {
     return sum_helper(0.0, len, a);
}

Sum_helperはdoubleを返し、sumはそれをintに変換する必要があるため、sumのsum_helperへの呼び出しはテール呼び出しではありません。

C++では、すべての種類の異なる解釈を持つ可能性のあるオブジェクト参照を返すことは非常に一般的であり、それぞれが異なる型変換である可能性があります。たとえば、

bool write_it(int it) {
      return cout << it;
}

ここでは、最後のステートメントとしてcout.operator <<を呼び出しています。 coutはそれ自体への参照を返します(これが、<<で区切られたリストに多くのものをつなげることができる理由です)。その後、boolとして評価され、coutの別のメソッドであるoperator bool( )。この場合、このcout.operator bool()は末尾呼び出しとして呼び出すことができますが、operator <<はできませんでした。

編集:

言及する価値のあることの1つは、Cでの末尾呼び出しの最適化が可能な主な理由は、呼び出された関数が戻り値を確実に格納する必要があるのと同じ場所に戻り値を格納することをコンパイラが知っていることですに保存されています。

28
nategoose

末尾再帰は、同時に2つの問題に実際に対処するための秘isです。 1つ目は、実行する反復回数がわからないときにループを実行することです。

これは単純な再帰で解決できますが、2番目の問題が発生します。これは、再帰呼び出しが何度も実行されることによるスタックオーバーフローの問題です。テールコールは、「計算とキャリー」技術を伴う場合の解決策です。

基本的なCSでは、コンピューターアルゴリズムには不変条件と終了条件が必要であることがわかります。これは、末尾再帰を構築するためのベースです。

  1. すべての計算は引数の引き渡しで行われます。
  2. すべての結果を関数呼び出しに渡す必要があります。
  3. 末尾呼び出しは最後の呼び出しであり、終了時に発生します。

簡単に言えば、functionの戻り値で計算を行わないでください。

たとえば、10のべき乗の計算を考えてみましょう。これは簡単であり、ループで書き込むことができます。

のように見えるはずです

_template<typename T> T pow10(T const p, T const res =1)
{
return p ? res: pow10(--p,10*res);
}
_

これにより、実行が行われます(例:4)。

ret、p、res

-、4、1

-、3、10

-、2,100

-、1,1000

-、0、10000

10000、-、-

コンパイラは、スタックポインタを変更せずに値をコピーするだけで、テールコールが発生して結果を返すだけであることが明らかです。

末尾再帰は、既成のコンパイル時評価を提供できるため、非常に重要です。上記のようにすることができます。

_template<int N,int R=1> struct powc10
{
int operator()() const
{
return  powc10<N-1, 10*R>()();
}
};

template<int R> struct powc10<0,R>
{

int operator()() const
{
return  R;
}

};
_

これをpowc10<10>()()として使用して、コンパイル時に10乗を計算できます。

ほとんどのコンパイラにはネストされた呼び出しの制限があるため、末尾呼び出しのトリックが役立ちます。明らかに、メタプログラミングループはないため、再帰を使用する必要があります。

2
g24l

C++のコンパイラレベルでは、末尾再帰は実際には存在しません。

末尾再帰を使用するプログラムを作成することはできますが、コンパイラ/インタープリター/言語をサポートすることで実装される末尾再帰の利点を継承することはできません。たとえば、Schemeは末尾再帰の最適化をサポートしているため、基本的に再帰を繰り返しに変更します。これにより、オーバーフローがスタックしやすくなります。 C++にはそのようなものはありません。 (私が見たどのコンパイラでもない)

どうやら、末尾再帰の最適化はMSVC++とGCCの両方に存在します。詳細については この質問 をご覧ください。

1
Earlz

ウィキペディアには末尾再帰に関するまともな記事があります 。基本的に、末尾再帰は通常の再帰よりも反復ループに最適化するのが簡単であり、反復ループは再帰関数呼び出しよりも効率的であるため、より優れています。これは、ループのない関数型言語では特に重要です。

C++の場合、再帰ループをより良く最適化できるので、末尾再帰を使用して記述できればなお良いですが、そのような場合、一般的に最初から繰り返し実行できるため、ゲインはそれほど大きくありません関数型言語である。

0