Je suis initié à la programmation fonctionnelle [FP] (en utilisant Scala). Une chose qui ressort de mes premiers apprentissages est que les PF s'appuient fortement sur la récursivité. Et il semble aussi que, dans purs FPs, la seule façon de faire des choses itératives est d'écrire des fonctions récursives.
Et à cause de l'utilisation intensive de la récursivité, il semble que la prochaine chose dont les FPs devaient s'inquiéter était StackoverflowExceptions
typiquement due à de longs appels récursifs. Ce problème a été résolu en introduisant quelques optimisations (optimisations liées à la récursivité de queue dans la maintenance des stackframes et @tailrec
annotation de Scala v2.8 et suivantes)
Quelqu'un peut-il s'il vous plaît m'éclairer pourquoi la récursivité est si importante pour le paradigme de la programmation fonctionnelle? Y a-t-il quelque chose dans les spécifications des langages de programmation fonctionnels qui est "violé" si nous faisons des choses de manière itérative? Si oui, je tiens à le savoir également.
PS: Notez que je suis novice en programmation fonctionnelle, alors n'hésitez pas à me diriger vers les ressources existantes si elles expliquent/répondent à ma question. Je comprends aussi que Scala en particulier fournit également un support pour faire des choses itératives.
Thèse de Church Turing met en évidence l'équivalence entre différents modèles de calculabilité.
En utilisant la récursivité, nous n'avons pas besoin d'un état mutable tout en résolvant un problème , et cela permet de spécifier une sémantique en termes plus simples. Ainsi, les solutions peuvent être plus simples, au sens formel.
Je pense que Prolog montre mieux que les langages fonctionnels l'efficacité de la récursivité (il n'a pas d'itération), et les limites pratiques que nous rencontrons lors de son utilisation.
Une programmation fonctionnelle pure signifie une programmation sans effets secondaires. Ce qui signifie que si vous écrivez une boucle par exemple, le corps de votre boucle ne peut pas produire d'effets secondaires. Ainsi, si vous voulez que votre boucle fasse quelque chose, elle doit réutiliser le résultat de l'itération précédente et produire quelque chose pour l'itération suivante. Ainsi, le corps de votre boucle est une fonction, prenant comme paramètre le résultat de l'exécution précédente et s'appelant pour l'itération suivante avec son propre résultat. Cela n'a pas un énorme avantage sur l'écriture directe d'une fonction récursive pour la boucle.
Un programme qui ne fait pas quelque chose de trivial devra itérer sur quelque chose à un moment donné. Pour la programmation fonctionnelle, cela signifie que le programme doit utiliser des fonctions récursives.
La fonctionnalité qui entraîne la exigence que vous faites les choses de manière récursive est des variables immuables.
Considérons une fonction simple pour calculer la somme d'une liste (en pseudocode):
fun calculateSum(list):
sum = 0
for each element in list: # dubious
sum = sum + element # impossible!
return sum
Maintenant, le element
dans chaque itération de la liste est différent, mais nous pouvons réécrire ceci pour utiliser une fonction foreach
avec un argument lambda pour se débarrasser de ce problème:
fun calculateSum(list):
sum = 0
foreach(list, lambda element:
sum = sum + element # impossible!
)
return sum
Néanmoins, la valeur de la variable sum
doit être modifiée à chaque exécution du lambda. Ceci est illégal dans un langage avec des variables immuables, vous devez donc le réécrire de manière à ne pas muter l'état:
fun calculateSum([H|T]):
return H + calculateSum(T)
fun calculateSum([]):
return 0
Maintenant, cette implémentation nécessitera beaucoup de pousser et de sortir de la pile d'appels, et un programme où toutes les petites opérations le feraient ne fonctionnerait pas très rapidement. Par conséquent, nous le réécrivons pour qu'il soit récursif de queue, afin que le compilateur puisse faire une optimisation des appels de queue:
fun calculateSum([H|T], partialSum):
return calculateSum(T, H + partialSum)
fun calculateSum([], partialSum):
return partialSum
fun calculateSum(list):
return calculateSum(list, 0)
Bien sûr, si vous voulez boucler indéfiniment, vous avez absolument besoin d'un appel récursif de queue, sinon cela entraînerait un débordement de pile.
L'annotation @tailrec
Dans Scala est un outil pour vous aider à analyser quelles fonctions sont récursives en queue. Vous déclarez "Cette fonction est récursive à la queue" et le compilateur peut vous dire si vous vous trompez. Ceci est particulièrement important dans Scala par rapport aux autres langages fonctionnels car la machine sur laquelle elle s'exécute, la JVM, ne prend pas bien en charge l'optimisation des appels de fin, il n'est donc pas possible d'obtenir l'optimisation des appels de fin dans Scala dans toutes les mêmes circonstances, vous l'obtiendrez dans d'autres langages fonctionnels.
TL; DR: la récursivité est utilisée pour gérer les données définies de manière inductive, ce qui sont omniprésents.
La récursivité est naturelle lorsque vous opérez à des niveaux d'abstraction plus élevés. La programmation fonctionnelle ne consiste pas seulement à coder avec des fonctions; il s'agit d'opérer à des niveaux d'abstraction plus élevés, où vous utilisez naturellement des fonctions. En utilisant des fonctions, il est naturel de réutiliser la même fonction (pour la rappeler), quel que soit le contexte où cela a du sens.
Le monde est construit par la répétition de blocs de construction similaires/identiques. Si vous coupez un morceau de tissu en deux, vous avez deux morceaux de tissu. L'induction mathématique est au cœur des mathématiques. Nous, les humains, comptons (comme dans, 1,2,3 ...). N'importe quel défini de manière inductive chose (comme, {nombres de 1} sont {1, et nombres de 2} ) est naturel à manipuler/analyser par une fonction récursive, selon les mêmes cas par lesquels cette chose est définie/construite.
La récursivité est partout. Toute boucle itérative est de toute façon une récursion déguisée, car lorsque vous entrez à nouveau dans cette boucle, vous entrez à nouveau la même chose boucle (juste avec peut-être différentes variables de boucle). Donc ce n'est pas comme inventer de nouveaux concepts sur l'informatique, c'est plus comme découvrir les fondations, et les rendre explicites.
Ainsi, la récursivité est naturelle. Nous écrivons simplement quelques lois sur notre problème, des équations impliquant la fonction que nous définissons qui préservent un invariant (sous l'hypothèse que la fonction est définie de manière cohérente), en spécifiant à nouveau le problème en termes simplifiés, et le tour est joué! Nous avons la solution.
Un exemple, une fonction pour calculer la longueur de la liste (un type de données récursif défini de manière inductive). Supposons qu'il est défini et renvoie la longueur de la liste, sans surprise. Quelles sont les lois auxquelles il doit obéir? Quel invariant est conservé sous quelle simplification d'un problème?
Le plus immédiat est de séparer la liste en son élément head, et le reste - c'est-à-dire la queue de la liste (selon la façon dont une liste est définie/construite). La loi est,
length (x:xs) = 1 + length xs
D'uh! Mais qu'en est-il de la liste vide? ça doit être ça
length [] = 0
Alors, comment écrire une telle fonction? ... Attendez ... Nous l'avons déjà écrite! (Dans Haskell, si vous vous demandiez, où l'application de la fonction est exprimée par juxtaposition, les parenthèses sont utilisées uniquement pour le regroupement, et (x:xs)
est une liste avec x
son premier élément, et xs
le reste).
Tout ce dont nous avons besoin d'un langage pour permettre un tel style de programmation est qu'il ait TCO (et peut-être, un peu luxueusement, TRMCO ), donc il n'y a pas d'explosion de pile, et nous sommes prêts.
Une autre chose est la pureté - l'immuabilité des variables de code et/ou de la structure des données (champs des enregistrements, etc.).
Ce que cela fait, en plus de libérer notre esprit d'avoir à suivre ce qui change quand, c'est que cela rend le temps explicitement apparent dans notre code, au lieu de se cacher dans nos variables/données mutables "changeantes". On ne peut "changer" dans le code impératif que la valeur d'une variable à partir de maintenant - on ne peut pas très bien changer sa valeur dans le passé, n'est-ce pas?
Nous nous retrouvons donc avec des listes d'historique des modifications enregistrées, avec des modifications explicitement apparentes dans le code: au lieu de x := x + 2
nous écrivons let x2 = x1 + 2
. Cela rend le raisonnement sur le code tellement plus facile.
Pour aborder l'immuabilité dans le contexte de la récursivité de queue avec TCO , considérez cette réécriture récursive de queue de la fonction ci-dessus length
sous le paradigme d'argument d'accumulateur :
length xs = length2 0 xs -- the invariant:
length2 a [] = a -- 1st arg plus
length2 a (x:xs) = length2 (a+1) xs -- length of 2nd arg
Ici, TCO signifie réutilisation des trames d'appel, en plus du saut direct, et donc la chaîne d'appels pour length [1,2,3]
peut être considéré comme une mutation des entrées de la trame de la pile d'appel correspondant aux paramètres de la fonction:
length [1,2,3]
length2 0 [1,2,3] -- a=0 (x:xs)=[1,2,3]
length2 1 [2,3] -- a=1 (x:xs)=[2,3]
length2 2 [3] -- a=2 (x:xs)=[3]
length2 3 [] -- a=3 (x:xs)=[]
3
Dans un langage pur, sans aucune primitive de mutation de valeur, la seule façon d'exprimer un changement est de passer des valeurs mises à jour comme arguments à une fonction, pour être traitées plus loin. Si le traitement ultérieur est le même que précédemment, nous devons naturellement invoquer la même fonction pour cela, en lui passant les valeurs mises à jour comme arguments. Et c'est la récursivité.
Et ce qui suit fait toute l'histoire du calcul de la longueur d'une liste d'arguments explicite (et disponible pour une réutilisation, si nécessaire):
length xs = last results
where
results = length3 0 xs
length3 a [] = [a]
length3 a (x:xs) = a : length3 (a+1) xs
Dans Haskell, cela est connu sous le nom de récursivité gardée, ou corecursion (du moins je pense que c'est le cas).
Il y a deux propriétés que je considère comme essentielles à la programmation fonctionnelle:
Les fonctions sont des membres de première classe (seulement pertinentes, car pour le rendre utile, la deuxième propriété est nécessaire)
Les fonctions sont pures, c'est-à-dire qu'une fonction appelée avec les mêmes arguments renvoie la même valeur.
Maintenant, si vous programmez dans un style impératif, vous devez utiliser l'affectation.
Considérez une boucle for. Il a un index et à chaque itération, l'index a une valeur différente. Vous pouvez donc définir une fonction qui renvoie cet index. Si vous appelez cette fonction deux fois, vous pourriez obtenir des résultats différents. Brisant ainsi le principe n ° 2.
Si vous enfreignez le principe n ° 2, le fait de transmettre des fonctions (principe n ° 1) devient quelque chose d'extrêmement dangereux, car maintenant le résultat de la fonction peut dépendre du moment et de la fréquence à laquelle une fonction est appelée.
Il n'y a rien de "spécial" dans la récursivité. C'est un outil répandu en programmation et en mathématiques et rien de plus. Cependant, les langages fonctionnels sont généralement minimalistes. Ils introduisent de nombreux concepts fantaisistes comme la correspondance de motifs, le système de types, la compréhension de liste, etc., mais ce n'est rien de plus que du sucre syntaxique pour des outils très généraux et très puissants, mais simples et primitifs. Ces outils sont: abstraction de fonction et application de fonction. C'est un choix conscient, car la simplicité du noyau du langage facilite le raisonnement à ce sujet. Cela facilite également l'écriture des compilateurs. La seule façon de décrire une boucle en termes de ces outils est d'utiliser la récursivité, donc les programmeurs impératifs peuvent penser que la programmation fonctionnelle concerne la récursivité. Ce n'est pas, il est simplement nécessaire d'imiter ces boucles fantaisistes pour les pauvres qui ne peuvent pas laisser tomber ce sucre syntaxique sur l'instruction goto
et c'est donc l'une des premières choses dans lesquelles ils se sont collés.
Un autre point où la récursivité (peut être indirecte) requise est le traitement de structures de données définies de manière récursive. L'exemple le plus courant est list
ADT. Dans FP, il est généralement défini comme ceci data List a = Nil | Branch a (List a)
. Puisque la définition de l'ADT ici est récursive, la fonction de traitement doit être récursive également. Encore une fois, la récursivité ici n'est pas de toute façon spécial: le traitement d'un tel ADT de manière récursive semble naturellement à la fois dans les langages impératifs et fonctionnels.
Il n'y a donc rien de spécial dans la récursivité. C'est simplement un autre type d'application de fonction. Cependant, en raison des limitations des systèmes informatiques modernes (qui proviennent de décisions de conception mal prises en langage C, qui est de facto un assembleur multiplateforme standard), les appels de fonction ne peuvent pas être imbriqués à l'infini, même s'il s'agit d'appels de queue. Pour cette raison, les concepteurs de langages de programmation fonctionnels doivent soit limiter les appels de queue autorisés à la récursivité de queue (scala), soit utiliser des techniques compliquées comme le trampoling (ancien code ghc) ou compiler directement en asm (code ghc moderne).
TL; DR: Il n'y a rien de spécial dans la récursivité dans FP, pas plus que dans IP au moins, cependant, la récursivité de queue est le seul type d'appels de queue autorisés dans scala en raison des limitations de JVM .
Pour les nouveaux apprenants FP apprenants, j'aimerais ajouter mes 2 cents. Comme mentionné dans certaines des réponses, la récursivité est de faire usage de variables immuables, mais pourquoi devons-nous faire cela? c'est parce que cela facilite l'exécution du programme sur plusieurs cœurs en parallèle, mais pourquoi nous le voulons? Ne pouvons-nous pas l'exécuter en single core et être heureux comme nous l'avons toujours été? Non parce que le contenu à traiter augmente de jour en jour et le cycle d'horloge du processeur ne peut pas être augmenté de manière aussi significative que d'ajouter plus de cœurs.Depuis une décennie, la vitesse d'horloge a été d'environ 2,7 GHz à 3,0 GHz pour les ordinateurs grand public et les concepteurs de puces ont des problèmes pour installer de plus en plus de transistors dans leur. Aussi FP a été leur depuis très longtemps, mais n'a pas repris car il utilisait la récursivité et la mémoire était très chère à l'époque, mais comme la vitesse d'horloge montait en flèche d'année en année, la communauté a décidé de continuez avec OOP Edit: c'était assez rapide, je n'avais que quelques minutes
La dernière fois que j'ai utilisé un langage fonctionnel (Clojure), je n'ai même jamais été tenté d'utiliser la récursivité. Tout pouvait être traité comme un ensemble de choses auxquelles une fonction était appliquée pour obtenir des produits partiels, auxquels une autre fonction était appliquée, jusqu'à ce que le résultat final soit atteint.
La récursivité n'est qu'un moyen, et pas nécessairement le plus clair, de gérer les multiples éléments que vous devez généralement gérer pour gérer n'importe quel g