it-swarm.com.de

Warum funktionieren Trampoline?

Ich habe funktionales JavaScript gemacht. Ich hatte gedacht, dass Tail-Call-Optimierung implementiert wurde, aber wie sich herausstellte, habe ich mich geirrt. Daher musste ich mir selbst beibringen Trampolin . Nachdem ich hier und anderswo ein bisschen gelesen hatte, konnte ich die Grundlagen vermitteln und mein erstes Trampolin bauen:

/*not the fanciest, it's just meant to
reenforce that I know what I'm doing.*/

function loopy(x){
    if (x<10000000){ 
        return function(){
            return loopy(x+1)
        }
    }else{
        return x;
    }
};

function trampoline(foo){
    while(foo && typeof foo === 'function'){
        foo = foo();
    }
    return foo;
/*I've seen trampolines without this,
mine wouldn't return anything unless
I had it though. Just goes to show I
only half know what I'm doing.*/
};

alert(trampoline(loopy(0)));

Mein größtes Problem ist, dass ich nicht weiß, warum das funktioniert. Ich habe die Idee, die Funktion in einer while-Schleife erneut auszuführen, anstatt eine rekursive Schleife zu verwenden. Technisch gesehen hat meine Basisfunktion jedoch bereits eine rekursive Schleife. Ich führe nicht die Basisfunktion loopy aus, sondern die darin enthaltene Funktion. Was hindert foo = foo() daran, einen Stapelüberlauf zu verursachen? Und mutiert foo = foo() nicht technisch oder fehlt mir etwas? Vielleicht ist es nur ein notwendiges Übel. Oder eine Syntax, die mir fehlt.

Gibt es überhaupt eine Möglichkeit, das zu verstehen? Oder ist es nur ein Hack, der irgendwie funktioniert? Ich habe es geschafft, mich durch alles andere zu kämpfen, aber dieser hat mich verwirrt.

105
Ucenna

Der Grund, warum Ihr Gehirn gegen die Funktion loopy() rebelliert, ist, dass es von einem inkonsistenten Typ ist:

function loopy(x){
    if (x<10000000){ 
        return function(){ // On this line it returns a function...
            // (This is not part of loopy(), this is the function we are returning.)
            return loopy(x+1)
        }
    }else{
        return x; // ...but on this line it returns an integer!
    }
};

In vielen Sprachen können Sie solche Dinge nicht einmal tun oder zumindest viel mehr tippen, um zu erklären, wie dies sinnvoll sein soll. Weil es wirklich nicht so ist. Funktionen und ganze Zahlen sind völlig verschiedene Arten von Objekten.

Lassen Sie uns diese while-Schleife also sorgfältig durchgehen:

while(foo && typeof foo === 'function'){
    foo = foo();
}

Anfangs ist foo gleich loopy(0). Was ist loopy(0)? Nun, es ist weniger als 10000000, also erhalten wir function(){return loopy(1)}. Das ist ein wahrer Wert und eine Funktion, also läuft die Schleife weiter.

Nun kommen wir zu foo = foo(). foo() ist dasselbe wie loopy(1). Da 1 immer noch kleiner als 10000000 ist, wird function(){return loopy(2)} zurückgegeben, das wir dann foo zuweisen.

foo ist immer noch eine Funktion, also machen wir weiter ... bis schließlich foo gleich function(){return loopy(10000000)} ist. Das ist eine Funktion, also machen wir foo = foo() noch einmal, aber dieses Mal, wenn wir loopy(10000000) aufrufen, ist x nicht kleiner als 10000000, also bekommen wir einfach x zurück. Da 10000000 ebenfalls keine Funktion ist, wird auch die while-Schleife beendet.

89
Kevin

Kevin weist kurz und bündig darauf hin, wie dieses spezielle Code-Snippet funktioniert (und warum es ziemlich unverständlich ist), aber ich wollte einige Informationen darüber hinzufügen, wie Trampoline im Allgemeinen funktionieren .

Ohne Tail-Call-Optimierung (TCO) fügt jeder Funktionsaufruf dem aktuellen Ausführungsstapel einen Stapelrahmen hinzu. Angenommen, wir haben eine Funktion zum Ausdrucken eines Countdowns von Zahlen:

function countdown(n) {
  if (n === 0) {
    console.log("Blastoff!");
  } else {
    console.log("Launch in " + n);
    countdown(n - 1);
  }
}

Wenn wir countdown(3) aufrufen, analysieren wir, wie der Aufrufstapel ohne TCO aussehen würde.

> countdown(3);
// stack: countdown(3)
Launch in 3
// stack: countdown(3), countdown(2)
Launch in 2
// stack: countdown(3), countdown(2), countdown(1)
Launch in 1
// stack: countdown(3), countdown(2), countdown(1), countdown(0)
Blastoff!
// returns, stack: countdown(3), countdown(2), countdown(1)
// returns, stack: countdown(3), countdown(2)
// returns, stack: countdown(3)
// returns, stack is empty

Mit TCO befindet sich jeder rekursive Aufruf von countdown in der Endposition (es bleibt nichts anderes zu tun, als das Ergebnis des Aufrufs zurückzugeben). Es wird also kein Stapelrahmen zugewiesen. Ohne TCO sprengt der Stapel sogar für leicht große n.

