web-dev-qa-db-fra.com

Pourquoi les trampolines fonctionnent-ils?

J'ai fait du JavaScript fonctionnel. J'avais pensé que Tail-Call Optimization avait été implémenté, mais il s'est avéré que j'avais tort. J'ai donc dû m'enseigner Trampoline . Après un peu de lecture ici et ailleurs, j'ai pu descendre les bases et construire mon premier trampoline:

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

Mon plus gros problème, c'est que je ne sais pas pourquoi cela fonctionne. J'ai l'idée de réexécuter la fonction dans une boucle while au lieu d'utiliser une boucle récursive. Sauf que, techniquement, ma fonction de base a déjà une boucle récursive. Je n'exécute pas la fonction de base loopy, mais j'exécute la fonction à l'intérieur de celle-ci. Qu'est-ce qui empêche foo = foo() de provoquer un débordement de pile? Et foo = foo() ne mute-t-il pas techniquement, ou manque-t-il quelque chose? C'est peut-être juste un mal nécessaire. Ou une syntaxe qui me manque.

Y a-t-il même un moyen de le comprendre? Ou est-ce juste un hack qui fonctionne d'une manière ou d'une autre? J'ai pu faire mon chemin à travers tout le reste, mais celui-ci m'a embrouillé.

105
Ucenna

La raison pour laquelle votre cerveau se rebelle contre la fonction loopy() est qu'il s'agit d'un type incohérent:

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

Un grand nombre de langues ne vous permettent même pas de faire des choses comme ça, ou au moins exigent beaucoup plus de dactylographie pour expliquer à quel point cela est sensé. Parce que ce n'est vraiment pas le cas. Les fonctions et les entiers sont des types d'objets totalement différents.

Passons donc en revue cette boucle while, soigneusement:

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

Initialement, foo est égal à loopy(0). Qu'est-ce que loopy(0)? Eh bien, c'est moins de 10000000, donc nous obtenons function(){return loopy(1)}. C'est une valeur vraie et c'est une fonction, donc la boucle continue.

Nous arrivons maintenant à foo = foo(). foo() est identique à loopy(1). Puisque 1 est toujours inférieur à 10000000, cela renvoie function(){return loopy(2)}, que nous attribuons ensuite à foo.

foo est toujours une fonction, nous continuons donc ... jusqu'à ce que foo soit égal à function(){return loopy(10000000)}. C'est une fonction, donc nous faisons foo = foo() une fois de plus, mais cette fois, quand nous appelons loopy(10000000), x n'est pas inférieur à 10000000 donc nous récupérons simplement x. Puisque 10000000 n'est pas non plus une fonction, cela termine également la boucle while.

89
Kevin

Kevin souligne succinctement comment fonctionne cet extrait de code particulier (ainsi que pourquoi il est assez incompréhensible), mais je voulais ajouter quelques informations sur le fonctionnement des trampolines en général.

Sans optimisation d'appel (TCO), chaque appel de fonction ajoute un frame de pile à la pile d'exécution actuelle. Supposons que nous ayons une fonction pour imprimer un compte à rebours de nombres:

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

Si nous appelons countdown(3), analysons à quoi ressemblerait la pile d'appels sans 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

Avec TCO, chaque appel récursif à countdown est en position de queue (il n'y a plus rien à faire d'autre que retourner le résultat de l'appel) donc aucune trame de pile n'est allouée. Sans TCO, la pile explose, même légèrement grande n.

Le trampoline contourne cette restriction en insérant un wrapper autour de la fonction countdown. Ensuite, countdown n'effectue pas d'appels récursifs et renvoie à la place immédiatement une fonction à appeler. Voici un exemple d'implémentation:

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

Pour mieux comprendre comment cela fonctionne, regardons la pile d'appels:

> 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

À chaque étape, la fonction countdownHopabandonne contrôle direct de ce qui se passe ensuite, renvoyant à la place une fonction à appeler qui décrit ce qu'elle comme se produirait ensuite. La fonction trampoline prend alors ceci et l'appelle, puis appelle la fonction que retourne, et ainsi de suite jusqu'à ce qu'il n'y ait pas de "prochaine étape". C'est ce qu'on appelle le trampoline car le flux de contrôle "rebondit" entre chaque appel récursif et la mise en œuvre du trampoline, au lieu de la fonction directement récurrente. En abandonnant le contrôle sur qui fait l'appel récursif, la fonction trampoline peut garantir que la pile ne devient pas trop grande. Remarque: cette implémentation de trampoline omet de renvoyer des valeurs pour plus de simplicité.

Il peut être difficile de savoir si c'est une bonne idée. La performance peut souffrir en raison de chaque étape allouant une nouvelle fermeture. Des optimisations intelligentes peuvent rendre cela viable, mais on ne sait jamais. Le trampoline est surtout utile pour contourner les limites de récursivité strictes, par exemple lorsqu'une implémentation de langage définit une taille maximale de pile d'appels.

173
Jack

Peut-être qu'il devient plus facile de comprendre si le trampoline est implémenté avec un type de retour dédié (au lieu d'abuser d'une fonction):

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

Comparez cela à votre version de trampoline, où le cas de récursivité est lorsque la fonction retourne une autre fonction, et le cas de base est quand il retourne autre chose.

Qu'est-ce qui empêche foo = foo() de provoquer un débordement de pile?

Il ne s'appelle plus. Au lieu de cela, il renvoie un résultat (dans mon implémentation, littéralement un Result) qui indique s'il faut continuer la récursivité ou si elle doit éclater.

Et foo = foo() ne mute-t-il pas techniquement, ou manque-t-il quelque chose? C'est peut-être juste un mal nécessaire.

Oui, c'est exactement le mal nécessaire de la boucle. On pourrait également écrire trampoline sans mutation, mais cela nécessiterait à nouveau une récursivité:

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

Pourtant, cela montre l'idée de ce que la fonction trampoline fait encore mieux.

Le point de trampoling est abstrait l'appel récursif de queue de la fonction qui veut utiliser la récursivité dans une valeur de retour, et faire la récursivité réelle en un seul endroit - la fonction trampoline , qui peut ensuite être optimisé en un seul endroit pour utiliser une boucle.

18
Bergi