it-swarm.com.de

Was ist Tail Call-Optimierung?

Ganz einfach, was ist Tail-Call-Optimierung? Kann jemand ein paar kleine Code-Schnipsel zeigen, wo sie angewendet werden könnten, und wo nicht, mit einer Erklärung, warum?

731
majelbstoat

Bei der Tail-Call-Optimierung können Sie die Zuweisung eines neuen Stack-Frames für eine Funktion vermeiden, da die aufrufende Funktion einfach den Wert zurückgibt, den sie von der aufgerufenen Funktion erhält. Die häufigste Verwendung ist die Schwanzrekursion, bei der eine rekursive Funktion, die zur Optimierung des Schwanzaufrufs geschrieben wurde, einen konstanten Stapelspeicherplatz verwenden kann.

Schema ist eine der wenigen Programmiersprachen, die in der Spezifikation garantieren, dass jede Implementierung diese Optimierung bieten muss (JavaScript auch, beginnend mit ES6) , also hier sind zwei Beispiele für die Fakultätsfunktion in Schema:

(define (fact x)
  (if (= x 0) 1
      (* x (fact (- x 1)))))

(define (fact x)
  (define (fact-tail x accum)
    (if (= x 0) accum
        (fact-tail (- x 1) (* x accum))))
  (fact-tail x 1))

Die erste Funktion ist nicht rekursiv, da die Funktion bei einem rekursiven Aufruf die Multiplikation verfolgen muss, die mit dem Ergebnis nach der Rückkehr des Aufrufs zu tun hat. Als solches sieht der Stapel folgendermaßen aus:

(fact 3)
(* 3 (fact 2))
(* 3 (* 2 (fact 1)))
(* 3 (* 2 (* 1 (fact 0))))
(* 3 (* 2 (* 1 1)))
(* 3 (* 2 1))
(* 3 2)
6

Im Gegensatz dazu sieht die Stapelablaufverfolgung für die rekursive Fakultät des Endes wie folgt aus:

(fact 3)
(fact-tail 3 1)
(fact-tail 2 3)
(fact-tail 1 6)
(fact-tail 0 6)
6

Wie Sie sehen, müssen wir bei jedem Aufruf von fact-tail nur die gleiche Datenmenge nachverfolgen, da wir einfach den Wert zurückgeben, den wir durchlaufen, bis wir ganz oben angekommen sind. Dies bedeutet, dass ich, selbst wenn ich anrufen würde (Fakt 1000000), nur die gleiche Menge an Speicherplatz benötige wie (Fakt 3). Dies ist nicht der Fall bei der nicht rekursiven Tatsache, und daher können große Werte einen Stapelüberlauf verursachen.

686
Kyle Cronin

Lassen Sie uns ein einfaches Beispiel durchgehen: die in C implementierte Fakultätsfunktion.

Wir beginnen mit der offensichtlichen rekursiven Definition

unsigned fac(unsigned n)
{
    if (n < 2) return 1;
    return n * fac(n - 1);
}

Eine Funktion endet mit einem Tail-Aufruf, wenn die letzte Operation vor der Rückkehr der Funktion ein weiterer Funktionsaufruf ist. Wenn dieser Aufruf dieselbe Funktion aufruft, ist er rekursiv.

Obwohl fac() auf den ersten Blick schwanzrekursiv aussieht, ist es nicht so, wie es tatsächlich passiert

unsigned fac(unsigned n)
{
    if (n < 2) return 1;
    unsigned acc = fac(n - 1);
    return n * acc;
}

dh die letzte Operation ist die Multiplikation und nicht der Funktionsaufruf.

Es ist jedoch möglich, fac() umzuschreiben, um rekursiv zu sein, indem der akkumulierte Wert in der Aufrufkette als zusätzliches Argument übergeben wird und nur das Endergebnis erneut als Rückgabewert übergeben wird:

unsigned fac(unsigned n)
{
    return fac_tailrec(1, n);
}

unsigned fac_tailrec(unsigned acc, unsigned n)
{
    if (n < 2) return acc;
    return fac_tailrec(n * acc, n - 1);
}

Warum ist das nützlich? Da wir unmittelbar nach dem Tail-Aufruf zurückkehren, können wir den vorherigen Stackframe verwerfen, bevor wir die Funktion in der Tail-Position aufrufen, oder bei rekursiven Funktionen den Stackframe wie er ist wiederverwenden.

Die Tail-Call-Optimierung wandelt unseren rekursiven Code in um

unsigned fac_tailrec(unsigned acc, unsigned n)
{
TOP:
    if (n < 2) return acc;
    acc = n * acc;
    n = n - 1;
    goto TOP;
}

Dies kann in fac() eingefügt werden und wir kommen zu

unsigned fac(unsigned n)
{
    unsigned acc = 1;

TOP:
    if (n < 2) return acc;
    acc = n * acc;
    n = n - 1;
    goto TOP;
}

das ist äquivalent zu

unsigned fac(unsigned n)
{
    unsigned acc = 1;

    for (; n > 1; --n)
        acc *= n;

    return acc;
}

Wie wir hier sehen können, kann ein ausreichend fortschrittliches Optimierungsprogramm die Endrekursion durch Iteration ersetzen, was weitaus effizienter ist, da Sie den Funktionsaufruf-Overhead vermeiden und nur eine konstante Menge an Stapelspeicherplatz verwenden.

517
Christoph