Trampolining umgeht diese Einschränkung, indem ein Wrapper um die Funktion countdown eingefügt wird. Dann führt countdown keine rekursiven Aufrufe durch und gibt stattdessen sofort eine aufzurufende Funktion zurück. Hier ist eine Beispielimplementierung:

function trampoline(firstHop) {
  nextHop = firstHop();
  while (nextHop) {
    nextHop = nextHop()
  }
}

function countdown(n) {
  trampoline(() => countdownHop(n));
}

function countdownHop(n) {
  if (n === 0) {
    console.log("Blastoff!");
  } else {
    console.log("Launch in " + n);
    return () => countdownHop(n-1);
  }
}

Schauen wir uns den Aufrufstapel an, um ein besseres Gefühl dafür zu bekommen, wie dies funktioniert:

> countdown(3);
// stack: countdown(3)
// stack: countdown(3), trampoline
// stack: countdown(3), trampoline, countdownHop(3)
Launch in 3
// return next hop from countdownHop(3)
// stack: countdown(3), trampoline
// trampoline sees hop returned another hop function, calls it
// stack: countdown(3), trampoline, countdownHop(2)
Launch in 2
// stack: countdown(3), trampoline
// stack: countdown(3), trampoline, countdownHop(1)
Launch in 1
// stack: countdown(3), trampoline
// stack: countdown(3), trampoline, countdownHop(0)
Blastoff!
// stack: countdown(3), trampoline
// stack: countdown(3)
// stack is empty

Bei jedem Schritt gibt die Funktion countdownHop die direkte Kontrolle über auf, was als nächstes passiert, und gibt stattdessen eine Funktion zum Aufrufen zurück, die beschreibt, was sie tun würde wie als nächstes passieren. Die Trampolinfunktion nimmt dies und ruft es auf, ruft dann die Funktion auf, die zurückgibt, und so weiter, bis es keinen "nächsten Schritt" mehr gibt. Dies wird als Trampolin bezeichnet, da der Kontrollfluss zwischen jedem rekursiven Aufruf und der Trampolinimplementierung "springt", anstatt dass die Funktion direkt wiederkehrt. Durch Aufgeben der Kontrolle darüber, wer den rekursiven Aufruf ausführt, kann die Trampolinfunktion sicherstellen, dass der Stapel nicht zu groß wird. Randnotiz: Bei dieser Implementierung von trampoline werden der Einfachheit halber keine Rückgabewerte angegeben.

Es kann schwierig sein zu wissen, ob dies eine gute Idee ist. Die Leistung kann durch jeden Schritt, bei dem ein neuer Verschluss zugewiesen wird, beeinträchtigt werden. Clevere Optimierungen können dies möglich machen, aber Sie wissen es nie. Trampolin ist meistens nützlich, um harte Rekursionsgrenzen zu umgehen, beispielsweise wenn eine Sprachimplementierung eine maximale Aufrufstapelgröße festlegt.

173
Jack

Vielleicht wird es einfacher zu verstehen, ob das Trampolin mit einem dedizierten Rückgabetyp implementiert ist (anstatt eine Funktion zu missbrauchen):

class Result {}
// poor man's case classes
class Recurse extends Result {
    constructor(a) { this.arg = a; }
}
class Return extends Result {
    constructor(v) { this.value = v; }
}

function loopy(x) {
    if (x<10000000)
        return new Recurse(x+1);
    else
        return new Return(x);
}

function trampoline(fn, x) {
    while (true) {
        const res = fn(x);
        if (res instanceof Recurse)
            x = res.arg;
        else if (res instanceof Return)
            return res.value;
    }
}

alert(trampoline(loopy, 0));

Vergleichen Sie dies mit Ihrer Version von trampoline, bei der der Rekursionsfall ist, wenn die Funktion eine andere Funktion zurückgibt, und der Basisfall, wenn sie etwas anderes zurückgibt.

Was hindert foo = foo() daran, einen Stapelüberlauf zu verursachen?

Es nennt sich nicht mehr. Stattdessen wird ein Ergebnis zurückgegeben (in meiner Implementierung buchstäblich ein Result), das angibt, ob die Rekursion fortgesetzt oder ausgebrochen werden soll.

Und mutiert foo = foo() nicht technisch oder fehlt mir etwas? Vielleicht ist es nur ein notwendiges Übel.

Ja, das ist genau das notwendige Übel der Schleife. Man könnte trampoline auch ohne Mutation schreiben, aber es würde wieder eine Rekursion erfordern:

function trampoline(fn, x) {
    const res = fn(x);
    if (res instanceof Recurse)
        return trampoline(fn, res.arg);
    else if (res instanceof Return)
        return res.value;
}

Trotzdem zeigt es die Idee, was die Trampolinfunktion noch besser macht.

Der Punkt des Trampolens ist abstrahieren der rekursive Aufruf der Funktion, die die Rekursion in einen Rückgabewert verwenden möchte, und die eigentliche Rekursion an nur einer Stelle - der Funktion trampoline , die dann an einer einzigen Stelle optimiert werden können, um eine Schleife zu verwenden.

18
Bergi