Perché funzionano i trampolini?


104

Ho fatto un po 'di JavaScript funzionale. Avevo pensato che l' ottimizzazione Tail-Call fosse stata implementata, ma a quanto pare ho sbagliato. Quindi, ho dovuto insegnare a me stesso il trampolino . Dopo aver letto un po 'qui e altrove, sono stato in grado di abbattere le basi e costruito il mio primo trampolino:

/*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)));

Il mio problema più grande è che non so perché funzioni. Mi viene in mente di ripetere la funzione in un ciclo while anziché utilizzare un ciclo ricorsivo. Tranne, tecnicamente la mia funzione di base ha già un ciclo ricorsivo. Non eseguo la loopyfunzione di base , ma sto eseguendo la funzione al suo interno. Che cosa impedisce foo = foo()di causare un overflow dello stack? E foo = foo()tecnicamente non sta mutando, o mi sto perdendo qualcosa? Forse è solo un male necessario. O qualche sintassi che mi manca.

C'è anche un modo per capirlo? O è solo qualche hack che in qualche modo funziona? Sono stato in grado di farmi strada attraverso tutto il resto, ma questo mi ha sconcertato.


5
Sì, ma questa è ancora la ricorsione. loopynon trabocca perché non si chiama da solo .
tkausl,

4
"Avevo pensato che il TCO fosse stato implementato, ma a quanto pare ho sbagliato". È stato almeno nel V8 nella maggior parte degli scenari. Puoi usarlo per esempio in qualsiasi versione recente di Node dicendo a Node di abilitarlo in V8: stackoverflow.com/a/30369729/157247 Chrome ce l'ha (dietro un flag "sperimentale") da Chrome 51.
TJ Crowder,

125
L'energia cinetica dell'utente viene trasformata in energia potenziale elastica quando il trampolino si affievolisce, quindi torna all'energia cinetica mentre si rimbalza.
user253751,

66
@immibis, a nome di tutti coloro che sono venuti qui senza controllare quale sito Stack Exchange fosse, grazie.
user1717828,

4
@jpaugh intendevi "saltare"? ;-)
Hulk,

Risposte:


89

Il motivo per cui il tuo cervello si ribella alla funzione loopy()è che è di tipo incoerente :

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!
    }
};

Molte lingue non ti consentono nemmeno di fare cose del genere, o almeno richiedono molta più digitazione per spiegare come questo dovrebbe avere un senso. Perché non lo fa davvero. Funzioni e numeri interi sono tipi di oggetti totalmente diversi.

Quindi esaminiamo il ciclo while, attentamente:

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

Inizialmente, fooè uguale a loopy(0). Che cosa è loopy(0)? Bene, è inferiore a 10000000, quindi otteniamo function(){return loopy(1)}. È un valore sincero ed è una funzione, quindi il ciclo continua.

Adesso arriviamo a foo = foo(). foo()è lo stesso di loopy(1). Poiché 1 è ancora inferiore a 10000000, questo restituisce function(){return loopy(2)}, a cui quindi assegniamo foo.

fooè ancora una funzione, quindi continuiamo ... fino a quando alla fine il foo è uguale function(){return loopy(10000000)}. Questa è una funzione, quindi lo facciamo ancora foo = foo()una volta, ma questa volta, quando chiamiamo loopy(10000000), x non è inferiore a 10000000, quindi torniamo indietro x. Poiché anche 10000000 non è una funzione, anche questo termina il ciclo while.


1
I commenti non sono per una discussione estesa; questa conversazione è stata spostata in chat .
yannis,

È davvero solo un tipo di somma. A volte noto come una variante. I linguaggi dinamici li supportano piuttosto facilmente perché ogni valore è taggato, mentre linguaggi più tipicamente statici richiedono di specificare che la funzione restituisce una variante. I trampolini sono facilmente possibili in C ++ o Haskell, per esempio.
GManNickG,

2
@GManNickG: Sì, questo è ciò che intendevo per "scrivere molto di più". In C dovresti dichiarare un'unione, dichiarare una struttura che tagga l'unione, impacchettare e spacchettare la struttura alle due estremità, impacchettare e spacchettare l'unione a entrambe le estremità e (probabilmente) capire chi possiede la memoria in cui la struttura abita . C ++ è molto probabilmente meno codice di quello, ma concettualmente non è meno complicato di C, ed è ancora più dettagliato del Javascript di OP.
Kevin,

Certo, non lo sto contestando, penso solo che l'enfasi che metti sul fatto che sia strano o non abbia senso sia un po 'forte. :)
GManNickG,

173

Kevin sottolinea brevemente come funziona questo particolare frammento di codice (insieme al motivo per cui è abbastanza incomprensibile), ma volevo aggiungere alcune informazioni su come funzionano i trampolini in generale .

Senza l'ottimizzazione del tail-call (TCO), ogni chiamata di funzione aggiunge un frame di stack allo stack di esecuzione corrente. Supponiamo di avere una funzione per stampare un conto alla rovescia di numeri:

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

Se chiamiamo countdown(3), analizziamo come apparirebbe lo stack di chiamate senza TCO.

> 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

Con il TCO, ogni chiamata ricorsiva verso countdownè nella posizione di coda (non c'è altro da fare che restituire il risultato della chiamata), quindi non viene assegnato alcun frame di stack. Senza TCO, lo stack esplode per anche leggermente più grande n.

Il trampolino elimina questa limitazione inserendo un wrapper attorno alla countdownfunzione. Quindi, countdownnon esegue chiamate ricorsive e restituisce immediatamente una funzione da chiamare. Ecco un esempio di implementazione:

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);
  }
}

Per capire meglio come funziona, diamo un'occhiata allo stack di chiamate:

> 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

Ad ogni passo la countdownHopfunzione abbandona controllo diretto di ciò che accade dopo, invece tornando una funzione per chiamare che descrive quello che sarebbe desidera per accadere. La funzione trampolino quindi prende questo e lo chiama, quindi chiama qualsiasi funzione che ritorna e così via fino a quando non vi è alcun "passaggio successivo". Questo si chiama trampolino in quanto il flusso di controllo "rimbalza" tra ogni chiamata ricorsiva e l'implementazione del trampolino, invece della funzione che ricorre direttamente. Abbandonando il controllo su chi effettua la chiamata ricorsiva, la funzione trampolino può garantire che lo stack non diventi troppo grande. Nota a margine: questa implementazione di trampolineomette la restituzione di valori per semplicità.

Può essere difficile sapere se questa è una buona idea. Le prestazioni possono risentire di ogni passaggio che assegna una nuova chiusura. Ottimizzazioni intelligenti possono renderlo praticabile, ma non lo sai mai. Il trampolino è utile soprattutto per aggirare i limiti di ricorsione, ad esempio quando un'implementazione del linguaggio imposta una dimensione massima dello stack di chiamate.


18

Forse diventa più facile capire se il trampolino è implementato con un tipo di ritorno dedicato (invece di abusare di una funzione):

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));

Contrastalo con la tua versione di trampoline, dove il caso di ricorsione è quando la funzione restituisce un'altra funzione, e il caso di base è quando restituisce qualcos'altro.

Che cosa impedisce foo = foo()di causare un overflow dello stack?

Non si chiama più. Invece, restituisce un risultato (nella mia implementazione, letteralmente a Result) che comunica se continuare la ricorsione o se scoppiare.

E foo = foo()tecnicamente non sta mutando, o mi sto perdendo qualcosa? Forse è solo un male necessario.

Sì, questo è esattamente il male necessario del ciclo. Si potrebbe anche scrivere trampolinesenza mutazione, ma richiederebbe nuovamente la ricorsione:

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;
}

Tuttavia, mostra l'idea di ciò che la funzione del trampolino fa ancora meglio.

Il punto del trampolino è quello di sottrarre la chiamata ricorsiva alla coda dalla funzione che vuole usare la ricorsione in un valore di ritorno e di fare la ricorsione effettiva in un solo posto: la trampolinefunzione, che può quindi essere ottimizzata in un unico posto per usare un ciclo continuo.


foo = foo()è una mutazione nel senso di modificare lo stato locale, ma in genere prenderei in considerazione tale riassegnazione poiché in realtà non si sta modificando l'oggetto funzione sottostante, lo si sta sostituendo con la funzione (o valore) che restituisce.
JAB,

@JAB Sì, non intendevo implicare la mutazione del valore che foocontiene, solo la variabile viene modificata. Un whileciclo richiede uno stato mutabile se si desidera che termini, in questo caso la variabile fooo x.
Bergi,

Ho fatto qualcosa del genere qualche tempo fa in questa risposta a una domanda Stack Overflow sull'ottimizzazione delle chiamate di coda, trampolini, ecc.
Joshua Taylor,

2
La tua versione senza mutazione ha convertito una chiamata ricorsiva fnin una chiamata ricorsiva in trampoline- non sono sicuro che sia un miglioramento.
Michael Anderson,

1
@MichaelAnderson Ha solo lo scopo di dimostrare l'astrazione. Naturalmente un trampolino ricorsivo non è utile.
Bergi,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.