Jedes Mal, wenn über eine neue Programmiersprache diskutiert wird, die auf die JVM abzielt, werden unvermeidlich Dinge gesagt wie:
"Die JVM unterstützt keine Tail-Call-Optimierung, daher sage ich viele explodierende Stacks voraus."
Es gibt Tausende von Variationen zu diesem Thema.
Jetzt weiß ich, dass einige Sprachen, wie zum Beispiel Clojure, ein spezielles wiederkehrendes Konstrukt haben, das Sie verwenden können.
Was ich nicht verstehe, ist: Wie ernst ist der Mangel an Tail-Call-Optimierung? Wann sollte ich mir darüber Sorgen machen?
Meine Hauptverwirrung rührt wahrscheinlich von der Tatsache her, dass Java eine der erfolgreichsten Sprachen aller Zeiten ist und einige der JVM-Sprachen ziemlich gut abschneiden. Wie ist das möglich , wenn der Mangel an TCO ist wirklich von jeder Sorge?
quelle
GOTO
die JVM nicht. Und x86 wird nicht als Interop-Plattform verwendet. Die JVM hat keineGOTO
und einer der Hauptgründe für die Wahl der Java-Plattform ist Interop. Wenn Sie auf der JVM TCO implementieren möchten, müssen Sie tun etwas in den Stapel. Verwalten Sie es selbst (dh verwenden Sie den JVM-Aufrufstapel überhaupt nicht), verwenden Sie Trampoline, verwenden Sie Ausnahmen alsGOTO
, so etwas. In all diesen Fällen werden Sie mit dem JVM-Aufrufstapel inkompatibel. Es ist unmöglich, mit Java Stack-kompatibel zu sein, TCO und hohe Leistung zu haben. Du musst einen dieser drei opfern.Antworten:
Nehmen wir an, wir haben alle Schleifen in Java beseitigt (die Compiler-Autoren streiken oder so). Jetzt wollen wir Fakultät schreiben, also können wir so etwas korrigieren
Jetzt fühlen wir uns ziemlich schlau, wir haben es geschafft, unsere Fakultät auch ohne Schleifen zu schreiben! Beim Testen stellen wir jedoch fest, dass bei einer angemessenen Anzahl Stapelüberlauffehler auftreten, da keine Gesamtbetriebskosten anfallen.
In echtem Java ist dies kein Problem. Wenn wir jemals einen rekursiven Algorithmus für den Schwanz haben, können wir ihn in eine Schleife umwandeln und alles in Ordnung sein. Was ist jedoch mit Sprachen ohne Schleifen? Dann bist du einfach abgespritzt. Das ist der Grund, warum Clojure diese
recur
Form hat, ohne sie ist sie nicht einmal vollständig (keine Möglichkeit, Endlosschleifen durchzuführen).Die Klasse der funktionalen Sprachen, die auf JVM, Frege, Kawa (Schema) und Clojure abzielen, versucht immer, mit dem Fehlen von Tail Calls umzugehen, da TC in diesen Sprachen die idiomatische Art ist, Schleifen zu erstellen! Wenn in Schema übersetzt, wäre diese Fakultät eine gute Fakultät. Es wäre furchtbar unpraktisch, wenn Ihr Programm durch eine 5000-fache Schleife abstürzen würde. Dies kann jedoch durch
recur
spezielle Formulare, Anmerkungen, die darauf hindeuten, dass Selbstanrufe optimiert werden, und Trampolinspringen, umgangen werden. Sie alle erzwingen entweder Leistungseinbußen oder unnötige Arbeit für den Programmierer.Jetzt kommt Java auch nicht frei davon, da es bei TCO mehr gibt als nur Rekursion. Was ist mit gegenseitig rekursiven Funktionen? Sie können nicht einfach in Schleifen übersetzt werden, werden jedoch von der JVM immer noch nicht optimiert. Dies macht es auf spektakuläre Weise unangenehm zu versuchen, Algorithmen mithilfe gegenseitiger Rekursion unter Verwendung von Java zu schreiben, da Sie, wenn Sie eine anständige Leistung / Reichweite wünschen, dunkle Magie anwenden müssen, damit sie in Schleifen passt.
Zusammenfassend ist dies für viele Fälle keine große Sache. Die meisten Tail-Aufrufe gehen entweder nur einen Stackframe tief, mit Dingen wie
oder sind Rekursion. Für die TC-Klasse, die nicht dazu passt, ist jedoch jede JVM-Sprache mit Schmerzen konfrontiert.
Es gibt jedoch einen guten Grund, warum wir noch keine Gesamtbetriebskosten haben. Die JVM gibt uns Stapelspuren. Mit TCO eliminieren wir systematisch Stackframes, von denen wir wissen, dass sie "zum Scheitern verurteilt" sind, aber die JVM möchte diese möglicherweise später für ein Stacktrace! Nehmen wir an, wir implementieren eine solche FSM, bei der jeder Staat den nächsten abruft. Wir würden alle Aufzeichnungen früherer Zustände löschen, damit ein Traceback uns zeigt, welcher Zustand vorliegt, aber nichts darüber, wie wir dorthin gekommen sind.
Darüber hinaus basiert ein Großteil der Bytecode-Verifizierung auf Stapeln, wodurch die Möglichkeit, Bytecode zu verifizieren, nicht unbedingt in Frage kommt. Abgesehen von der Tatsache, dass Java Schleifen aufweist, scheint die TCO für die JVM-Ingenieure etwas schwieriger zu sein, als sie es wert ist.
quelle
Die Optimierung von Schwanzaufrufen ist hauptsächlich wegen der Schwanzrekursion wichtig. Es gibt jedoch ein Argument, warum es eigentlich gut ist, dass die JVM keine Tail-Aufrufe optimiert: Da die TCO einen Teil des Stacks wiederverwendet, ist ein Stack-Trace einer Ausnahme unvollständig, wodurch das Debuggen etwas schwieriger wird.
Es gibt verschiedene Möglichkeiten, um die Einschränkungen der JVM zu umgehen:
Dies erfordert möglicherweise ein größeres Beispiel. Betrachten Sie eine Sprache mit Abschlüssen (z. B. JavaScript oder ähnliches). Wir können die Fakultät schreiben als
Jetzt können wir stattdessen einen Rückruf anfordern:
Dies funktioniert jetzt in einem konstanten Stapelbereich, was irgendwie albern ist, weil es sowieso rekursiv ist. Diese Technik ist jedoch in der Lage, alle Tail-Aufrufe in einen konstanten Stapelraum zu reduzieren. Befindet sich das Programm in CPS, bedeutet dies, dass der Callstack insgesamt konstant ist (in CPS ist jeder Aufruf ein Tail Call).
Ein großer Nachteil dieser Technik ist, dass es viel schwieriger zu debuggen, etwas schwieriger zu implementieren und weniger performant ist - sehen Sie alle Abschlüsse und Indirektionen, die ich verwende.
Aus diesen Gründen wäre es sehr zu bevorzugen, wenn die VM eine Tail-Call-Operation implementieren würde - Sprachen wie Java, die aus guten Gründen keine Tail-Calls unterstützen, müssten diese nicht verwenden.
quelle
return foo(....);
in Methodefoo
(2) verweisen . Wir akzeptieren jedoch unvollständige Ablaufverfolgungen von Schleifen, Zuweisungen (!) Und Anweisungsfolgen. Wenn Sie zum Beispiel einen unerwarteten Wert in einer Variablen finden, möchten Sie sicher wissen, wie er dort ankommt. Aber Sie beklagen sich nicht über fehlende Spuren in diesem Fall. Weil es irgendwie in unser Gehirn eingraviert ist, dass a) es nur bei Anrufen passiert, b) es bei allen Anrufen passiert. Beides ergibt keinen Sinn, IMHO.Ein wesentlicher Teil der Aufrufe in einem Programm sind Tail Calls. Jedes Unterprogramm hat einen letzten Aufruf, daher hat jedes Unterprogramm mindestens einen Endaufruf. Tail Calls haben die Leistungsmerkmale
GOTO
aber die Sicherheit eines Unterprogrammaufrufs.Mit Proper Tail Calls können Sie Programme schreiben, die Sie sonst nicht schreiben können. Nehmen Sie zum Beispiel eine Zustandsmaschine. Eine Zustandsmaschine kann sehr direkt implementiert werden, indem jeder Zustand ein Unterprogramm und jeder Zustandsübergang ein Unterprogrammaufruf ist. In diesem Fall wechseln Sie von Staat zu Staat, indem Sie Anruf nach Anruf nach Anruf tätigen, und Sie kehren tatsächlich nie zurück! Ohne die richtigen Tail Calls würden Sie den Stack sofort sprengen.
Ohne PTC, müssen Sie Verwendung
GOTO
oder Trampoline oder Ausnahmen als Kontrollfluss oder so ähnlich. Es ist viel hässlicher und nicht so sehr eine direkte 1: 1-Darstellung der Zustandsmaschine.(Beachten Sie, wie ich es geschickt vermieden habe, das langweilige "Schleifen" -Beispiel zu verwenden. Dies ist ein Beispiel, in dem PTCs auch in einer Sprache mit Schleifen nützlich sind .)
Ich habe hier bewusst den Begriff "Proper Tail Calls" anstelle von TCO verwendet. TCO ist eine Compileroptimierung. PTC ist eine Sprachfunktion, bei der jeder Compiler die TCO durchführen muss.
quelle
The vast majority of calls in a program are tail calls.
Nicht, wenn "die überwiegende Mehrheit" der aufgerufenen Methoden mehr als einen eigenen Aufruf ausführt.Every subroutine has a last call, so every subroutine has at least one tail call.
Dies ist trivialerweise nachweisbar als falsch:return a + b
. (Es sei denn, Sie sprechen eine verrückte Sprache, in der Grundrechenarten als Funktionsaufrufe definiert sind.)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 ):
Im folgenden Code ist der Anruf an
bar()
der Rückruf vonfoo()
: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.
Um die Tail-Call-Optimierung zu implementieren, benötigen Sie zwei Dinge:
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
:Diese Funktion wird in den folgenden Bytecode umgewandelt:
Beachten Sie die
goto 0
am Ende. Im Vergleich dazu wird eine äquivalente Java-Funktion (die ein verwenden mussIterator
, 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.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 optimieren
switch
, 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 alsfoldLeft()
. 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
wirdJUMP
.quelle
return foo(123);
diese besser durch Inlining ausgeführt werden kann,foo
als durch Generieren von Code, um den Stack zu manipulieren und einen Sprung auszuführen, aber ich verstehe nicht, warum sich der Tail-Call von einem normalen Call-In unterscheidet diesbezüglich.