テールコールの最適化とは何ですか?


818

非常に単純に、末尾呼び出しの最適化とは何ですか?

より具体的には、それが適用される可能性のあるいくつかの小さなコードスニペットは何ですか?


10
TCOは、末尾の関数呼び出しをgoto、ジャンプに変換します。
ネスは

8
この質問は、その質問の8年前に完全に尋ねられました;)
majelbstoat '30 / 07/30

回答:


755

呼び出し側の関数は呼び出された関数から取得した値を返すだけなので、テール呼び出しの最適化では、関数に新しいスタックフレームを割り当てることを回避できます。最も一般的な使用法は、末尾再帰です。末尾呼び出しの最適化を利用するために作成された再帰関数は、一定のスタックスペースを使用できます。

Schemeは、あらゆる実装がこの最適化を提供する必要があることを仕様で保証する数少ないプログラミング言語の1つです(JavaScriptもES6以降)。したがって、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を呼び出すたびに同じ量のデータを追跡するだけで済みます。これは、単純に取得した値を先頭に返すためです。つまり、(事実1000000)を呼び出したとしても、(事実3)と同じ量のスペースしか必要ありません。これは、末尾再帰以外の事実には当てはまりません。そのような大きな値は、スタックオーバーフローを引き起こす可能性があるためです。


99
これについて詳しく知りたい場合は、コンピュータプログラムの構造と解釈の最初の章を読むことをお勧めします。
カイル・クロニン、

3
素晴らしい答え、完全に説明されました。
ジョナ

15
厳密に言うと、末尾呼び出しの最適化では、呼び出し元のスタックフレームが呼び出し先に置き換えられるとは限りませんが、末尾位置にある無限の数の呼び出しが、限られた量のスペースのみを必要とすることが保証されます。Will Clingerの論文「Proper tail recursion and spaceefficient
Jon Harrop、

3
これは、定数空間の方法で再帰関数を書く方法にすぎませんか?反復的なアプローチを使用して同じ結果を達成できなかったからですか?
dclowd9901

5
@ dclowd9901、TCOを使用すると、反復ループではなく関数スタイルを優先できます。あなたは命令的なスタイルを好むことができます。多くの言語(Java、Python)はTCOを提供していません。そのため、関数呼び出しはメモリを消費することを知っておく必要があります...そして命令型スタイルが優先されます。
mcoolive 2016年

553

簡単な例を見てみましょう。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;
}

ここでわかるように、十分に高度なオプティマイザは、末尾再帰を反復に置き換えることができます。これは、関数呼び出しのオーバーヘッドを回避し、一定量のスタック領域のみを使用するため、はるかに効率的です。


スタックフレームの意味を正確に説明できますか?呼び出しスタックとスタックフレームに違いはありますか?
Shasak 2015年

10
@Kasahs:スタックフレームは、特定の(アクティブな)関数に「属している」呼び出しスタックの一部です。cf en.wikipedia.org/wiki/Call_stack#Structure
Christoph


198

TCO(Tail Call Optimization)は、スマートコンパイラが関数を呼び出し、追加のスタックスペースを必要としないプロセスです。機能で実行された最後の命令場合、この問題が発生する唯一の状況は、fは関数gの呼び出しである(:注gがあってもよいF)。ここで重要なのは、ということですfは、それは単に呼び出す-もはやニーズスタックスペースグラム何でも、その後戻っgが戻ってくるし。この場合、gが実行され、fを呼び出したものに必要な値を返すように最適化を行うことができます。

この最適化により、再帰呼び出しは、爆発するのではなく、一定のスタックスペースを使用するようになります。

例:この階乗関数はTCOptimizableではありません:

def fact(n):
    if n == 0:
        return 1
    return n * fact(n-1)

この関数は、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)

これは、これらの関数のいずれかで最後に起こることは別の関数を呼び出すことだからです。


3
全体の「関数gはfになり得る」ということは少し混乱しましたが、私はあなたが何を意味するかを理解し、例は本当に明確にしたものです。どうもありがとう!
majelbstoat 2008年