TCO (Tail Call Optimization) ist der Prozess, mit dem ein intelligenter Compiler eine Funktion aufrufen kann und keinen zusätzlichen Stapelspeicher benötigt. Die einzige Situation, in der dies passiert, ist, wenn der letzte Befehl, der in einer Funktion ausgeführt wird, f ein Aufruf einer Funktion ist g (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 einfach läuft und den Wert zurückgibt, den es für das Ding mit dem Namen f haben würde.

Diese Optimierung kann dazu führen, dass rekursive Aufrufe nicht explodieren, sondern einen konstanten Stapelspeicher beanspruchen.

Beispiel: Diese Fakultätsfunktion ist nicht TCOptimierbar:

def fact(n):
    if n == 0:
        return 1
    return n * fact(n-1)

Diese Funktion ruft in ihrer return-Anweisung eine andere Funktion auf.

Diese unten stehende Funktion ist TCOptimizable:

def fact_h(n, acc):
    if n == 0:
        return acc
    return fact_h(n-1, acc*n)

def fact(n):
    return fact_h(n, 1)

Dies liegt daran, dass in einer dieser Funktionen als letztes eine andere Funktion aufgerufen wird.

180
Claudiu

Die wahrscheinlich beste Beschreibung, 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:

Betrachten Sie für einen Moment diese einfache Funktion:

sub foo (int a) {
  a += 15;
  return bar(a);
}

Was können Sie bzw. Ihr Sprachcompiler also tun? Nun, was es tun kann, ist Code der Form return somefunc(); in die Low-Level-Sequenz pop stack frame; goto somefunc(); umzuwandeln. In unserem Beispiel heißt das, bevor wir bar aufrufen, bereinigt sich foo und statt bar als Unterroutine aufzurufen, führen wir ein Low-Level-goto Operation zum Start von bar. Foo hat sich bereits aus dem Stapel entfernt. Wenn also bar gestartet wird, sieht es so aus, als hätte derjenige, der foo angerufen hat, wirklich bar angerufen, und wenn bar gibt seinen Wert zurück, er gibt ihn direkt an denjenigen zurück, der foo aufgerufen hat, anstatt ihn an foo zurückzugeben, der ihn dann an seinen Aufrufer zurückgeben würde.

Und zur Schwanzrekursion:

Die Schwanzrekursion tritt auf, wenn eine Funktion als letzte Operation das Ergebnis des Aufrufs selbst zurückgibt. Die Schwanzrekursion ist einfacher zu handhaben, da Sie nicht irgendwo an den Anfang einer zufälligen Funktion springen müssen, sondern nur zum Anfang Ihrer selbst zurückkehren müssen, was eine verdammt einfache Sache ist.

Damit dies:

sub foo (int a, int b) {
  if (b == 1) {
    return a;
  } else {
    return foo(a*a + a, b - 1);
  }

wird leise verwandelt in:

sub foo (int a, int b) {
  label:
    if (b == 1) {
      return a;
    } else {
      a = a*a + a;
      b = b - 1;
      goto label;
   }

Was ich an dieser Beschreibung mag, ist, wie kurz und einfach sie für diejenigen ist, die aus einem imperativen Sprachhintergrund stammen (C, C++, Java).

58
btiernay

Beachten Sie zunächst, dass nicht alle Sprachen dies unterstützen.

TCO gilt für einen speziellen Rekursionsfall. Das Wesentliche dabei ist, dass, wenn Sie als letztes in einer Funktion sich selbst aufrufen (z. B. sich selbst von der Endposition aus aufrufen), dies vom Compiler so optimiert werden kann, dass es sich wie eine Iteration anstelle einer Standardrekursion verhält.

Normalerweise muss die Laufzeit während der Rekursion alle rekursiven Aufrufe nachverfolgen, damit sie bei einer Rückkehr beim vorherigen Aufruf fortgesetzt werden kann und so weiter. (Versuchen Sie, das Ergebnis eines rekursiven Aufrufs manuell zu schreiben, um eine visuelle Vorstellung davon zu erhalten, wie dies funktioniert.) Das Verfolgen aller Aufrufe nimmt Platz in Anspruch, was von Bedeutung ist, wenn die Funktion sich häufig selbst aufruft. Bei TCO kann jedoch nur "Zurück zum Anfang, nur dieses Mal ändern Sie die Parameterwerte in diese neuen." Dies ist möglich, da nach dem rekursiven Aufruf nichts auf diese Werte verweist.

13
J Cooper

Schau hier:

http://tratt.net/laurie/tech_articles/articles/tail_call_optimization

Wie Sie wahrscheinlich wissen, können rekursive Funktionsaufrufe Verwüstungen auf einem Stapel anrichten. Es ist einfach, schnell keinen Stapelplatz mehr zu haben. Bei der Optimierung von Tail-Aufrufen können Sie einen rekursiven Stilalgorithmus erstellen, der konstanten Stapelspeicherplatz verwendet. Daher wächst dieser nicht ständig und es treten Stapelfehler auf.

6
BobbyShaftoe
  1. Wir sollten sicherstellen, dass die Funktion selbst keine goto-Anweisungen enthält.

  2. Rekursionen in großem Maßstab können dies für Optimierungen verwenden, aber in kleinem Maßstab verringert der Anweisungsaufwand für das Umsetzen des Funktionsaufrufs in einen Tail-Aufruf den tatsächlichen Zweck.

  3. TCO kann eine ewig laufende Funktion verursachen:

    void eternity()
    {
        eternity();
    }
    
4
grillSandwich

Der rekursive Funktionsansatz weist ein Problem auf. 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 Cost Optimization). Hier können rekursive Funktionen optimiert werden, um den Aufbau eines hohen Aufrufstapels zu vermeiden und damit die Speicherkosten zu senken.

Es gibt viele Sprachen, die TCO ausführen wie (Javascript, Ruby und einige C), wobei Python und Java) tun TCO nicht tun.

Die JavaScript-Sprache wurde mit :) http://2ality.com/2015/06/tail-call-optimization.html bestätigt

3