Was ist Tail-Call-Optimierung?
Genauer gesagt, was sind einige kleine Codefragmente, wo sie angewendet werden könnten und wo nicht, mit einer Erklärung, warum?
Was ist Tail-Call-Optimierung?
Genauer gesagt, was sind einige kleine Codefragmente, wo sie angewendet werden könnten und wo nicht, mit einer Erklärung, warum?
Antworten:
Bei der Tail-Call-Optimierung können Sie vermeiden, einer Funktion einen neuen Stapelrahmen zuzuweisen, da die aufrufende Funktion einfach den Wert zurückgibt, den sie von der aufgerufenen Funktion erhält. Die häufigste Verwendung ist die Tail-Rekursion, bei der eine rekursive Funktion, die zur Nutzung der Tail-Call-Optimierung geschrieben wurde, konstanten Stapelspeicher verwenden kann.
Das Schema ist eine der wenigen Programmiersprachen, die in der Spezifikation garantieren, dass jede Implementierung diese Optimierung bereitstellen muss (JavaScript auch, beginnend mit ES6) . Hier sind zwei Beispiele für die Fakultätsfunktion im Schema:
Die erste Funktion ist nicht rekursiv, da die Funktion bei einem rekursiven Aufruf die Multiplikation verfolgen muss, die sie mit dem Ergebnis nach der Rückkehr des Aufrufs durchführen muss. Als solches sieht der Stapel wie folgt aus:
Im Gegensatz dazu sieht die Stapelverfolgung für die rekursive Fakultät des Schwanzes wie folgt aus:
Wie Sie sehen, müssen wir bei jedem Aufruf von fact-tail nur die gleiche Datenmenge nachverfolgen, da wir einfach den Wert, den wir erhalten, ganz nach oben zurückgeben. Dies bedeutet, dass ich selbst bei einem Anruf (Fakt 1000000) nur den gleichen Speicherplatz benötige wie (Fakt 3). Dies ist bei der nicht rekursiven Tatsache nicht der Fall, und als solche können große Werte einen Stapelüberlauf verursachen.
quelle
Lassen Sie uns ein einfaches Beispiel durchgehen: die in C implementierte Fakultätsfunktion.
Wir beginnen mit der offensichtlichen rekursiven Definition
Eine Funktion endet mit einem Tail-Aufruf, wenn die letzte Operation vor der Rückkehr der Funktion ein anderer Funktionsaufruf ist. Wenn dieser Aufruf dieselbe Funktion aufruft, ist er schwanzrekursiv.
Obwohl es
fac()
auf den ersten Blick schwanzrekursiv aussieht, ist es nicht so, wie es tatsächlich passiertdh die letzte Operation ist die Multiplikation und nicht der Funktionsaufruf.
Es ist jedoch möglich,
fac()
eine rekursive Umschreibung vorzunehmen, indem der akkumulierte Wert als zusätzliches Argument an die Aufrufkette übergeben und nur das Endergebnis erneut als Rückgabewert übergeben wird:Warum ist das nützlich? Da wir sofort nach dem Tail-Aufruf zurückkehren, können wir den vorherigen Stackframe verwerfen, bevor wir die Funktion in Tail-Position aufrufen, oder bei rekursiven Funktionen den Stackframe unverändert wiederverwenden.
Die Tail-Call-Optimierung wandelt unseren rekursiven Code in um
Dies kann eingefügt werden
fac()
und wir kommen zudas ist äquivalent zu
Wie wir hier sehen können, kann ein ausreichend fortschrittlicher Optimierer die Endrekursion durch Iteration ersetzen. Dies ist weitaus effizienter, da Sie den Aufwand für Funktionsaufrufe vermeiden und nur eine konstante Menge an Stapelspeicher verwenden.
quelle
TCO (Tail Call Optimization) ist der Prozess, mit dem ein Smart Compiler eine Funktion aufrufen und keinen zusätzlichen Stapelspeicherplatz beanspruchen kann. Die einzige Situation, in der dies geschieht, ist, wenn der letzte in einer Funktion f ausgeführte Befehl ein Aufruf einer Funktion g ist (Hinweis: g kann f sein ). Der Schlüssel hier ist, dass f keinen Stapelspeicher mehr benötigt - es ruft einfach g auf und gibt dann zurück, was auch immer g zurückgeben würde. In diesem Fall kann die Optimierung vorgenommen werden, dass g nur ausgeführt wird und den Wert, den es für das Objekt mit dem Namen f haben würde, zurückgibt.
Diese Optimierung kann dazu führen, dass rekursive Aufrufe einen konstanten Stapelspeicher beanspruchen und nicht explodieren.
Beispiel: Diese Fakultätsfunktion ist nicht TCOptimierbar:
Diese Funktion ruft nicht nur eine andere Funktion in ihrer return-Anweisung auf.
Diese Funktion ist TCOptimizable:
Dies liegt daran, dass das Letzte, was in einer dieser Funktionen passiert, darin besteht, eine andere Funktion aufzurufen.
quelle
(cons a (foo b))
oder die Heckposition einnehmen würden(+ c (bar d))
.Die wahrscheinlich beste Beschreibung auf hoher Ebene, die ich für Tail Calls, rekursive Tail Calls und Tail Call-Optimierung gefunden habe, ist der Blog-Beitrag
"Was zum Teufel ist: Ein Schwanzruf"
von Dan Sugalski. Zur Tail-Call-Optimierung schreibt er:
Und auf Schwanzrekursion:
Damit das:
wird leise verwandelt in:
Was mir an dieser Beschreibung gefällt, ist, wie prägnant und einfach es für diejenigen ist, die einen imperativen Sprachhintergrund haben (C, C ++, Java).
quelle
foo
Funktionsendaufruf nicht optimiert? Es ruft nur eine Funktion als letzten Schritt auf und gibt einfach diesen Wert zurück, oder?Beachten Sie zunächst, dass dies nicht in allen Sprachen unterstützt wird.
TCO gilt für einen Sonderfall der Rekursion. Der Kern davon ist, dass, wenn das letzte, was Sie in einer Funktion tun, der Aufruf selbst ist (z. B. der Aufruf von der "End" -Position), dies vom Compiler so optimiert werden kann, dass er sich wie eine Iteration anstelle einer Standardrekursion verhält.
Normalerweise muss die Laufzeit während der Rekursion alle rekursiven Aufrufe verfolgen, damit sie bei der Rückkehr beim vorherigen Aufruf fortgesetzt werden kann und so weiter. (Versuchen Sie, das Ergebnis eines rekursiven Aufrufs manuell aufzuschreiben, um eine visuelle Vorstellung davon zu erhalten, wie dies funktioniert.) Das Verfolgen aller Aufrufe nimmt Speicherplatz in Anspruch, der erheblich wird, wenn sich die Funktion häufig selbst aufruft. Aber mit TCO kann es einfach sagen: "Gehen Sie zurück zum Anfang, nur dieses Mal ändern Sie die Parameterwerte in diese neuen." Dies ist möglich, da sich nach dem rekursiven Aufruf nichts auf diese Werte bezieht.
quelle
foo
Methoden-Tail-Aufruf nicht optimiert?Beispiel für ein minimal lauffähiges GCC mit x86-Demontageanalyse
Lassen Sie uns anhand der generierten Assembly sehen, wie GCC automatisch Tail-Call-Optimierungen für uns durchführen kann.
Dies ist ein äußerst konkretes Beispiel dafür, was in anderen Antworten wie https://stackoverflow.com/a/9814654/895245 erwähnt wurde, dass die Optimierung rekursive Funktionsaufrufe in eine Schleife konvertieren kann.
Dies spart wiederum Speicher und verbessert die Leistung, da Speicherzugriffe heutzutage häufig die Hauptsache sind, die Programme verlangsamt .
Als Eingabe geben wir GCC eine nicht optimierte naive stapelbasierte Fakultät:
tail_call.c
GitHub stromaufwärts .
Kompilieren und disassemblieren:
Wo
-foptimize-sibling-calls
ist der Name der Verallgemeinerung von Tail Calls nachman gcc
:wie unter: Wie überprüfe ich, ob gcc eine Tail-Rekursionsoptimierung durchführt?
Ich wähle
-O1
weil:-O0
. Ich vermute, dass dies daran liegt, dass erforderliche Zwischentransformationen fehlen.-O3
erzeugt gottlos effizienten Code, der nicht sehr lehrreich wäre, obwohl er auch für Tail Call optimiert ist.Demontage mit
-fno-optimize-sibling-calls
:Mit
-foptimize-sibling-calls
:Der Hauptunterschied zwischen den beiden ist:
die
-fno-optimize-sibling-calls
Verwendungencallq
, die der typische nicht optimierte Funktionsaufruf ist.Dieser Befehl verschiebt die Rücksprungadresse an den Stapel und erhöht sie daher.
Darüber hinaus tut dies auch diese Version
push %rbx
, die auf den Stapel schiebt%rbx
.GCC tut dies, weil es speichert
edi
, was das erste Funktionsargument (n
) istebx
, und dann aufruftfactorial
.GCC muss dies tun, da es sich auf einen weiteren Anruf bei vorbereitet
factorial
, bei dem das neue verwendet wirdedi == n-1
.Es wird ausgewählt,
ebx
weil dieses Register als Angerufene gespeichert ist: Welche Register werden durch einen Linux x86-64-Funktionsaufruf beibehalten, damit der Unteraufruffactorial
es nicht ändert und verliertn
.Das
-foptimize-sibling-calls
verwendet keine Anweisungen, die auf den Stapel verschoben werden: Esgoto
springt nurfactorial
mit den Anweisungenje
undjne
.Daher entspricht diese Version einer while-Schleife ohne Funktionsaufrufe. Die Stapelnutzung ist konstant.
Getestet in Ubuntu 18.10, GCC 8.2.
quelle
Schau hier:
http://tratt.net/laurie/tech_articles/articles/tail_call_optimization
Wie Sie wahrscheinlich wissen, können rekursive Funktionsaufrufe einen Stapel zerstören. Es ist leicht, schnell keinen Stapelplatz mehr zu haben. Die Tail-Call-Optimierung ist eine Methode, mit der Sie einen rekursiven Stilalgorithmus erstellen können, der konstanten Stapelspeicher verwendet. Daher wächst er nicht und wächst und es treten Stapelfehler auf.
quelle
Wir sollten sicherstellen, dass die Funktion selbst keine goto-Anweisungen enthält. Der Funktionsaufruf ist das Letzte in der Angerufenenfunktion.
Rekursionen in großem Maßstab können dies für Optimierungen verwenden, aber in kleinem Maßstab verringert der Anweisungsaufwand, um den Funktionsaufruf zu einem Endaufruf zu machen, den tatsächlichen Zweck.
TCO kann eine für immer laufende Funktion verursachen:
quelle
Der Ansatz der rekursiven Funktion hat ein Problem. Es baut einen Aufrufstapel der Größe O (n) auf, wodurch unser Gesamtspeicher O (n) kostet. Dies macht es anfällig für einen Stapelüberlauffehler, bei dem der Aufrufstapel zu groß wird und nicht mehr genügend Speicherplatz zur Verfügung steht.
TCO-Schema (Tail Call Optimization). Wo es rekursive Funktionen optimieren kann, um den Aufbau eines hohen Aufrufstapels zu vermeiden und somit die Speicherkosten zu sparen.
Es gibt viele Sprachen, die TCO-ähnliche Aufgaben ausführen (JavaScript, Ruby und wenige C), während Python und Java keine TCO-Vorgänge ausführen.
Die JavaScript-Sprache wurde mit :) http://2ality.com/2015/06/tail-call-optimization.html bestätigt
quelle
In einer funktionalen Sprache ist die Endaufrufoptimierung so, als ob ein Funktionsaufruf als Ergebnis einen teilweise ausgewerteten Ausdruck zurückgeben könnte, der dann vom Aufrufer ausgewertet würde.
f 6 reduziert sich auf g 6. Wenn die Implementierung also g 6 als Ergebnis zurückgeben und diesen Ausdruck dann aufrufen könnte, würde ein Stapelrahmen gespeichert.
Ebenfalls
Reduziert sich auf f 6 auf entweder g 6 oder h 6. Wenn die Implementierung also c 6 auswertet und feststellt, dass es wahr ist, kann sie reduzieren,
Ein einfacher Nicht-Tail-Call-Optimierungsinterpreter könnte folgendermaßen aussehen:
Ein Interpreter für die Tail-Call-Optimierung könnte folgendermaßen aussehen:
quelle