末尾再帰の排除

私は最近、Python Historyブログに投稿しました。「 Pythonの機能的特徴の起源 」( translation )。 Pythonが末尾再帰(TRE)をサポートしていないことに言及すると、Pythonがこの最適化をサポートしていないことを残念に思うというコメントがすぐに出ました。 TREをPythonに簡単に追加できる他のブログの最近のエントリへのリンクがあります。 私の立場を守らせてください-言語にTREを追加したくありません 。 短い答えが必要な場合は、これはまったく無意味です。



ここに長い答えがあります。



まず 、コメントで誰かが指摘したように、TREは通常のスタックトレースと互換性がありません。テール再帰が排除されると、何か問題が発生した場合にトレースバックを出力するスタックフレームが残りません。 これは、誤って再帰的な何かを書いたユーザーを混乱させます(スタックトレースでは再帰は明らかではありません)、デバッグが困難になります。 TREを無効にする機能を提供することは、私には間違っているように思えます。Pythonは、常にできるだけ簡単にデバッグできるはずです。 これにより、次の結論に至ります。



第二に、TREは単一のPython実装が実装できる最適化にすぎないという考えは誤りです。 末尾再帰の削除が導入されると、開発者はこの最適化に依存するコードの記述を開始し、それらのコードはそれをサポートしない実装では動作しません:典型的なPython実装では、非再帰的に記述されたコードおよびコードに十分な1000回の再帰を行うことができます、たとえば、ツリーをトラバースするために再帰的に呼び出されますが、大きなリストの周りに再帰的に記述されたループには十分ではありません。



第三に 、すべてのプログラミングの基礎として再帰を信じていません。 これは、特定のプログラマー、特にSchemeを愛し、再帰から始めてプログラミングを学ぶのが好きなプログラマーの基本的な信念です。 しかし、再帰は基本的な数学に対する優れた理論的アプローチであり、タスクを解決するための日常的なツールではないと考えています。



実際には、Pythonスタイルのリスト(リンクリストではなく、動的な長さの配列)、および一般的なシーケンスは、再帰よりもプログラミングの素晴らしい世界を探索するのに非常に役立ちます。 これらは、経験豊富なPythonプログラマーにとって最も重要なツールの1つです。 リンクされたリストを使用して値のシーケンスを表すことは非Pythonicalであり、ほとんどの場合非常に非効率的です。 Pythonライブラリのほとんどは、リンクリストではなく、プログラム(およびもちろん辞書)のメインブロックとしてシーケンスとイテレータを使用して記述されています。 したがって、リストとシーケンスを使用しない場合、言語に組み込まれている機能の一部を自分で奪うことになります。



最後に、末尾再帰の削除を実装する方法を見てみましょう。 最初の観察は、コンパイル時にこれを実行できないことです。 バイトコードハッキングを使用してCALLを直接RETURNに置き換えて機能の先頭に移動するブログ投稿を少なくとも1つ見ました。 これは良いデモかもしれませんが、残念ながら、Pythonコンパイラは、同じ名前であっても、特定の呼び出しが現在の関数を参照しているかどうかを確実に判断できません。 この簡単な例を考えてみましょう。

def f(x): if x > 0: return f(x-1) return 0
      
      





ボディを次のようなものに置き換えることができます。

 if x > 0: x = x-1 <jump to top> return 0
      
      





これは十分に単純に思えますが、今これを追加します:

 g = f def f(x): return x g(5)
      
      





g(5)の呼び出しは、以前に定義された関数fを呼び出しますが、「再帰」呼び出しはもはやそのようなものではありません! 実行時に、名前「f」は非再帰関数に再定義されるため、戻り値は0ではなく4です。同時に、これは悪いスタイルであることに同意しますが、論理的にする多くの方法があるPythonセマンティクスの明確な部分ですコンパイラは、fの定義が変更されないままであることを期待してこの置き換えを行い、実際のコードでかなり多くのエラーを引き起こします。