10
コンセプトを説明する優れた例。選択する言語が末尾呼び出しの削除または末尾呼び出しの最適化を実装する必要があることを考慮してください。Pythonで記述された例では、1000の値を入力すると、デフォルトのPython実装がテール再帰除去をサポートしていないため、「RuntimeError:maximum recursion depth超過」が発生します。その理由を説明するGuido自身の投稿を参照してください:neopythonic.blogspot.pt/2009/04/tail-recursion-elimination.html
rmcc

唯一の状況」は少し絶対的すぎる。少なくとも理論的には、同じように最適化またはテール位置にあるTRMCもあります。(cons a (foo b))(+ c (bar d))
ネス

私は数学者なので、受け入れられた答えよりもfとgのアプローチが好きでした。
Nithin

TCOptimizedのことだと思います。TCOptimizableでないと言うことは、最適化することは決してできないと推測します(実際には最適化できる場合)
Jacques Mathieu

65

おそらく、末尾呼び出し、再帰末尾呼び出し、末尾呼び出しの最適化について私が見つけた最高の高レベルの説明はブログ投稿です

「一体何が:テールコール」

ダンスガルスキ 末尾呼び出しの最適化について、彼は次のように書いています。

少しの間、この単純な関数を考えてみましょう:

sub foo (int a) {
  a += 15;
  return bar(a);
}

それで、あなた、またはあなたの言語コンパイラは何ができますか?まあ、それができることは、フォームのコードをreturn somefunc();低レベルのシーケンスに変えることですpop stack frame; goto somefunc();。この例では、我々は呼んでその手段の前にbarfoo自分自身をクリーンアップしてから、むしろ呼び出しよりbarサブルーチンとして、我々は、低レベルやるgotoの開始に操作をbarFooはすでにスタックから自分自身をクリーンアップしているので、barそれが見え起動するようにと呼ばれる人は誰でもfoo、本当に求めているbar、とするときbar、その値を返し、それが直接呼び出さ誰にそれを返すfooというに戻すよりも、fooその呼び出し側にそれを返すことになります。

そして末尾再帰:

関数が最後の操作として自身を呼び出した結果を返すと、末尾再帰が発生します。テール関数の再帰は、ランダム関数の最初にどこかにジャンプする必要がなく、自分自身の最初に戻るだけなので、扱いが簡単です。

だからこれ:

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)の出身者を理解するのがいかに簡潔で簡単かです。


4
404エラー。しかし、それはarchive.orgにまだ提供されています:web.archive.org/web/20111030134120/http://www.sidhe.org/~dan/...
トミー・

わかりませんでした。最初のfoo関数末尾呼び出しは最適化されていませんか?最後のステップとして関数を呼び出すだけで、その値を返すだけですよね?
SexyBeast 2014年

1
@TryinHardはあなたが考えていたものではないかもしれませんが、私はそれが何であるかの要点を与えるために更新しました。申し訳ありませんが、記事全体を繰り返すつもりはありません!
btiernay 2015

2
ありがとう、これは最も投票されたスキームの例よりも単純で理解しやすい(言うまでもなく、Schemeはほとんどの開発者が理解する一般的な言語ではありません)
Sevin7

2
関数型言語にめったに飛び込むことのない人として、「私の方言」で説明を見るのはうれしいです。関数型プログラマーが選択した言語で伝道する(理解できる)傾向がありますが、命令型の世界から来ると、このような答えに頭を抱える方がはるかに簡単だと思います。
James Beninger、2016

15

まず、すべての言語でサポートされているわけではないことに注意してください。

TCOは、再帰の特殊なケースに適用されます。その要点は、関数で最後に行うのがそれ自体の呼び出し(たとえば、「末尾」の位置から自分自身を呼び出す)である場合、これはコンパイラーによって最適化され、標準の再帰ではなく反復のように動作することです。

