Je connais l'idée de l' élimination de base de la récursivité de la queue, où les fonctions qui renvoient le résultat direct d'un appel peuvent être réécrites sous forme de boucles itératives.
foo(...):
# ...
return foo(...)
Je comprends également que, comme cas spécial, la fonction peut toujours être réécrite si l'appel récursif est encapsulé dans un appel à cons
.
foo(...):
# ...
return (..., foo(...))
Quelle propriété de cons
permet cela? Quelles fonctions autres que cons
peuvent encapsuler un appel de queue récursif sans détruire notre capacité à le réécrire de manière itérative?
GCC (mais pas Clang) est capable d'optimiser cet exemple de « multiplication modulo de récursivité de queue », mais on ne sait pas quel mécanisme lui permet de découvrir cela ou comment il effectue ses transformations.
pow(x, n):
if n == 0: return 1
else if n == 1: return x
else: return x * pow(x, n-1)
if(n==0) return 0;
(pas de retour 1 comme dans votre question).x^0 = 1
, c'est donc un bug. Mais ce n'est pas important pour le reste de la question; l'asm itératif vérifie d'abord ce cas spécial. Mais étrangement, l'implémentation itérative introduit une multiplicité de ce1 * x
qui n'était pas présent dans la source, même si nous faisons unefloat
version. gcc.godbolt.org/z/eqwine (et gcc ne réussit qu'avec-ffast-math
.)return 0
a été corrigé. La multiplication par 1 est intéressante. Je ne sais pas quoi en penser.float
sans-ffast-math
, même s'il s'agit de la même valeur multipliée à chaque fois. (Sauf pour le 1.0f` qui pourrait être le point de friction?)Réponses:
Bien que GCC utilise probablement des règles ad hoc, vous pouvez les dériver de la manière suivante. Je vais utiliser
pow
pour illustrer puisque vous êtesfoo
si vaguement défini. En outre, ilfoo
pourrait être mieux compris comme une instance d'optimisation du dernier appel en ce qui concerne les variables à affectation unique comme le langage Oz et comme discuté dans Concepts, Techniques et Modèles de Programmation Informatique . L'avantage d'utiliser des variables à affectation unique est qu'il permet de rester dans un paradigme de programmation déclarative. Essentiellement, vous pouvez avoir chaque champ desfoo
retours de structure représenté par des variables à affectation unique qui sont ensuite passées enfoo
tant qu'arguments supplémentaires.foo
devient alors une queue récursivevoid
fonction de retour. Aucune intelligence particulière n'est nécessaire pour cela.Revenant à
pow
, d'abord, se transformer en style de passage de continuation .pow
devient:Tous les appels sont maintenant des appels de queue. Cependant, la pile de contrôle a été déplacée dans les environnements capturés dans les fermetures représentant les suites.
Ensuite, défonctionnalisez les suites. Puisqu'il n'y a qu'un seul appel récursif, la structure de données résultante représentant les suites non fonctionnalisées est une liste. On a:
Que
applyPow(k, acc)
fait est de prendre une liste, à savoir monoïde libre, commek=Cons(x, Cons(x, Cons(x, Nil)))
et d' en fairex*(x*(x*acc))
. Mais comme il*
est associatif et forme généralement un monoïde avec l'unité1
, nous pouvons le réassocier((x*x)*x)*acc
, et, pour plus de simplicité, clouer un point1
de départ, la production devient alors juste(((1*x)*x)*x)*acc
. L'essentiel est que nous pouvons réellement calculer partiellement le résultat avant même de l'avoiracc
. Cela signifie qu'au lieu de la diffuserk
comme une liste qui est essentiellement une "syntaxe" incomplète que nous "interpréterons" à la fin, nous pouvons "l'interpréter" au fur et à mesure. Le résultat est que nous pouvons remplacerNil
par l'unité du monoïde,1
dans ce cas, etCons
par le fonctionnement du monoïde*
, etk
représente maintenant le "produit en cours d'exécution".applyPow(k, acc)
k*acc
lequel nous pouvons réintégrerpow2
et simplifier la production:Une version récursive de la queue qui passe par l'accumulateur de l'original
pow
.Bien sûr, je ne dis pas que GCC fait tout ce raisonnement au moment de la compilation. Je ne sais pas quelle logique GCC utilise. Mon point est simplement d'avoir fait ce raisonnement une fois, il est relativement facile de reconnaître le modèle et de traduire immédiatement le code source original dans cette forme finale. Cependant, la transformation CPS et la transformation de défonctionnalisation sont complètement générales et mécaniques. À partir de là, des techniques de fusion, de déforestation ou de supercompilation pourraient être utilisées pour tenter d'éliminer les suites réifiées. Les transformations spéculatives pourraient être rejetées s'il n'est pas possible d'éliminer toute l'allocation des suites réifiées. Je soupçonne, cependant, que ce serait trop coûteux à faire tout le temps, en toute généralité, d'où des approches plus ad hoc.
Si vous voulez devenir ridicule, vous pouvez consulter le papier Continuations de recyclage qui utilise également CPS et des représentations de continuations comme données, mais fait quelque chose de similaire mais différent de tail-recursion-modulo-cons. Ceci décrit comment vous pouvez produire des algorithmes d'inversion de pointeur par transformation.
Ce modèle de transformation et de défonctionnalisation CPS est un outil assez puissant pour la compréhension, et est utilisé à bon escient dans une série d'articles que je liste ici .
la source
Je vais tourner autour du pot pendant un moment, mais il y a un point.
Demi-groupes
La réponse est la propriété associative de l'opération de réduction binaire .
C'est assez abstrait, mais la multiplication est un bon exemple. Si x , y et z sont des nombres naturels (ou des nombres entiers, ou des nombres rationnels, ou des nombres réels, ou des nombres complexes, ou des matrices N × N , ou tout un tas d'autres choses), alors x × y est du même genre du nombre à la fois x et y . Nous avons commencé avec deux nombres, c'est donc une opération binaire, et nous en avons obtenu un, nous avons donc réduit le nombre de nombres que nous avions d'un, ce qui en fait une opération de réduction. Et ( x × y ) × z est toujours identique à x × ( y ×z ), qui est la propriété associative.
(Si vous savez déjà tout cela, vous pouvez passer à la section suivante.)
Quelques autres choses que vous voyez souvent en informatique qui fonctionnent de la même manière:
"a"+"b"+"c"
est"abc"
que vous commencez avec"ab"+"c"
ou"a"+"bc"
)[a]++[b]++[c]
est similaire[a,b,c]
de l'arrière vers l'avant ou de l'avant vers l'arrière.cons
sur une tête et une queue, si vous pensez à la tête comme une liste singleton. C'est simplement concaténer deux listes.&
,|
et^
Certaines choses qui ne le font pas:
int print2(int x, int y) { return printf( "%d %d\n", x, y ); }
, asprint2( print2(x,y), z );
etprint2( x, print2(y,z) );
ont une sortie différente.C'est un concept suffisamment utile que nous l'avons nommé. Un ensemble avec une opération qui possède ces propriétés est un semi - groupe . Ainsi, les nombres réels sous multiplication sont un semi-groupe. Et votre question s'avère être l'une des façons dont ce type d'abstraction devient utile dans le monde réel. Les opérations de semi-groupe peuvent toutes être optimisées comme vous le demandez.
Essayez ceci à la maison
Pour autant que je sache, cette technique a été décrite pour la première fois en 1974, dans l'article de Daniel Friedman et David Wise, «Folding Stylized Recursions into Iterations» , bien qu'ils aient assumé quelques propriétés de plus qu'il ne leur en fallait.
Haskell est un excellent langage pour illustrer cela, car il a la
Semigroup
classe de types dans sa bibliothèque standard. Il appelle l'opération d'un génériqueSemigroup
l'opérateur<>
. Comme les listes et les chaînes sont des instances deSemigroup
, leurs instances se définissent<>
comme l'opérateur de concaténation++
, par exemple. Et avec la bonne importation,[a] <> [b]
est un alias pour[a] ++ [b]
, ce qui est[a,b]
.Mais qu'en est-il des chiffres? Nous avons vu juste que les types numériques sont semigroupes sous soit plus ou multiplication! Alors, lequel devient
<>
unDouble
? Eh bien, l'un ou l'autre! Haskell définit les typesProduct Double
,where (<>) = (*)
(qui est la définition même dans Haskell), et aussiSum Double
,where (<>) = (+)
.Une ride est que vous avez utilisé le fait que 1 est l'identité multiplicative. Un semi-groupe avec une identité est appelé un monoïde et est défini dans le package Haskell
Data.Monoid
, qui appelle l'élément d'identité générique d'une classe de typesmempty
.Sum
,Product
Et une liste chacun a un élément d'identité (0, 1 et[]
, respectivement), de sorte qu'ils sont des instances deMonoid
ainsi queSemigroup
. (A ne pas confondre avec un monade , alors oubliez juste que j'en ai même parlé.)C'est assez d'informations pour traduire votre algorithme en une fonction Haskell à l'aide de monoïdes:
Il est important de noter qu'il s'agit du semi-groupe modulo de récursion de queue: chaque cas est soit une valeur, un appel récursif de queue, soit le produit du semi-groupe des deux. En outre, cet exemple est arrivé à utiliser
mempty
pour l'un des cas, mais si nous n'en avions pas eu besoin, nous aurions pu le faire avec la classe de type plus généraleSemigroup
.Chargeons ce programme dans GHCI et voyons comment cela fonctionne:
Rappelez-vous comment nous avons déclaré
pow
pour un génériqueMonoid
, dont nous avons appelé le typea
? Nous avons donné suffisamment d' informations GHCi pour en déduire que le typea
est làProduct Integer
, qui est uninstance
deMonoid
dont le<>
fonctionnement est la multiplication entier. Doncpow 2 4
S'étend récursivement à2<>2<>2<>2
, qui est2*2*2*2
ou16
. Jusqu'ici tout va bien.Mais notre fonction n'utilise que des opérations monoïdes génériques. Auparavant, j'ai dit qu'il y avait un autre exemple de
Monoid
appeléSum
, dont<>
fonctionnement est+
. Pouvons-nous essayer cela?La même expansion nous donne maintenant
2+2+2+2
au lieu de2*2*2*2
. La multiplication, c'est l'addition comme l'exponentiation, c'est la multiplication!Mais j'ai donné un autre exemple d'un monoïde Haskell: les listes, dont le fonctionnement est la concaténation.
L' écriture
[2]
indique au compilateur que ceci est une liste,<>
sur les listes est++
, donc[2]++[2]++[2]++[2]
est[2,2,2,2]
.Enfin, un algorithme (deux, en fait)
En remplaçant simplement
x
par[x]
, vous convertissez l'algorithme générique qui utilise le module de récursivité un semi-groupe en un qui crée une liste. Quelle liste? La liste des éléments auxquels l'algorithme s'applique<>
. Étant donné que nous avons utilisé uniquement les opérations de semi-groupe que les listes possèdent également, la liste résultante sera isomorphe au calcul d'origine. Et comme l'opération d'origine était associative, on peut tout aussi bien évaluer les éléments d'arrière en avant ou d'avant en arrière.Si votre algorithme atteint un cas de base et se termine, la liste ne sera pas vide. Puisque le cas terminal a renvoyé quelque chose, ce sera le dernier élément de la liste, donc il aura au moins un élément.
Comment appliquez-vous une opération de réduction binaire à chaque élément d'une liste dans l'ordre? C'est vrai, un pli. Ainsi , vous pouvez remplacer
[x]
pourx
, obtenir une liste des éléments pour réduire par<>
, puis soit à droite ou à gauche fois fois la liste:La version avec
foldr1
existe en fait dans la bibliothèque standard, commesconcat
pourSemigroup
etmconcat
pourMonoid
. Il fait un pli paresseux à droite sur la liste. Autrement dit, il se développe[Product 2,Product 2,Product 2,Product 2]
à2<>(2<>(2<>(2)))
.Ce n'est pas efficace dans ce cas, car vous ne pouvez rien faire avec les termes individuels tant que vous ne les avez pas tous générés. (À un moment donné, j'ai eu une discussion ici sur le moment d'utiliser les plis droits et le moment d'utiliser des plis gauches stricts, mais cela allait trop loin.)
La version avec
foldl1'
est un pli gauche strictement évalué. C'est-à-dire une fonction récursive de queue avec un accumulateur strict. Cela évalue à(((2)<>2)<>2)<>2
, calculé immédiatement et pas plus tard lorsque cela est nécessaire. (Au moins, il n'y a aucun retard dans le pli lui-même: la liste en cours de pliage est générée ici par une autre fonction qui pourrait contenir une évaluation paresseuse.) Ainsi, le pli calcule(4<>2)<>2
, puis calcule immédiatement8<>2
, puis16
. C'est pourquoi nous avions besoin que l'opération soit associative: nous venons de changer le regroupement des parenthèses!Le pli gauche strict est l'équivalent de ce que fait GCC. Le nombre le plus à gauche dans l'exemple précédent est l'accumulateur, dans ce cas un produit en cours d'exécution. À chaque étape, il est multiplié par le nombre suivant de la liste. Une autre façon de l'exprimer est la suivante: vous parcourez les valeurs à multiplier, en gardant le produit en cours d'exécution dans un accumulateur, et à chaque itération, vous multipliez l'accumulateur par la valeur suivante. Autrement dit, c'est une
while
boucle déguisée.Il peut parfois être rendu aussi efficace. Le compilateur peut peut-être optimiser la structure des données de la liste en mémoire. En théorie, il a suffisamment d'informations au moment de la compilation pour comprendre qu'il devrait le faire ici:
[x]
est un singleton,[x]<>xs
est donc le même quecons x xs
. Chaque itération de la fonction peut être en mesure de réutiliser le même cadre de pile et de mettre à jour les paramètres en place.Un pli droit ou un pli gauche strict pourrait être plus approprié, dans un cas particulier, alors sachez lequel vous voulez. Il y a aussi certaines choses que seul un pli droit peut faire (comme générer une sortie interactive sans attendre toutes les entrées et fonctionner sur une liste infinie). Ici, cependant, nous réduisons une séquence d'opérations à une valeur simple, donc un pli gauche strict est ce que nous voulons.
Ainsi, comme vous pouvez le voir, il est possible d'optimiser automatiquement le module de récursion de queue de n'importe quel semi-groupe (dont un exemple est l'un des types numériques habituels en cours de multiplication) en un pli droit paresseux ou un pli gauche strict, en une seule ligne de Haskell.
Généraliser davantage
Les deux arguments de l'opération binaire ne doivent pas nécessairement être du même type, tant que la valeur initiale est du même type que votre résultat. (Vous pouvez bien sûr toujours retourner les arguments pour qu'ils correspondent à l'ordre du type de pli que vous faites, à gauche ou à droite.) Vous pouvez donc ajouter plusieurs fois des correctifs à un fichier pour obtenir un fichier mis à jour, ou en commençant par une valeur initiale de 1.0, divisez par des nombres entiers pour accumuler un résultat en virgule flottante. Ou ajoutez des éléments à la liste vide pour obtenir une liste.
Un autre type de généralisation consiste à appliquer les plis non à des listes mais à d'autres
Foldable
structures de données. Souvent, une liste liée linéaire immuable n'est pas la structure de données que vous souhaitez pour un algorithme donné. Un problème que je n'ai pas abordé ci-dessus est qu'il est beaucoup plus efficace d'ajouter des éléments au début d'une liste qu'à l'arrière, et lorsque l'opération n'est pas commutative, l'applicationx
à gauche et à droite de l'opération ne l'est pas. le même. Il vous faudrait donc utiliser une autre structure, telle qu'une paire de listes ou un arbre binaire, pour représenter un algorithme qui pourrait s'appliquerx
à droite<>
comme à gauche.Notez également que la propriété associative vous permet de regrouper les opérations d'autres manières utiles, telles que diviser pour mieux régner:
Ou le parallélisme automatique, où chaque thread réduit une sous-gamme à une valeur qui est ensuite combinée avec les autres.
la source
pow(float x, unsigned n)
version gcc.godbolt.org/z/eqwine n'optimise qu'avec-ffast-math
, (ce qui implique-fassociative-math
. La virgule flottante stricte n'est bien sûr pas associative car différentes temporelles = arrondi différent). Le introduit un1.0f * x
qui n'était pas présent dans la machine abstraite C (mais qui donnera toujours un résultat identique). Ensuite, les multiplications n-1 commedo{res*=x;}while(--n!=1)
sont les mêmes que celles récursives, il s'agit donc d'une optimisation manquée.