別のブログ投稿では、魔法の例外または戻り値を使用して末尾再帰を実装するために使用できるデコレーターについて説明しました。 それらはプレーンなPythonコードで書くことができます(ただし、メッセージはCythonの最適化されたバージョンを示していますが、「10%だけ遅い」と言われています。スレッドセーフではないようです)。 あなたがとても興味があるなら、私はあなたを止めるつもりはありませんが、私は言語の組み込み機能にこのようなものを含めることに強く反対します:デコレータを使用しない多くの理由があります、なぜなら再帰呼び出しは末尾再帰であり、除去される。 経験の浅いユーザーの手に渡ると、これは災害につながります。 たとえば、階乗の通常の再帰的な定義は末尾再帰ではありません。

 def fact(n): if n > 1: return n * fact(n-1) return 1
      
      





通常の再帰呼び出しに加えて、末尾再帰を含む多くの関数もあります。 デコレータはそのような場合をサポートしません。 このようなデコレータが処理しないもう1つの微妙な点は、tryブロックでの末尾再帰呼び出しです。これらは削除できるように思えるかもしれませんが、TREが例外処理を削除できるため、実行できません。 これらすべての理由から、このようなアプローチは、少なくとも幅広い聴衆にとっては運命にあると思います。



ただし、誰かが(たとえば)CpythonにTREを追加するように構成されている場合は、このようにコンパイラを変更できます。 最初に、「安全な」テール再帰ポイントを定義します。 たとえば、完全にtryブロックの外側にある間に、すぐにRETURN命令コードが続くCALL命令コード。 (注:同じアプローチを使用して簡単に処理できるさまざまなCALL_ *操作については言及しません。)次に、そのようなCALL-RETURNの各ペアを単一のCALL_RETURN操作に置き換えます。 コンパイラーは、呼び出された関数の名前が現在の関数と同じであるかどうかを確認する必要はありません:実行時にオーバーライドが発生した場合、このCALLはTREには適用されず、CALLの後にRETURNコードが続く通常のアクションを実行する必要があります。 (再定義を高速化するために、ダイヤルピアによってインデックス付けされたキャッシュメカニズムを追加できると思います)。



TREを使用できる場所を決定するために、適用できる「攻撃性」のレベルがいくつかあります。 最も攻撃的な「バニラ」アプローチは、呼び出されたオブジェクトが現在のスタックフレームで既に機能している関数である場合にのみ、呼び出しを最適化することです。 この場合に行う必要があるのは、現在のスタックフレーム(およびアクティブループなどの他の隠された状態)からローカル変数をクリアし、引数を設定して、先頭に移動することです。 (微妙:新しいパラメーターは、定義により、現在のスタックフレーム内にあります。これはおそらく単なるコピーの問題です。引数、可変長引数リスト、およびデフォルト値を持つ引数と呼ばれる質問もあります。これは単なるプログラミングの質問です)。



より積極的なバージョンでは、末尾再帰のあるメソッド (つまり、呼び出されたオブジェクトが関連メソッドであり、基礎となる関数が現在のスタックフレームと同じである)の状況も認識します。 これにはもう少しプログラミングが必要です。 CPythonインタープリターコード(ceval.c)には、すでにメソッド呼び出しの最適化があります。 (これがどれほど役立つかはわかりませんが、TREスタイルは一般に関数型プログラミングスタイルを使用したいプログラマーにアピールし、おそらくクラスをまったく使用しないことを期待しています。:-)



理論的には、呼び出されたオブジェクトがPythonで記述された関数またはメソッドであり、新しい呼び出しに必要なローカル変数の数を現在のスタックフレームに配置できるすべてのケースを最適化することさえできます。 (CPythonのフレームオブジェクトはヒープ上にあり、ローカル変数に必要なメモリに基づいてサイズが可変です。フレームオブジェクトを再利用するのはアーキテクチャの問題です)。 これらのアクションは、他の方法では最適化されない相互再帰的なテール再帰を最適化します。 残念ながら、これはほとんどの場合スタックトレースも無効にするので、良い考えではありません。



よりソフトなバージョンでは、以前とまったく同じPythonレベルでスタックフレームオブジェクトを作成する必要がありますが、Cスタックフレームを再び使用します。これにより、スタックレスPythonのようなものが作成されました。関数またはメソッド。



もちろん、3つの主張を満足させるアプローチはありません。 ループを使用するように関数を書き換えることは本当に難しいですか?



All Articles