通常、再帰中に、ランタイムはすべての再帰呼び出しを追跡する必要があるため、1つが戻ったときに前の呼び出しから再開できるようになっています。(再帰呼び出しの結果を手動で書き出して、これがどのように機能するかを視覚的に把握してみてください。)すべての呼び出しを追跡すると、スペースを占有します。これは、関数自体が頻繁に呼び出されるときに重要になります。しかし、TCOを使用すると、「最初に戻って、今回のみパラメーター値をこれらの新しい値に変更する」と言うことができます。再帰呼び出しの後は何もこれらの値を参照しないため、これを行うことができます。


3
末尾呼び出しは、非再帰関数にも適用できます。戻る前の最後の計算が別の関数の呼び出しである関数は、末尾呼び出しを使用できます。
ブライアン

言語ごとに必ずしも当てはまるわけではありません。64ビットC#コンパイラはテールオペコードを挿入する可能性がありますが、32ビットバージョンは挿入しません。F#リリースビルドは行いますが、F#デバッグはデフォルトでは行いません。
スティーブギルハム

3
「TCOは再帰の特殊なケースに適用されます」。それは完全に間違っていると思います。テールコールは、テール位置にあるすべてのコールに適用されます。再帰の文脈で一般的に議論されますが、実際には再帰とは特に関係ありません。
Jon Harrop 2013

@ブライアン、上記のリンク@btiernayを見てください。最初のfooメソッド末尾呼び出しは最適化されていませんか?
SexyBeast 2014年

13

x86逆アセンブリ分析を使用したGCC最小実行可能例

生成されたアセンブリを見て、GCCがテールコールの最適化を自動的に行う方法を見てみましょう。

これは、https: //stackoverflow.com/a/9814654/895245などの他の回答で言及された内容の非常に具体的な例として機能しますた最適化が再帰的な関数呼び出しをループに変換できるます。

これにより、メモリが節約され、パフォーマンスが向上します。これは、最近のメモリアクセスが、プログラムを遅くする主な原因であることが多いためです。です。

入力として、GCCに最適化されていない単純なスタックベースの階乗を与えます。

tail_call.c

#include <stdio.h>
#include <stdlib.h>

unsigned factorial(unsigned n) {
    if (n == 1) {
        return 1;
    }
    return n * factorial(n - 1);
}

int main(int argc, char **argv) {
    int input;
    if (argc > 1) {
        input = strtoul(argv[1], NULL, 0);
    } else {
        input = 5;
    }
    printf("%u\n", factorial(input));
    return EXIT_SUCCESS;
}

GitHubアップストリーム

コンパイルと逆アセンブル:

gcc -O1 -foptimize-sibling-calls -ggdb3 -std=c99 -Wall -Wextra -Wpedantic \
  -o tail_call.out tail_call.c
objdump -d tail_call.out

ここ-foptimize-sibling-callsで、末尾呼び出しの一般化の名前は次のとおりですman gcc

   -foptimize-sibling-calls
       Optimize sibling and tail recursive calls.

       Enabled at levels -O2, -O3, -Os.

で述べたように:gccが末尾再帰最適化を実行しているかどうかを確認するにはどうすればよいですか?

私が選ん-O1だ理由:

  • 最適化はで行われません-O0。これは、必要な中間変換が欠落しているためと考えられます。
  • -O3 非常に教育的ではない、信じられないほど効率的なコードを生成しますが、テールコールも最適化されています。

分解-fno-optimize-sibling-calls

0000000000001145 <factorial>:
    1145:       89 f8                   mov    %edi,%eax
    1147:       83 ff 01                cmp    $0x1,%edi
    114a:       74 10                   je     115c <factorial+0x17>
    114c:       53                      push   %rbx
    114d:       89 fb                   mov    %edi,%ebx
    114f:       8d 7f ff                lea    -0x1(%rdi),%edi
    1152:       e8 ee ff ff ff          callq  1145 <factorial>
    1157:       0f af c3                imul   %ebx,%eax
    115a:       5b                      pop    %rbx
    115b:       c3                      retq
    115c:       c3                      retq

