Un exemple en JavaScript
Voici un exemple utilisant JavaScript. Actuellement, la plupart des navigateurs ne prennent pas en charge l'optimisation des appels de queue et, par conséquent, l'extrait suivant échouera.
const repeat = n => f => x =>
n === 0 ? x : repeat (n - 1) (f) (f(x))
console.log(repeat(1e3) (x => x + 1) (0)) // 1000
console.log(repeat(1e5) (x => x + 1) (0)) // Error: Uncaught RangeError: Maximum call stack size exceeded
Trampolines
Nous pouvons contourner cette limitation en modifiant la façon dont nous écrivons les répétitions, mais seulement légèrement. Au lieu de renvoyer une valeur directement ou de répéter immédiatement, nous allons renvoyer un de nos deux types de trampoline, Bounce
o Done
. Ensuite, nous utiliserons notre trampoline
pour gérer la boucle.
// trampoline
const Bounce = (f,x) => ({ isBounce: true, f, x })
const Done = x => ({ isBounce: false, x })
const trampoline = ({ isBounce, f, x }) => {
while (isBounce)
({ isBounce, f, x } = f(x))
return x
}
// our revised repeat function, now stack-safe
const repeat = n => f => x =>
n === 0 ? Done(x) : Bounce(repeat (n - 1) (f), f(x))
// apply trampoline to the result of an ordinary call repeat
let result = trampoline(repeat(1e6) (x => x + 1) (0))
// no more stack overflow
console.log(result) // 1000000
Currying ralentit aussi un peu les choses, mais nous pouvons y remédier en utilisant une fonction auxiliaire pour la récursion. Cette fonction est également intéressante car elle cache les détails de l'implémentation du trampoline et n'attend pas de l'appelant qu'il fasse rebondir la valeur de retour. Cela fonctionne environ deux fois plus vite que la fonction ci-dessus repeat
// aux helper hides trampoline implementation detail
// runs about 2x as fast
const repeat = n => f => x => {
const aux = (n, x) =>
n === 0 ? Done(x) : Bounce(x => aux (n - 1, x), f (x))
return trampoline (aux (n, x))
}
Style Clojure loop
/ recur
Les trampolines sont sympas et tout mais c'est un peu ennuyeux d'avoir à s'inquiéter d'appeler. trampoline
sur la valeur de retour de votre fonction. Nous avons vu que l'alternative était d'utiliser une aide auxiliaire, mais bon, c'est un peu ennuyeux aussi. Je suis sûr que certains d'entre vous ne sont pas très enthousiastes à l'idée d'utiliser l'aide auxiliaire. Bounce
y Done
les emballages, aussi.
Clojure crée une interface trampoline spécialisée qui utilise une paire de fonctions, loop
y recur
- cette paire de tandem se prête à une expression remarquablement élégante d'un programme
Oh et c'est très rapide, aussi
const recur = (...values) =>
({ recur, values })
const loop = run =>
{ let r = run ()
while (r && r.recur === recur)
r = run (...r.values)
return r
}
const repeat = n => f => x =>
loop
( (m = n, r = x) =>
m === 0
? r
: recur (m - 1, f (r))
)
console.time ('loop/recur')
console.log (repeat (1e6) (x => x + 1) (0)) // 1000000
console.timeEnd ('loop/recur') // 24 ms
Au début, ce style vous semblera étranger, mais avec le temps, je constate qu'il est le plus cohérent tout en produisant des programmes durables. Les commentaires ci-dessous vous aideront à vous familiariser avec cette syntaxe riche en expressions.
const repeat = n => f => x =>
loop // begin a loop with
( ( m = n // local loop var m: counter, init with n
, r = x // local loop var r: result, init with x
) =>
m === 0 // terminating condition
? r // return result
: recur // otherwise recur with
( m - 1 // next m value
, f (r) // next r value
)
)
La monade de continuation
C'est l'un de mes sujets préférés, alors nous allons voir à quoi cela ressemble avec la monade de continuation. Réutilisation de loop
y recur
nous mettons en place un système de sécurité pour les piles cont
qui peut enchaîner les opérations en utilisant chain
et exécuter des séquences d'opérations en utilisant runCont
. Pour repeat
c'est absurde (et lent), mais c'est cool de voir la mécanique de cont
à l'œuvre dans cet exemple simple -
const identity = x =>
x
const recur = (...values) =>
({ recur, values })
const loop = run =>
{ let r = run ()
while (r && r.recur === recur)
r = run (...r.values)
return r
}
// cont : 'a -> 'a cont
const cont = x =>
k => recur (k, x)
// chain : ('a -> 'b cont) -> 'a cont -> 'b cont
const chain = f => mx =>
k => recur (mx, x => recur (f (x), k))
// runCont : ('a -> 'b) -> a cont -> 'b
const runCont = f => mx =>
loop ((r = mx, k = f) => r (k))
const repeat = n => f => x =>
{ const aux = n => x =>
n === 0 // terminating condition
? cont (x) // base case, continue with x
: chain // otherise
(aux (n - 1)) // sequence next operation on
(cont (f (x))) // continuation of f(x)
return runCont // run continuation
(identity) // identity; pass-thru
(aux (n) (x)) // the continuation returned by aux
}
console.time ('cont monad')
console.log (repeat (1e6) (x => x + 1) (0)) // 1000000
console.timeEnd ('cont monad') // 451 ms
Y
combinateur
Le Y combinator est mon combinateur d'esprit ; cette réponse serait incomplète sans lui donner une place parmi les autres techniques. Cependant, la plupart des implémentations du Y combinator ne sont pas stack-safe et débordent si la fonction fournie par l'utilisateur se répète trop souvent. Puisque cette réponse a pour but de préserver un comportement sûr pour la pile, nous implémenterons bien sûr Y
d'une manière sûre - encore une fois, en s'appuyant sur notre fidèle trampoline.
Y
démontre la possibilité d'étendre la récursion infinie synchrone, facile à utiliser et sûre pour la pile, sans encombrer votre fonction.
const bounce = f => (...xs) =>
({ isBounce: true, f, xs })
const trampoline = t => {
while (t && t.isBounce)
t = t.f(...t.xs)
return t
}
// stack-safe Y combinator
const Y = f => {
const safeY = f =>
bounce((...xs) => f (safeY (f), ...xs))
return (...xs) =>
trampoline (safeY (f) (...xs))
}
// recur safely to your heart's content
const repeat = Y ((recur, n, f, x) =>
n === 0
? x
: recur (n - 1, f, f (x)))
console.log(repeat (1e5, x => x + 1, 0)) // 10000
L'aspect pratique avec while
boucle
Mais soyons honnêtes : cela fait beaucoup de cérémonies alors que nous négligeons l'une des solutions potentielles les plus évidentes : utiliser une for
o while
boucle, mais la cache derrière une interface fonctionnelle
A toutes fins utiles, cette repeat
fonctionne de manière identique à celle fournie ci-dessus - sauf que celle-ci est environ un ou deux gadzillion fois plus rapide (à l'exception de la fonction loop
/ recur
solution). De plus, il est sans doute beaucoup plus facile à lire.
Il est vrai que cette fonction est peut-être un exemple artificiel - toutes les fonctions récursives ne peuvent pas être converties en une fonction de type for
o while
mais dans un tel scénario où c'est possible, il est probablement préférable de le faire comme ça. Gardez les trampolines et les continuations pour les cas difficiles où une simple boucle ne suffit pas.
const repeat = n => f => x => {
let m = n
while (true) {
if (m === 0)
return x
else
(m = m - 1, x = f (x))
}
}
const gadzillionTimes = repeat(1e8)
const add1 = x => x + 1
const result = gadzillionTimes (add1) (0)
console.log(result) // 100000000
setTimeout
n'est PAS une solution au problème de débordement de pile
OK, alors c'est fait travailler, mais seulement de façon paradoxale. Si votre ensemble de données est petit, vous n'avez pas besoin de setTimeout
car il n'y aura pas de dépassement de pile. Si votre ensemble de données est grand et que vous utilisez setTimeout
comme mécanisme de récursion sûr, non seulement vous rendez impossible le retour synchrone d'une valeur depuis votre fonction, mais elle sera si lente que vous ne voudrez même pas utiliser votre fonction
Certaines personnes ont trouvé un site de préparation aux questions d'entretien qui encourage cette redoutable stratégie
Ce que notre repeat
ressemblerait à l'utilisation de setTimeout
- remarquez qu'il est également défini dans un style de passage continu - c'est-à-dire que nous devons appeler repeat
avec un rappel ( k
) pour obtenir la valeur finale
// do NOT implement recursion using setTimeout
const repeat = n => f => x => k =>
n === 0
? k (x)
: setTimeout (x => repeat (n - 1) (f) (x) (k), 0, f (x))
// be patient, this one takes about 5 seconds, even for just 1000 recursions
repeat (1e3) (x => x + 1) (0) (console.log)
// comment the next line out for absolute madness
// 10,000 recursions will take ~1 MINUTE to complete
// paradoxically, direct recursion can compute this in a few milliseconds
// setTimeout is NOT a fix for the problem
// -----------------------------------------------------------------------------
// repeat (1e4) (x => x + 1) (0) (console.log)
Je ne peux pas assez insister sur le fait que c'est mauvais. Même 1e5
prend tellement de temps à fonctionner que j'ai renoncé à essayer de le mesurer. Je ne l'ai pas inclus dans les benchmarks ci-dessous car il est trop lent pour être considéré comme une approche viable.
Promesses
Les promesses ont la capacité de chaîner des calculs et sont sûres en pile. Cependant, la réalisation d'une pile sûre repeat
l'utilisation de Promises signifie que nous devrons abandonner notre valeur de retour synchrone, de la même manière que nous l'avons fait avec l'utilisation des setTimeout
. Je présente ceci comme une "solution" parce qu'elle fait résoudre le problème, contrairement setTimeout
mais d'une manière très simple par rapport au trampoline ou à la monade de continuation. Comme vous pouvez l'imaginer, les performances sont un peu mauvaises, mais pas autant que celles de la monade de continuation. setTimeout
exemple ci-dessus
Il est intéressant de noter que dans cette solution, les détails de l'implémentation de Promise sont complètement cachés à l'appelant. Une seule continuation est fournie comme quatrième argument et est appelée lorsque le calcul est terminé.
const repeat = n => f => x => k =>
n === 0
? Promise.resolve(x).then(k)
: Promise.resolve(f(x)).then(x => repeat (n - 1) (f) (x) (k))
// be patient ...
repeat (1e6) (x => x + 1) (0) (x => console.log('done', x))
Repères
Sérieusement, le while
La boucle est une lot plus rapide - comme presque 100x plus rapide (en comparant le meilleur au pire - mais sans inclure les réponses asynchrones : setTimeout
y Promise
)
// sync
// -----------------------------------------------------------------------------
// repeat implemented with basic trampoline
console.time('A')
console.log(tramprepeat(1e6) (x => x + 1) (0))
console.timeEnd('A')
// 1000000
// A 114 ms
// repeat implemented with basic trampoline and aux helper
console.time('B')
console.log(auxrepeat(1e6) (x => x + 1) (0))
console.timeEnd('B')
// 1000000
// B 64 ms
// repeat implemented with cont monad
console.time('C')
console.log(contrepeat(1e6) (x => x + 1) (0))
console.timeEnd('C')
// 1000000
// C 33 ms
// repeat implemented with Y
console.time('Y')
console.log(yrepeat(1e6) (x => x + 1) (0))
console.timeEnd('Y')
// 1000000
// Y 544 ms
// repeat implemented with while loop
console.time('D')
console.log(whilerepeat(1e6) (x => x + 1) (0))
console.timeEnd('D')
// 1000000
// D 4 ms
// async
// -----------------------------------------------------------------------------
// repeat implemented with Promise
console.time('E')
promiserepeat(1e6) (x => x + 1) (0) (console.log)
console.timeEnd('E')
// 1000000
// E 2224 ms
// repeat implemented with setTimeout; FAILED
console.time('F')
timeoutrepeat(1e6) (x => x + 1) (0) (console.log)
console.timeEnd('F')
// ...
// too slow; didn't finish after 3 minutes
JavaScript de l'âge de pierre
Les techniques ci-dessus sont démontrées à l'aide des nouvelles syntaxes ES6, mais vous pouvez mettre en œuvre un trampoline dans la version la plus ancienne possible de JavaScript. while
et des fonctions de première classe
Ci-dessous, nous utilisons un javascript de l'âge de pierre pour démontrer que la récursion infinie est possible et performante sans nécessairement en sacrifiant une valeur de retour synchrone - 100,000,000 récursions dans le cadre de 6 secondes - il s'agit d'une différence considérable par rapport à setTimeout
qui ne peut que 1,000 récursions dans le même laps de temps.
function trampoline (t) {
while (t && t.isBounce)
t = t.f (t.x);
return t.x;
}
function bounce (f, x) {
return { isBounce: true, f: f, x: x };
}
function done (x) {
return { isBounce: false, x: x };
}
function repeat (n, f, x) {
function aux (n, x) {
if (n === 0)
return done (x);
else
return bounce (function (x) { return aux (n - 1, x); }, f (x));
}
return trampoline (aux (n, x));
}
console.time('JS1 100K');
console.log (repeat (1e5, function (x) { return x + 1 }, 0));
console.timeEnd('JS1 100K');
// 100000
// JS1 100K: 15ms
console.time('JS1 100M');
console.log (repeat (1e8, function (x) { return x + 1 }, 0));
console.timeEnd('JS1 100M');
// 100000000
// JS1 100K: 5999ms
Récursion infinie non bloquante à l'aide de JavaScript de l'âge de pierre
Si Si, pour une raison ou une autre, vous souhaitez une récursion infinie non bloquante (asynchrone), vous pouvez vous appuyer sur les éléments suivants setTimeout
pour différer un cadre unique au début du calcul. Ce programme utilise également le javascript de l'âge de pierre et calcule 100.000.000 de récursions en moins de 8 secondes, mais cette fois-ci de manière non bloquante.
Cela démontre qu'il n'y a rien de spécial à avoir une exigence de non-blocage. A while
la boucle et les fonctions de première classe sont toujours la seule exigence fondamentale pour obtenir une récursion sûre pour la pile sans sacrifier les performances.
Dans un programme moderne, étant donné les Promesses, nous substituerions l'option setTimeout
appel pour une seule promesse.
function donek (k, x) {
return { isBounce: false, k: k, x: x };
}
function bouncek (f, x) {
return { isBounce: true, f: f, x: x };
}
function trampolinek (t) {
// setTimeout is called ONCE at the start of the computation
// NOT once per recursion
return setTimeout(function () {
while (t && t.isBounce) {
t = t.f (t.x);
}
return t.k (t.x);
}, 0);
}
// stack-safe infinite recursion, non-blocking, 100,000,000 recursions in under 8 seconds
// now repeatk expects a 4th-argument callback which is called with the asynchronously computed result
function repeatk (n, f, x, k) {
function aux (n, x) {
if (n === 0)
return donek (k, x);
else
return bouncek (function (x) { return aux (n - 1, x); }, f (x));
}
return trampolinek (aux (n, x));
}
console.log('non-blocking line 1')
console.time('non-blocking JS1')
repeatk (1e8, function (x) { return x + 1; }, 0, function (result) {
console.log('non-blocking line 3', result)
console.timeEnd('non-blocking JS1')
})
console.log('non-blocking line 2')
// non-blocking line 1
// non-blocking line 2
// [ synchronous program stops here ]
// [ below this line, asynchronous program continues ]
// non-blocking line 3 100000000
// non-blocking JS1: 7762ms
1 votes
Si vous avez besoin des performances et que le TCO n'est pas disponible, il n'y a pas moyen de contourner la boucle. Si vous n'en avez pas besoin et que vous voulez programmer dans un style fonctionnel, utilisez simplement la récursion.
1 votes
Il me semble que ES6 exige l'optimisation des appels de queue En effet, lorsqu'un nouveau cadre de pile est créé par un appel de queue, ES6 exige que la mémoire utilisée n'augmente que de la différence entre le nouveau cadre de pile et l'ancien, de sorte que les cadres de pile créés par l'appel récursif de la même fonction ne devraient pas entraîner d'augmentation de la mémoire. Ceci dit, toutes les implémentations ne supportent pas ES6 (et ES6 ne supporte le TCO qu'en mode strict), donc il y a toujours une question valide ici.
3 votes
Pour autant que je sache, la méthode traditionnelle (et unique ?) pour éliminer la récursion est la suivante trampoline -- C'est-à-dire qu'il faut faire sauter la pile d'appels en sortant de la fonction, puis invoquer à nouveau la fonction avec la valeur de retour précédente. Le trampoline nécessite une boucle et c'est plus ou moins ce que vous avez fait ici.
0 votes
Que voulez-vous dire par " mais n'optimise pas leurs performances " ?