J'ai parcouru tout le Web à la recherche de Lumières sur les suites, et c'est ahurissant de voir comment la plus simple des explications peut si complètement confondre un programmeur JavaScript comme moi. Cela est particulièrement vrai lorsque la plupart des articles expliquent les suites avec du code dans Scheme ou utilisent des monades.
Maintenant que je pense enfin avoir compris l'essence des suites, je voulais savoir si ce que je sais est réellement la vérité. Si ce que je pense être vrai n'est pas réellement vrai, alors c'est l'ignorance et non l'illumination.
Alors, voici ce que je sais:
Dans presque toutes les langues, les fonctions renvoient explicitement des valeurs (et un contrôle) à leur appelant. Par exemple:
var sum = add(2, 3);
console.log(sum);
function add(x, y) {
return x + y;
}
Maintenant, dans un langage avec des fonctions de première classe, nous pouvons passer le contrôle et retourner la valeur à un rappel au lieu de retourner explicitement à l'appelant:
add(2, 3, function (sum) {
console.log(sum);
});
function add(x, y, cont) {
cont(x + y);
}
Ainsi, au lieu de renvoyer une valeur d'une fonction, nous continuons avec une autre fonction. Par conséquent, cette fonction est appelée une continuation de la première.
Alors, quelle est la différence entre une continuation et un rappel?
Je crois que les suites sont un cas particulier de rappels. Une fonction peut rappeler n'importe quel nombre de fonctions, n'importe quel nombre de fois. Par exemple:
var array = [1, 2, 3];
forEach(array, function (element, array, index) {
array[index] = 2 * element;
});
console.log(array);
function forEach(array, callback) {
var length = array.length;
for (var i = 0; i < length; i++)
callback(array[i], array, i);
}
Cependant, si une fonction rappelle une autre fonction comme dernière chose qu'elle fait, alors la deuxième fonction est appelée une continuation de la première. Par exemple:
var array = [1, 2, 3];
forEach(array, function (element, array, index) {
array[index] = 2 * element;
});
console.log(array);
function forEach(array, callback) {
var length = array.length;
// This is the last thing forEach does
// cont is a continuation of forEach
cont(0);
function cont(index) {
if (index < length) {
callback(array[index], array, index);
// This is the last thing cont does
// cont is a continuation of itself
cont(++index);
}
}
}
Si une fonction appelle une autre fonction comme dernière chose qu'elle fait, elle est appelée un appel de queue. Certaines langues comme Scheme effectuent des optimisations d'appel de queue. Cela signifie que l'appel de queue n'encourt pas la surcharge totale d'un appel de fonction. Au lieu de cela, il est implémenté comme un simple goto (avec le cadre de pile de la fonction appelante remplacé par le cadre de pile de l'appel de queue).
Bonus : Passage au style de passage de continuation. Considérez le programme suivant:
console.log(pythagoras(3, 4));
function pythagoras(x, y) {
return x * x + y * y;
}
Maintenant, si chaque opération (y compris l'addition, la multiplication, etc.) était écrite sous forme de fonctions, nous aurions alors:
console.log(pythagoras(3, 4));
function pythagoras(x, y) {
return add(square(x), square(y));
}
function square(x) {
return multiply(x, x);
}
function multiply(x, y) {
return x * y;
}
function add(x, y) {
return x + y;
}
De plus, si nous n'étions pas autorisés à retourner des valeurs, nous devions utiliser les suites comme suit:
pythagoras(3, 4, console.log);
function pythagoras(x, y, cont) {
square(x, function (x_squared) {
square(y, function (y_squared) {
add(x_squared, y_squared, cont);
});
});
}
function square(x, cont) {
multiply(x, x, cont);
}
function multiply(x, y, cont) {
cont(x * y);
}
function add(x, y, cont) {
cont(x + y);
}
Ce style de programmation dans lequel vous n'êtes pas autorisé à retourner des valeurs (et donc vous devez recourir à des continuations de passage) est appelé style de passage de continuation.
Il y a cependant deux problèmes avec le style de passage de continuation:
Le premier problème peut être facilement résolu en JavaScript en appelant les suites de manière asynchrone. En appelant la continuation de manière asynchrone, la fonction retourne avant l'appel de la continuation. Par conséquent, la taille de la pile d'appels n'augmente pas:
Function.prototype.async = async;
pythagoras.async(3, 4, console.log);
function pythagoras(x, y, cont) {
square.async(x, function (x_squared) {
square.async(y, function (y_squared) {
add.async(x_squared, y_squared, cont);
});
});
}
function square(x, cont) {
multiply.async(x, x, cont);
}
function multiply(x, y, cont) {
cont.async(x * y);
}
function add(x, y, cont) {
cont.async(x + y);
}
function async() {
setTimeout.bind(null, this, 0).apply(null, arguments);
}
Le deuxième problème est généralement résolu en utilisant une fonction appelée call-with-current-continuation
qui est souvent abrégé en callcc
. Malheureusement, callcc
ne peut pas être entièrement implémenté en JavaScript, mais nous pourrions écrire une fonction de remplacement pour la plupart de ses cas d'utilisation:
pythagoras(3, 4, console.log);
function pythagoras(x, y, cont) {
var x_squared = callcc(square.bind(null, x));
var y_squared = callcc(square.bind(null, y));
add(x_squared, y_squared, cont);
}
function square(x, cont) {
multiply(x, x, cont);
}
function multiply(x, y, cont) {
cont(x * y);
}
function add(x, y, cont) {
cont(x + y);
}
function callcc(f) {
var cc = function (x) {
cc = x;
};
f(cc);
return cc;
}
La fonction callcc
prend une fonction f
et l'applique à la current-continuation
(abrégé en cc
). Le current-continuation
est une fonction de continuation qui enveloppe le reste du corps de la fonction après l'appel à callcc
.
Considérez le corps de la fonction pythagoras
:
var x_squared = callcc(square.bind(null, x));
var y_squared = callcc(square.bind(null, y));
add(x_squared, y_squared, cont);
Le current-continuation
du second callcc
est:
function cc(y_squared) {
add(x_squared, y_squared, cont);
}
De même, le current-continuation
du premier callcc
est:
function cc(x_squared) {
var y_squared = callcc(square.bind(null, y));
add(x_squared, y_squared, cont);
}
Depuis le current-continuation
du premier callcc
contient un autre callcc
il doit être converti en style de passage de continuation:
function cc(x_squared) {
square(y, function cc(y_squared) {
add(x_squared, y_squared, cont);
});
}
Donc, essentiellement callcc
convertit logiquement le corps entier de la fonction à ce à partir duquel nous sommes partis (et donne à ces fonctions anonymes le nom cc
). La fonction pythagore utilisant cette implémentation de callcc devient alors:
function pythagoras(x, y, cont) {
callcc(function(cc) {
square(x, function (x_squared) {
square(y, function (y_squared) {
add(x_squared, y_squared, cont);
});
});
});
}
Encore une fois, vous ne pouvez pas implémenter callcc
en JavaScript, mais vous pouvez l'implémenter le style de passage de continuation en JavaScript comme suit:
Function.prototype.async = async;
pythagoras.async(3, 4, console.log);
function pythagoras(x, y, cont) {
callcc.async(square.bind(null, x), function cc(x_squared) {
callcc.async(square.bind(null, y), function cc(y_squared) {
add.async(x_squared, y_squared, cont);
});
});
}
function square(x, cont) {
multiply.async(x, x, cont);
}
function multiply(x, y, cont) {
cont.async(x * y);
}
function add(x, y, cont) {
cont.async(x + y);
}
function async() {
setTimeout.bind(null, this, 0).apply(null, arguments);
}
function callcc(f, cc) {
f.async(cc);
}
La fonction callcc
peut être utilisée pour implémenter des structures de flux de contrôle complexes telles que des blocs try-catch, des coroutines, des générateurs, fibres , etc.
Malgré le merveilleux résumé, je pense que vous confondez un peu votre terminologie. Par exemple, vous avez raison de dire qu'un appel de queue se produit lorsque l'appel est la dernière chose qu'une fonction doit exécuter, mais par rapport aux continuations, un appel de queue signifie que la fonction ne modifie pas la suite avec laquelle elle est appelée, seulement qu'elle met à jour la valeur passée à la suite (si elle le souhaite). C'est pourquoi la conversion d'une fonction récursive de queue en CPS est si facile (il suffit d'ajouter la continuation en tant que paramètre et d'appeler la continuation sur le résultat).
Il est également un peu étrange d'appeler des suites un cas spécial de rappels. Je peux voir comment ils sont facilement regroupés, mais les suites ne découlent pas de la nécessité de distinguer d'un rappel. Une suite représente en fait les instructions restantes pour terminer un calcul , ou le reste du calcul de ce point dans le temps. Vous pouvez considérer une continuation comme un trou qui doit être comblé. Si je peux capturer la continuation actuelle d'un programme, alors je peux revenir exactement à ce qu'était le programme lorsque j'ai capturé la continuation. (Cela rend les débogueurs plus faciles à écrire.)
Dans ce contexte, la réponse à votre question est qu'un rappel est une chose générique qui est appelée à tout moment spécifié par un contrat fourni par le appelant [du rappel]. Un rappel peut avoir autant d'arguments qu'il le souhaite et être structuré comme bon lui semble. Une continuation est donc nécessairement une procédure à un argument qui résout la valeur qui lui est transmise. Une continuation doit être appliquée à une seule valeur et l'application doit se produire à la fin. Lorsqu'une continuation termine l'exécution, l'expression est terminée et, selon la sémantique du langage, des effets secondaires peuvent ou non avoir été générés.
La réponse courte est que la différence entre une continuation et un rappel est qu'après l'appel (et la fin) d'un rappel, l'exécution reprend au point où elle a été invoquée, tandis qu'en invoquant une continuation, l'exécution reprend au point où la continuation a été créée. En d'autres termes: ne suite ne revient jamais.
Considérez la fonction:
function add(x, y, c) {
alert("before");
c(x+y);
alert("after");
}
(J'utilise la syntaxe Javascript même si Javascript ne prend pas réellement en charge les continuations de première classe, car c'est dans cela que vous avez donné vos exemples, et il sera plus compréhensible pour les personnes qui ne connaissent pas la syntaxe LISP.)
Maintenant, si nous lui passons un rappel:
add(2, 3, function (sum) {
alert(sum);
});
puis nous verrons trois alertes: "avant", "5" et "après".
En revanche, si nous devions lui passer une suite qui fait la même chose que le rappel, comme ceci:
alert(callcc(function(cc) {
add(2, 3, cc);
}));
nous ne verrions alors que deux alertes: "avant" et "5". L'appel de c()
à l'intérieur de add()
met fin à l'exécution de add()
et provoque le retour de callcc()
; la valeur renvoyée par callcc()
était la valeur passée comme argument à c
(à savoir la somme).
En ce sens, même si l'invocation d'une continuation ressemble à un appel de fonction, elle s'apparente à certains égards à une instruction return ou à la levée d'une exception.
En fait, l'appel/cc peut être utilisé pour ajouter des instructions de retour aux langues qui ne les prennent pas en charge. Par exemple, si JavaScript n'avait pas d'instruction return (à la place, comme beaucoup de langages Lips, renvoyant juste la valeur de la dernière expression dans le corps de la fonction) mais avait un appel/cc, nous pourrions implémenter return comme ceci:
function find(myArray, target) {
callcc(function(return) {
var i;
for (i = 0; i < myArray.length; i += 1) {
if(myArray[i] === target) {
return(i);
}
}
return(undefined); // Not found.
});
}
L'appel de return(i)
appelle une continuation qui termine l'exécution de la fonction anonyme et fait que callcc()
renvoie l'index i
auquel target
a été trouvé dans myArray
.
(NB: l'analogie "retour" est quelque peu simpliste. Par exemple, si une continuation s'échappe de la fonction dans laquelle elle a été créée - en étant sauvegardée dans un global quelque part, disons - il est possible que la fonction qui a créé la suite peut retourner plusieurs fois même si elle n'a été invoquée qu'une seule fois.)
Call/cc peut également être utilisé pour implémenter la gestion des exceptions (throw et try/catch), les boucles et de nombreuses autres structures de contrôle.
Pour dissiper certaines erreurs possibles:
L'optimisation des appels de queue n'est en aucun cas requise pour prendre en charge des continuations de première classe. Considérez que même le langage C a une forme (restreinte) de continuations sous la forme de setjmp()
, qui crée une continuation, et longjmp()
, qui en appelle une!
Il n'y a aucune raison particulière pour qu'une continuation ne prenne qu'un seul argument. C'est juste que les arguments de la continuation deviennent la ou les valeurs de retour de call/cc, et call/cc est généralement défini comme ayant une seule valeur de retour, donc naturellement la continuation doit en prendre exactement une. Dans les langues prenant en charge plusieurs valeurs de retour (comme Common LISP, Go ou même Scheme), il serait tout à fait possible d'avoir des continuations qui acceptent plusieurs valeurs.