-foptimize-sibling-calls

0000000000001145 <factorial>:
    1145:       b8 01 00 00 00          mov    $0x1,%eax
    114a:       83 ff 01                cmp    $0x1,%edi
    114d:       74 0e                   je     115d <factorial+0x18>
    114f:       8d 57 ff                lea    -0x1(%rdi),%edx
    1152:       0f af c7                imul   %edi,%eax
    1155:       89 d7                   mov    %edx,%edi
    1157:       83 fa 01                cmp    $0x1,%edx
    115a:       75 f3                   jne    114f <factorial+0xa>
    115c:       c3                      retq
    115d:       89 f8                   mov    %edi,%eax
    115f:       c3                      retq

2つの主な違いは次のとおりです。

  • -fno-optimize-sibling-calls用途callq典型的な非最適化された関数呼び出しです。

    この命令は、戻りアドレスをスタックにプッシュするため、増加します。

    さらに、このバージョンではpush %rbxスタックにプッシュ%rbxします

    GCCはこれをedi実行します。これは、最初の関数引数(n)であるを格納してからebxfactorialです。

    GCCはfactorial、新しいを使用するへの別の呼び出しの準備をしているため、これを行う必要がありますedi == n-1

    これはebx、このレジスターが呼び出し先に保存されているために選択されます。Linuxのx86-64関数呼び出しを通じてどのレジスターが保存されるかによって、サブコールfactorialが変更されずに失われnます。

  • これ-foptimize-sibling-callsは、スタックにプッシュする命令を使用しません:命令でgotoジャンプするだけで、factorialjejne

    したがって、このバージョンは、関数呼び出しのないwhileループと同等です。スタックの使用量は一定です。

Ubuntu 18.10、GCC 8.2でテスト済み。


6

ここを見て:

http://tratt.net/laurie/tech_articles/articles/tail_call_optimization

おそらくご存じのとおり、再帰的な関数呼び出しはスタックに大混乱をもたらす可能性があります。スタック領域がすぐに不足するのは簡単です。末尾呼び出しの最適化は、一定のスタックスペースを使用する再帰的なスタイルアルゴリズムを作成できる方法です。そのため、定数は増加せず、スタックエラーが発生します。


3
  1. 関数自体にgotoステートメントがないことを確認する必要があります。呼び出し先の関数の最後にある関数呼び出しによって処理されます。

  2. 大規模な再帰はこれを最適化に使用できますが、小規模では、関数呼び出しを末尾呼び出しにするための命令オーバーヘッドにより、実際の目的が減少します。

  3. TCOにより、機能が永久に実行される可能性があります。

    void eternity()
    {
        eternity();
    }
    

3はまだ最適化されていません。これは最適化されていない表現であり、コンパイラーは再帰的なコードの代わりに定数スタックスペースを使用する反復コードに変換します。TCOは、データ構造に間違った再帰スキームを使用する原因にはなりません。
nomen

「TCOがデータ構造に誤った再帰スキームを使用する原因ではない」これが特定のケースにどのように関連しているかを詳しく説明してください。上記の例は、TCOがある場合とない場合のコールスタックに割り当てられるフレームの例を示しています。
GrillSandwich 2013年

()をトラバースするために、根拠のない再帰を使用することを選択しました。TCOとは関係ありません。eternityはたまたま末尾呼び出し位置ですが、末尾呼び出し位置は必要ありません。void eternity(){eternity(); 出口(); }
nomen

その間、「大規模再帰」とは何ですか?関数でgotoを避ける必要があるのはなぜですか?これは、TCOを許可するのに必要でも十分でもありません。そして、どんな命令オーバーヘッド?TCOの要点は、コンパイラーが末尾の関数呼び出しをgotoに置き換えることです。
nomen 2013年

