Clojureは単独で末尾呼び出しの最適化を実行しません。末尾再帰関数があり、それを最適化したい場合は、特殊な形式recur
を使用する必要があります。同様に、相互に再帰的な関数が2つある場合は、trampoline
を使用することによってのみ関数を最適化できます。
Scalaコンパイラは、再帰関数に対してTCOを実行できますが、2つの相互に再帰的な関数に対しては実行できません。
これらの制限について読んだときはいつでも、それらは常にJVMモデルに固有のいくつかの制限によるものでした。私はコンパイラについてほとんど何も知りませんが、これは私を少し困惑させます。 Programming Scala
の例を見てみましょう。ここに関数
def approximate(guess: Double): Double =
if (isGoodEnough(guess)) guess
else approximate(improve(guess))
に翻訳されます
0: aload_0
1: astore_3
2: aload_0
3: dload_1
4: invokevirtual #24; //Method isGoodEnough:(D)Z
7: ifeq
10: dload_1
11: dreturn
12: aload_0
13: dload_1
14: invokevirtual #27; //Method improve:(D)D
17: dstore_1
18: goto 2
したがって、バイトコードレベルでは、goto
が必要です。この場合、実際には、ハードワークはコンパイラーによって行われます。
コンパイラがTCOをより簡単に処理できるようにする、基盤となる仮想マシンの機能はどれですか。
余談ですが、実際のマシンがJVMよりもはるかに賢いとは思いません。それでも、Haskellなどのネイティブコードにコンパイルする多くの言語では、末尾呼び出しの最適化に問題がないようです(まあ、Haskellは遅延が原因で発生することがありますが、それも別の問題です)。
さて、私はClojureについてはあまり知りませんが、Scalaについてはほとんど知りませんが、試してみましょう。
まず、tail-CALLとtail-RECURSIONを区別する必要があります。テール再帰は、実際にはループに変換するのがかなり簡単です。末尾呼び出しを使用すると、一般的なケースではそれがはるかに困難になります。何が呼び出されているかを知る必要がありますが、ポリモーフィズムやファーストクラスの関数を使用している場合、そのことをほとんど知らないため、コンパイラーは呼び出しを置き換える方法を知ることができません。実行時にのみ、ターゲットコードを知っており、別のスタックフレームを割り当てることなくそこにジャンプできます。たとえば、次のフラグメントには末尾呼び出しがあり、適切に最適化されている場合(TCOを含む)にはスタックスペースは必要ありませんが、JVM用にコンパイルする場合は削除できません。
function forward(obj: Callable<int, int>, arg: int) =
let arg1 <- arg + 1 in obj.call(arg1)
ここでは少し効率が悪いだけですが、大量の末尾呼び出しがあり、めったに戻らないプログラミングスタイル全体(Continuation Passing StyleまたはCPSなど)があります。完全なTCOなしでこれを行うと、スタックスペースが不足する前に実行できるコードはごくわずかです。
コンパイラがTCOをより簡単に処理できるようにする、基盤となる仮想マシンの機能はどれですか。
Lua 5.1 VMなどの末尾呼び出し命令。あなたの例はそれほど単純にはなりません。鉱山は次のようになります:
Push arg
Push 1
add
load obj
tailcall Callable.call
// implicit return; stack frame was recycled
余談ですが、実際のマシンがJVMよりもはるかに賢いとは思いません。
そうです、そうではありません。実際、それらはless smartであり、スタックフレームのようなものについて(多く)さえ知りません。スタックアドレスを再利用したり、リターンアドレスをプッシュせずにコードにジャンプしたりするようなトリックを引き出すことができるのは、まさにそのためです。
Clojurecouldループへの末尾再帰の自動最適化を実行します。これは、JVMでScala証明します。
これは実際には設計上の決定であり、これを行わないでください。この機能が必要な場合は、recur
特殊フォームを明示的に使用する必要があります。 Clojure googleグループのメールスレッド Re:なぜテールコールの最適化がないのか を参照してください。
現在のJVMでは、実行できない唯一のことは、異なる関数間の相互呼び出し最適化(相互再帰)です。これは実装するのに特に複雑ではありませんが(Schemeなどの他の言語では最初からこの機能がありました)、JVM仕様の変更が必要になります。たとえば、完全な関数呼び出しスタックの保持に関するルールを変更する必要があります。
古いコードの下位互換性のある動作が維持されるようにするためのオプションとしておそらく、JVMの将来の反復はこの機能を取得する可能性があります。言います 機能プレビュー GeeknizerのJava 9:
末尾呼び出しと継続を追加しています...
もちろん、将来のロードマップは常に変更される可能性があります。
結局のところ、それほど大きな問題ではありません。 Clojureのコーディングの2年以上の間に、私はTCOの不足が問題である状況に遭遇することは決してありません。これの主な理由は次のとおりです。
recur
またはループを使用すると、一般的なケースの99%で高速末尾再帰をすでに取得できます。通常のコードでは相互末尾再帰のケースはかなりまれです余談ですが、実際のマシンがJVMよりもはるかに賢いとは思いません。
それは賢いことではなく、違うことです。最近まで、JVMは非常に厳密なメモリと呼び出しモデルを持つ単一の言語(明らかにJava)専用に設計および最適化されていました。
goto
やポインタがなかっただけでなく、「ベア」関数(クラス内で定義されたメソッドではない関数)を呼び出す方法すらありませんでした。
概念的には、JVMをターゲットとする場合、コンパイラー作成者は「この概念をJava用語で表現するにはどうすればよいですか?」」と質問する必要があります。
これらはJavaには必要ないため、JVMの障害とは見なされないことに注意してください。 Javaがこれらのような機能を必要とするや否や、それはJVMに追加されます。
Java当局が非Java言語のプラットフォームとして真剣にJVMを採用し始めたのはごく最近のことです。そのため、Java =同等。最もよく知られているのは動的型付けで、これはすでにJVMにありますが、Javaにはありません。
したがって、バイトコードレベルでは、gotoが必要です。この場合、実際には、ハードワークはコンパイラーによって行われます。
メソッドのアドレスが0で始まることに気づきましたか? allメソッドのセットは0で始まりますか? JVMでは、メソッドの外にジャンプすることはできません。
Java-多分それはバイトコードベリファイアによってキャッチされるかもしれません、多分それは例外を生成するでしょう、そして多分それは実際にはメソッドの外にジャンプします。
もちろん問題は、同じクラスの他のメソッドがどこにあるのか、他のクラスのメソッドよりはるかに少ないことを本当に保証できないことです。 JVMがメソッドをどこにロードするかについては保証できないと思いますが、修正されれば幸いです。