JVMをターゲットにした新しいプログラミング言語についての議論があるたびに、必然的に次のようなことを言う人々がいます。
"JVMは末尾呼び出しの最適化をサポートしていないため、爆発するスタックがたくさんあると予測します"
そのテーマには何千ものバリエーションがあります。
たとえば、Clojureなどの一部の言語には、使用できる特別なrecur構文があることがわかります。
私が理解していないのは、末尾呼び出しの最適化の欠如がどれほど深刻であるかです。いつ心配する必要がありますか?
Javaはこれまでで最も成功した言語の1つであり、かなりの数のJVM言語がかなりうまく機能しているようです。) TCOの欠如は本当にanyの懸念事項ですか?
これを考慮して、Javaのすべてのループを取り除いたとしましょう(コンパイラの作成者はストライキか何かです)。今度は階乗を書きたいので、次のように修正します。
int factorial(int i){ return factorial(i, 1);}
int factorial(int i, int accum){
if(i == 0) return accum;
return factorial(i-1, accum * i);
}
これでかなり賢くなり、ループなしでも階乗を書くことができました!しかし、テストすると、適度なサイズの数値では、TCOがないためにスタックオーバーフローエラーが発生していることがわかります。
実際にはJavaこれは問題ありません。もしテール再帰アルゴリズムがあれば、それをループに変換して問題なく実行できます。しかし、ループのない言語はどうでしょうか?だから、clojureにはこのrecur
形式があるのですが、それがないと、完全にチューリングすることすらできません(無限ループを実行する方法はありません)。
JVMをターゲットとする関数型言語のクラスであるFrege、Kawa(Scheme)、Clojureは、常に末尾呼び出しの欠如に対処しようとしています。これらの言語では、TCはループを行う慣用的な方法だからです。 Schemeに変換すると、上記の階乗は良い階乗になります。 5000回ループするとプログラムがクラッシュするのは非常に不便です。ただし、これはrecur
の特殊な形式、自己呼び出しの最適化を示唆する注釈、トランポリンなどを使用して回避できます。しかし、それらはすべて、パフォーマンスヒットまたはプログラマーへの不要な作業のいずれかを強制します。
Javaも無料ではありません。TCOの後に再帰するだけなので、相互再帰関数についてはどうでしょうか?それらはループに直接変換することはできませんが、それでもまだ最適化されていません。 JVM。これにより、Javaを使用して相互再帰を使用するアルゴリズムを記述しようとするのは非常に不快になります。適切なパフォーマンス/範囲が必要な場合は、ループに収まるようにダークマジックを実行する必要があるためです。
つまり、要約すると、これは多くの場合大きな問題ではありません。ほとんどの末尾呼び出しは、次のようなもので、1スタックフレームの深さでのみ進行します。
return foo(bar, baz); // foo is just a simple method
または再帰です。しかし、これに当てはまらないTCのクラスでは、すべてのJVM言語が苦痛を感じます。
ただし、TCOがまだない理由はそこそこあります。 JVMはスタックトレースを提供します。 TCOを使用すると、「終了」することがわかっているスタックフレームを体系的に排除できますが、JVMは実際にスタックトレースのためにこれらを後で必要とする場合があります。このようなFSMを実装するとします。各州が次を末尾呼び出しします。以前の状態のすべてのレコードを消去して、トレースバックでどのような状態が表示されるかを示しますが、そこに到達した方法については何も表示しません。
さらに、そしてより差し迫っては、バイトコード検証の多くはスタックベースであるため、バイトコードの検証を可能にするものを排除することは好ましい見通しではありません。これとJavaにループがあるという事実の間で、TCOは、JVMエンジニアにとって価値があるよりも少し厄介なように見えます。
末尾再帰のため、末尾呼び出しの最適化は主に重要です。ただし、JVMがテールコールを最適化しないことが実際に良い理由は、次のとおりです。TCOがスタックの一部を再利用するため、例外からのスタックトレースが不完全になり、デバッグが少し難しくなります。
JVMの制限を回避する方法があります。
これには、より大きな例が必要になる場合があります。クロージャーを使用する言語(JavaScriptなど)を検討します。次のように階乗を書くことができます
def fac(n, acc = 1) = if (n <= 1) acc else n * fac(n-1, acc*n)
print fac(x)
これで、代わりにコールバックを返すことができます。
def fac(n, acc = 1) =
if (n <= 1) acc
else (() => fac(n-1, acc*n)) // this isn't full CPS, but you get the idea…
var continuation = (() => fac(x))
while (continuation instanceof function) {
continuation = continuation()
}
var result = continuation
print result
これは一定のスタックスペースで動作するようになりました。とにかく末尾再帰であるため、これはちょっとばかげています。ただし、この手法では、定数スタックスペースへのall末尾呼び出しをフラット化できます。そして、プログラムがCPSにある場合、これはコールスタックが全体的に一定であることを意味します(CPSでは、すべての呼び出しは末尾呼び出しです)。
この手法の主な欠点は、デバッグがはるかに難しく、実装が少し難しく、パフォーマンスが低いことです。使用しているすべてのクロージャーと間接参照を確認してください。
これらの理由から、VM末尾呼び出しopを実装することは非常に好ましいでしょう。Javaのような、末尾呼び出しをサポートしない正当な理由がある言語はそれを使用する必要があります。
プログラムの呼び出しのかなりの部分は末尾呼び出しです。すべてのサブルーチンには最後の呼び出しがあるため、すべてのサブルーチンには少なくとも1つの末尾呼び出しがあります。末尾呼び出しにはGOTO
のパフォーマンス特性がありますが、サブルーチン呼び出しの安全性があります。
適切な末尾呼び出しがあると、他の方法では作成できないプログラムを作成できます。たとえば、ステートマシンを取り上げます。状態マシンは、各状態をサブルーチンにして、各状態遷移をサブルーチン呼び出しにすることで、非常に直接実装できます。その場合、呼び出しの後に呼び出しを行うことにより、状態から状態へと遷移し、実際にはnever return!適切なテールコールがないと、すぐにスタックが破壊されます。
PTCがない場合、GOTO
またはトランポリンまたは例外を制御フローなどとして使用する必要があります。これは非常に醜く、状態機械を直接1対1で表現するものではありません。
(退屈な「ループ」の例の使用を巧みに回避した方法に注意してください。これは、言語でもPTCが役立つ例withループです。)
ここでは、TCOの代わりに、意図的に「適切なテールコール」という用語を使用しました。 TCOはコンパイラの最適化です。 PTCは、TCOを実行するためにeveryコンパイラを必要とする言語機能です。
「JVMは末尾呼び出しの最適化をサポートしていないため、大量のスタックの爆発を予測しています」
これを言う人は、(1)末尾呼び出しの最適化を理解していないか、(2)JVMを理解していない、または(3)両方です。
Wikipedia からの末尾呼び出しの定義から始めます(Wikipediaが気に入らない場合は、 ここに代替案があります ):
コンピュータサイエンスでは、末尾呼び出しは、最後のアクションとして別のプロシージャ内で発生するサブルーチン呼び出しです。戻り値が生成される場合があり、その値は呼び出し側のプロシージャによってすぐに返されます。
以下のコードでは、bar()
の呼び出しはfoo()
の末尾呼び出しです。
_private void foo() {
// do something
bar()
}
_
末尾呼び出しの最適化は、末尾呼び出しを確認して、言語の実装が通常のメソッド呼び出し(スタックフレームを作成する)を使用せず、代わりにブランチを作成するときに発生します。スタックフレームはメモリを必要とし、情報(戻りアドレスなど)をフレームにプッシュするにはCPUサイクルが必要であり、呼び出しと戻りのペアは無条件ジャンプよりも多くのCPUサイクルを必要とすると想定されるため、これは最適化です。
TCOはしばしば再帰に適用されますが、それが唯一の用途ではありません。また、すべての再帰には適用されません。たとえば、階乗を計算するための単純な再帰的コードは、末尾呼び出しで最適化できません。これは、関数で発生する最後のことが乗算演算であるためです。
_public static int fact(int n) {
if (n <= 1) return 1;
else return n * fact(n - 1);
}
_
末尾呼び出しの最適化を実装するには、次の2つが必要です。
それでおしまい。他の場所で述べたように、JVM(他のすべてのチューリング完全アーキテクチャと同様)には後付けがあります。たまたま 無条件goto がありますが、機能は条件付きブランチを使用して簡単に実装できます。
静的分析のピースはトリッキーです。単一の関数内では問題ありません。たとえば、以下は末尾再帰Scala関数で、List
の値を合計するものです。
_def sum(acc:Int, list:List[Int]) : Int = {
if (list.isEmpty) acc
else sum(acc + list.head, list.tail)
}
_
この関数は、次のバイトコードになります。
_public int sum(int, scala.collection.immutable.List);
Code:
0: aload_2
1: invokevirtual #63; //Method scala/collection/immutable/List.isEmpty:()Z
4: ifeq 9
7: iload_1
8: ireturn
9: iload_1
10: aload_2
11: invokevirtual #67; //Method scala/collection/immutable/List.head:()Ljava/lang/Object;
14: invokestatic #73; //Method scala/runtime/BoxesRunTime.unboxToInt:(Ljava/lang/Object;)I
17: iadd
18: aload_2
19: invokevirtual #76; //Method scala/collection/immutable/List.tail:()Ljava/lang/Object;
22: checkcast #59; //class scala/collection/immutable/List
25: astore_2
26: istore_1
27: goto 0
_
最後の_goto 0
_に注意してください。比較すると、同等のJava関数(Iterator
を使用して、Scalaリストを先頭と末尾に分解する動作を模倣する必要があります))最後の2つの操作はinvokeになり、その後にその再帰呼び出しによって生成された値が明示的に返されることに注意してください。
_public static int sum(int, Java.util.Iterator);
Code:
0: aload_1
1: invokeinterface #64, 1; //InterfaceMethod Java/util/Iterator.hasNext:()Z
6: ifne 11
9: iload_0
10: ireturn
11: iload_0
12: aload_1
13: invokeinterface #70, 1; //InterfaceMethod Java/util/Iterator.next:()Ljava/lang/Object;
18: checkcast #25; //class Java/lang/Integer
21: invokevirtual #74; //Method Java/lang/Integer.intValue:()I
24: iadd
25: aload_1
26: invokestatic #43; //Method sum:(ILjava/util/Iterator;)I
29: ireturn
_
単一の関数の末尾呼び出しの最適化は簡単です。コンパイラーは、呼び出しの結果を使用するコードがないことを確認できるため、invokeをgoto
。
人生が難しくなるのは、複数の方法がある場合です。 80x86などの汎用プロセッサの命令とは異なり、JVMの分岐命令は単一のメソッドに限定されます。プライベートメソッドがある場合は、比較的簡単です。コンパイラは、必要に応じてこれらのメソッドを自由にインライン化できるため、末尾呼び出しを最適化できます(これがどのように機能するのか疑問がある場合は、switch
を使用する一般的なメソッドを検討してください動作を制御します)。この手法を同じクラスの複数のパブリックメソッドに拡張することもできます。コンパイラはメソッド本体をインライン化し、パブリックブリッジメソッドを提供し、内部呼び出しはジャンプに変わります。
ただし、特にインターフェイスとクラスローダーを考慮して、さまざまなクラスのパブリックメソッドを検討すると、このモデルは機能しなくなります。ソースレベルのコンパイラは、末尾呼び出しの最適化を実装するための十分な知識を持っていません。ただし、とは異なり「ベアメタル」実装では、* JVM(には、これを行うための情報がHotspotコンパイラ(少なくとも、元Sunコンパイラ)の形式で含まれています。それが実際に末尾呼び出しの最適化を実行するかどうかはわかりませんが、そうではないと思われますが、couldです。
これにより、質問の2番目の部分に移動します。これを「気にする必要がありますか?」と言い換えます。
明らかに、言語が反復の唯一のプリミティブとして再帰を使用している場合は、気にする必要があります。しかし、この機能を必要とする言語はそれを実装できます。唯一の問題は、その言語のコンパイラが、任意のJavaクラスによって呼び出され、呼び出されるクラスを生成できるかどうかです。
それ以外の場合は、無関係と言って反対投票を呼びます。私が見たほとんどの再帰的コード(そして私は多くのグラフプロジェクトで作業しました)は、末尾呼び出しの最適化可能ではありません。単純な階乗のように、状態を構築するために再帰を使用し、テール操作は組み合わせです。
末尾呼び出しを最適化できるコードの場合、そのコードを反復可能な形式に変換するのは簡単です。たとえば、先に示したsum()
関数は、foldLeft()
として一般化できます。 ソースで を見ると、実際には反復演算として実装されていることがわかります。 JörgW Mittagには、関数呼び出しを介して実装された状態マシンの例がありました。関数呼び出しがジャンプに変換されることに依存しない、効率的な(そして保守可能な)ステートマシンの実装がたくさんあります。
まったく違うもので仕上げます。 SICPで footnotes からGoogleを使用すると、最終的に here になる可能性があります。個人的に、コンパイラーがJSR
をJUMP
に置き換えるよりもはるかに興味深い場所だと思います。