TCOは、コールスタックで使用されるスペースを最適化することです。大規模な再帰とは、フレームのサイズを指します。再帰が発生するたびに、呼び出し先関数の上の呼び出しスタックに巨大なフレームを割り当てる必要がある場合、TCOがさらに役立ち、再帰のレベルを上げることができます。しかし、フレームサイズが小さい場合でも、TCOがなくてもプログラムを実行できます(ここでは、無限再帰については話していません)。関数にgotoが残っている場合、「末尾」呼び出しは実際には末尾呼び出しではなく、TCOは適用されません。
GrillSandwich 2013年

3

再帰関数アプローチには問題があります。サイズO(n)のコールスタックを構築します。これにより、合計メモリコストはO(n)になります。これにより、コールスタックが大きくなりすぎて領域が不足するスタックオーバーフローエラーに対して脆弱になります。

テールコール最適化(TCO)スキーム。再帰関数を最適化して、高い呼び出しスタックの構築を回避できるため、メモリコストを節約できる場合。

PythonやJavaはTCOを行わないのに対し、(JavaScript、Ruby、およびいくつかのC)のようにTCOを行う多くの言語があります。

JavaScript言語は、:) http://2ality.com/2015/06/tail-call-optimization.htmlを使用して確認されています


0

関数型言語では、末尾呼び出しの最適化は、関数呼び出しが部分的に評価された式を結果として返し、呼び出し元によって評価されるかのようです。

f x = g x

f 6はg 6に減少します。したがって、実装が結果としてg 6を返し、その式を呼び出した場合、スタックフレームが保存されます。

また

f x = if c x then g x else h x.

f 6に還元してg 6またはh 6のいずれかにします。したがって、実装がc 6を評価し、それが真であるとわかった場合、それは減少できます。

if true then g x else h x ---> g x

f x ---> h x

単純な非末尾呼び出し最適化インタープリターは次のようになります。

class simple_expresion
{
    ...
public:
    virtual ximple_value *DoEvaluate() const = 0;
};

class simple_value
{
    ...
};

class simple_function : public simple_expresion
{
    ...
private:
    simple_expresion *m_Function;
    simple_expresion *m_Parameter;

public:
    virtual simple_value *DoEvaluate() const
    {
        vector<simple_expresion *> parameterList;
        parameterList->push_back(m_Parameter);
        return m_Function->Call(parameterList);
    }
};

class simple_if : public simple_function
{
private:
    simple_expresion *m_Condition;
    simple_expresion *m_Positive;
    simple_expresion *m_Negative;

public:
    simple_value *DoEvaluate() const
    {
        if (m_Condition.DoEvaluate()->IsTrue())
        {
            return m_Positive.DoEvaluate();
        }
        else
        {
            return m_Negative.DoEvaluate();
        }
    }
}

末尾呼び出しの最適化インタープリターは次のようになります。

class tco_expresion
{
    ...
public:
    virtual tco_expresion *DoEvaluate() const = 0;
    virtual bool IsValue()
    {
        return false;
    }
};

class tco_value
{
    ...
public:
    virtual bool IsValue()
    {
        return true;
    }
};

class tco_function : public tco_expresion
{
    ...
private:
    tco_expresion *m_Function;
    tco_expresion *m_Parameter;

public:
    virtual tco_expression *DoEvaluate() const
    {
        vector< tco_expression *> parameterList;
        tco_expression *function = const_cast<SNI_Function *>(this);
        while (!function->IsValue())
        {
            function = function->DoCall(parameterList);
        }
        return function;
    }

    tco_expresion *DoCall(vector<tco_expresion *> &p_ParameterList)
    {
        p_ParameterList.push_back(m_Parameter);
        return m_Function;
    }
};

class tco_if : public tco_function
{
private:
    tco_expresion *m_Condition;
    tco_expresion *m_Positive;
    tco_expresion *m_Negative;

    tco_expresion *DoEvaluate() const
    {
        if (m_Condition.DoEvaluate()->IsTrue())
        {
            return m_Positive;
        }
        else
        {
            return m_Negative;
        }
    }
}
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.