"Die JVM unterstützt keine Tail-Call-Optimierung, daher sage ich viele explodierende Stacks voraus."
Jeder, der dies sagt, versteht entweder (1) die Tail-Call-Optimierung nicht oder (2) die JVM nicht oder (3) beides.
Ich beginne mit der Definition von Tail-Calls aus Wikipedia (wenn Sie Wikipedia nicht mögen, finden Sie hier eine Alternative ):
In der Informatik ist ein Tail-Aufruf ein Unterprogrammaufruf, der in einer anderen Prozedur als letzte Aktion ausgeführt wird. es kann einen Rückgabewert erzeugen, der dann sofort von der aufrufenden Prozedur zurückgegeben wird
Im folgenden Code ist der Anruf an bar()
der Rückruf von foo()
:
private void foo() {
// do something
bar()
}
Die Optimierung von Tail-Aufrufen geschieht, wenn die Sprachimplementierung, die einen Tail-Aufruf sieht, keinen normalen Methodenaufruf verwendet (wodurch ein Stack-Frame erstellt wird), sondern stattdessen eine Verzweigung erstellt. Dies ist eine Optimierung, da ein Stack-Frame Speicher benötigt und CPU-Zyklen erforderlich sind, um Informationen (wie z. B. die Rücksprungadresse) auf den Frame zu übertragen, und da angenommen wird, dass das Aufruf / Rücksprung-Paar mehr CPU-Zyklen benötigt als ein unbedingter Sprung.
TCO wird oft auf Rekursion angewendet, aber das ist nicht seine einzige Verwendung. Es gilt auch nicht für alle Rekursionen. Der einfache rekursive Code zum Berechnen einer Fakultät kann beispielsweise nicht nachträglich optimiert werden, da das Letzte, was in der Funktion passiert, eine Multiplikationsoperation ist.
public static int fact(int n) {
if (n <= 1) return 1;
else return n * fact(n - 1);
}
Um die Tail-Call-Optimierung zu implementieren, benötigen Sie zwei Dinge:
- Eine Plattform, die Verzweigungen zusätzlich zu Subtroutinenaufrufen unterstützt.
- Ein statischer Analysator, mit dem festgestellt werden kann, ob eine Tail Call-Optimierung möglich ist.
Das ist es. Wie ich bereits an anderer Stelle bemerkt habe, hat die JVM (wie jede andere Turing-complete-Architektur) ein Goto. Es ist ein bedingungsloses goto-Objekt vorhanden , aber die Funktionalität kann leicht mithilfe eines bedingten Zweigs implementiert werden.
Die statische Analyse ist schwierig. Innerhalb einer einzelnen Funktion ist das kein Problem. Hier ist zum Beispiel eine rekursive Scala-Funktion, um die Werte in a zu summieren List
:
def sum(acc:Int, list:List[Int]) : Int = {
if (list.isEmpty) acc
else sum(acc + list.head, list.tail)
}
Diese Funktion wird in den folgenden Bytecode umgewandelt:
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
Beachten Sie die goto 0
am Ende. Im Vergleich dazu wird eine äquivalente Java-Funktion (die ein verwenden muss Iterator
, um das Verhalten des Aufteilens einer Scala-Liste in Kopf und Schwanz zu imitieren) in den folgenden Bytecode umgewandelt. Beachten Sie, dass die letzten beiden Operationen jetzt ein Aufruf sind , gefolgt von einer expliziten Rückgabe des durch diesen rekursiven Aufruf erzeugten Werts.
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
Die Optimierung von Tail-Aufrufen für eine einzelne Funktion ist trivial: Der Compiler kann feststellen, dass es keinen Code gibt, der das Ergebnis des Aufrufs verwendet, sodass er den Aufruf durch einen ersetzen kann goto
.
Wo das Leben schwierig wird, ist, wenn Sie mehrere Methoden haben. Die Verzweigungsanweisungen der JVM sind im Gegensatz zu denen eines Universalprozessors wie dem 80x86 auf eine einzige Methode beschränkt. Es ist immer noch relativ einfach, wenn Sie über private Methoden verfügen: Der Compiler kann diese Methoden nach Bedarf einbinden, um Endaufrufe zu optimierenswitch
, um das Verhalten zu steuern). Sie können diese Technik sogar auf mehrere öffentliche Methoden in derselben Klasse ausweiten: Der Compiler fügt die Methodentexte ein, stellt öffentliche Bridge-Methoden bereit und interne Aufrufe werden zu Sprüngen.
Dieses Modell bricht jedoch zusammen, wenn Sie öffentliche Methoden in verschiedenen Klassen berücksichtigen, insbesondere im Hinblick auf Schnittstellen und Klassenladeprogramme. Der Compiler auf Quellenebene verfügt einfach nicht über das erforderliche Wissen, um die Tail-Call-Optimierung zu implementieren. Im Gegensatz zu "Bare-Metal" -Implementierungen verfügt die JVM über die entsprechenden Informationen in Form des Hotspot-Compilers (zumindest der frühere Sun-Compiler). Ich weiß nicht, ob sie tatsächlich funktioniert Tail-Call-Optimierungen und vermuten nicht, aber es könnte .
Womit ich zum zweiten Teil Ihrer Frage komme, den ich als "sollten wir uns darum kümmern?" Umformulieren werde.
Wenn Ihre Sprache die Rekursion als einziges Grundelement für die Iteration verwendet, ist Ihnen das natürlich wichtig. Sprachen, die diese Funktion benötigen, können sie jedoch implementieren. Die einzige Frage ist, ob ein Compiler für diese Sprache eine Klasse erzeugen kann, die von einer beliebigen Java-Klasse aufgerufen und aufgerufen werden kann.
Außerhalb dieses Falles werde ich Ablehnungen einladen, indem ich sage, dass dies irrelevant ist. Der meiste rekursive Code, den ich gesehen habe (und mit vielen Grafikprojekten gearbeitet habe), ist nicht für Tail-Calls optimierbar . Wie die einfache Fakultät verwendet sie die Rekursion, um den Status zu erstellen, und die Tail-Operation ist eine Kombination.
Für Code, der per Tail-Call optimiert werden kann, ist es häufig unkompliziert, diesen Code in eine iterierbare Form zu übersetzen. Zum Beispiel kann diese sum()
Funktion, die ich zuvor gezeigt habe, verallgemeinert werden als foldLeft()
. Wenn Sie sich die Quelle ansehen , werden Sie feststellen, dass sie tatsächlich als iterative Operation implementiert ist. Jörg W. Mittag ließ ein Beispiel einer Zustandsmaschine über Funktionsaufrufe implementieren; Es gibt viele effiziente (und wartbare) Zustandsmaschinenimplementierungen, die nicht darauf angewiesen sind, dass Funktionsaufrufe in Sprünge übersetzt werden.
Ich werde mit etwas völlig anderem abschließen. Wenn Sie Ihren Weg von Fußnoten in der SICP googeln, könnten Sie hier landen . Ich persönlich finde das viel interessanter, als wenn mein Compiler durch ersetzt JSR
wird JUMP
.