Dot.Blog

C#, XAML, WinUI, WPF, Android, MAUI, IoT, IA, ChatGPT, Prompt Engineering

Programmer en pensant fonctionnel (F#)–Partie 4

[new:30/08/2014]Quatrième partie de notre voyage au cœur de la programmation fonctionnelle avec F#, nous allons aborder la Curryfication et bien d’autres merveilles ! Suivez le guide…

Curryfication ? WTF ?

En programmation fonctionnelle la curryfication ne consiste pas à transformer un plat du Périgord en spécialité indienne à coup d’épices exotiques. Non. Pas plus qu’un concerné n’est un crétin en état de siège ou qu’un concubin n’est une andouille de nationalité cubaine, la curryfication n’est pas une méthode culinaire destinée à tout transformer en curry.

En réalité on désigne par ce terme (odieusement francisé – Currying en US) une opération qui fait passer une fonction à plusieurs paramètres à une fonction à un seul argument qui retourne une fonction retournant les autres paramètres. Selon Wikipédia hein. C’est pas bien clair je l’accorde. D’autant que pour rester dans le jeu mot à deux balles on pourrait ajouter “et réciproquement” (ou “lycée de versailles” pour les plus gradés dans le calembour de fin de repas). Je n’invente pas puisque le même Wikipédia ajoute “L’opération inverse est évidemment possible”. Moi c’est sur le “évidemment” que j’ai un peu buté mais bon certains jours je suis moins performants que d’autres comme tout le monde à vous cela semblera très certainement limpide…

Néanmoins on n’est pas loin de la recette de cuisine malgré tout ! En effet la curryfication ça sert forcément à quelque chose. Et c’est très utile puisque cela permet de transformer une fonction pas terrible en une “fonction pure” !

Ne me dites pas que vous avez déjà oublié ? Avec tout le mal que je me donne ça me ferait de la peine… Mais si, dans la Partie 2 de “Programmer en pensant fonctionnel (F#)” je parlais justement des fonctions mathématiques à l’origine des langages fonctionnels et des “Avantages des fonctions pures” (cherchez c’est un gros titre dans l’article !).

Résumé rapide pour vous éviter de vous replonger dans la partie 2 :

Les fonctions pures sont des fonctions sans effet de bord qui ne prennent qu’un seul paramètre qu’elles ne peuvent pas modifier.

Et cela a plein d’avantages : la parallélisation simplifiée et totalement naturelle sans rien avoir à faire de plus, l’ordre d’exécution qui ne compte pas (exécution “lazy”), possibilité de mettre un résultat en cache puisque toute entrée donne systématiquement le même résultat…

Le seul petit problème, comme je le soulevais alors, c’est que des fonctions de ce type c’est pas très pratique pour faire des applications un peu sophistiquées…

Du coup on arrive très vite à des fonctions à plusieurs paramètres par exemple, elles perdent leur pureté et les avantages qui vont avec. Il faut donc absolument retrouver la pureté perdue, et ce n’est pas par le Saint Sacrement ni pas la Confession qu’on y arrive mais par la Curryfication !

Mécanisme de la Curryfication

Bref, nous avons une fonction avec plusieurs paramètres et il va falloir la transformer en fonction pure, donc à un seul paramètre.

Le mécanisme va alors consister en pratique à é écrire plusieurs fonctions n’ayant qu’un seul paramètre à la place de l’originale en utilisant plusieurs.

Comment ?

Facile, c’est le compilateur qui le fait pour vous ! C’est pour ça que je pouvais perdre un peu de temps en plaisanteries de supermarché, il n’y a rien à expliquer d’autre que de marquer l’esprit du lecteur pour qu’il sache de quoi il s’agit…

Pour être totalement informatif il faut préciser que l’opération s’appelle le Currying d’après justement le nom du mathématicien Haskell Curry qui a été d’une grande influence dans le développement de la programmation fonctionnelle. C’est un peu comme si plaisanter tout en véhiculant un savoir utile était appelée la dahanification…

Concrètement

Prenons une fonction pas trop difficile à comprendre qui utilise deux paramètres d’entrée :

// version normale
let printA2Paramètres x y = 
   printfn "x=%i y=%i" x y
   
// utilisation
printA2Paramètres  12 13

(*
x=12 y=13

val printA2Paramètres : x:int -> y:int -> unit
val it : unit = ()
*)

 

Comme toujours je place en commentaire la sortie de la console si on exécute le code. Appelée avec les deux paramètres 12 et 13 notre fonction affiche bien les deux valeurs.

Maintenant comment le compilateur se débrouille-t-il pour en faire une fonction “pure” ?

Les mécanismes internes n’étant pas facilement visibles et la décompilation du compilateur n’étant pas compatible avec cet article nous allons juste imaginer comment nous le ferions nous à la main. Et cela donnerait à peu près cela :

//version Curryfier explicitement
let printA2Paramètres x  =    // un seul paramètre !
   let subFunction y = 
      printfn "x=%i y=%i" x y  // sub avec 1 seul paramètre !
   subFunction                 // fin de la sub…
   
// utilsation
let x = 12
let y = 13
let intermédiaire = printA2Paramètres x
let résultat = intermédiaire y

// version inline de la précédente
let résultat2 = (printA2Paramètres x) y

// retour à la syntaxe normale..
let résultat3 = printA2Paramètres x y

 

La sortie console donnant :

x=12 y=13
x=12 y=13
x=12 y=13

Ce qui est normal puisque le code ci-dessus appelle trois fois la fonction de trois façons différentes.

Mais regardons plus en détail comment nous en sommes arrivés à réécrire la fonction, ce qui, je le rappelle, n’est qu’un pur exercice pour comprendre ce que le compilateur fait pour nous (la Curryfication justement).

Tout d’abord nous recréons la fonction printA2Paramètres mais nous ne lui laissons qu’un seul paramètre, le X.

A l’intérieur de cette fonction nous créons une sous-fonction qui elle aussi n’a qu’un seul paramètre, Y. Cette sous-fonction utilise enfin le X et le Y pour produire le résultat. Le X comme dans la plupart des langages est visible par la sous-fonction puisque cette dernière est déclarée à l’intérieur de la première.

Voici comment, à partir d’une fonction utilisant deux paramètres, on peut toujours créer un équivalent étant un assemblage de fonctions à un seul paramètres, chacune étant désormais “pure” avec tous les avantages qui font avec.

Reste à exploiter la fonction réécrite.

Lorsque nous définissons la fonction “intermédiaire” nous la construisons à l’aide de la fonction printA2Paramètres et du paramètre X. “intermédiaire” est donc une “variable” (cela n’existe pas en fonctionnel mais c’est pour simplifier) qui fait référence à une fonction printA2Paramètres empaquetée avec son paramètre X qui a la valeur 12.

Enfin nous utilisons cette “variable” pour appeler le code se trouvant derrière (printA2Paramètres avec le paramètre 12) et en lui passant le paramètre Y.

C’est ce qui créée la première sortie “x=12 y=13” sur la console.

Ensuite nous voyons une version “inline” de l’appel précédent, on utilise cette fois-ci la fonction de base à qui on passe X puis on enrobe tout ça dans des parenthèses, cela devient une seule valeur (une fonction, vous suivez ?) à qui on passe le second paramètre Y.

C’est ce qui affiche la seconde sortie sur la console (identique ce qui est un bon signe !).

Allons plus loin dans la simplification syntaxique et nous pouvons écrire la dernière ligne de l’exemple qui ressemble comme deux gouttes d’eau à la façon d’appeler la première fonction originale à deux paramètres… Et le résultat est toujours le même à la console…

En fait quand on appelle une fonction divisée en sous-fonction avec chacune un paramètre on peut se permettre d’appeler uniquement la première fonction en passant tous les paramètres, le second et les éventuels suivants sont “distribués” dans l’ordre à toutes les sous-fonctions.

Comme ce mécanisme est parfaitement rigoureux et répétable, il est évident qu’il était taillé pour être intégré au compilateur. C’est ce qui a été fait. Cela permet d’utiliser la première version de la fonction, celle avec deux paramètres, tout en gardant les avantages d’une fonction pure à un seul paramètre !

Finalement qu’est ce qui différencie une fonction à un paramètre qui retourne une fonction à un paramètre d’une fonction à deux paramètres ? Grâce à la Curryfication : rien.

Pour se le prouver regardons le code suivant et sa sortie console :

let add x = (+) x
add 2 8

(*
val add : x:int -> (int -> int)
val it : int = 10
*)

let add2p x y = (+) x y
add2p 2 8

(*
val add2p : x:int -> y:int -> int
val it : int = 10
*)

 

Les deux fonctions (“add” et “add2p”) s’utilisent toutes les deux de la même façon, avec deux paramètres, et retourne la même valeur résultat.

La première retourne une fonction préparée avec le 1er paramètre et l’exécute avec le second. La seconde utilise la Curryfication pour obtenir le même résultat.

Les signatures trahissent les légères différences internes. La première fonction a une signature qui nous indique qu’elle prend un entier en entrée et qu’elle retourne une fonction mappant un entier sur un autre. La seconde signature montre une fonction prenant un paramètre entier qui est envoyé à une seconde fonction de même type pour enfin retourner un entier.

Au final cela revient au même. Et c’est pour cette raison que F# autorise une écriture à plusieurs paramètres violant le principe de la fonction pure tout en faisant que ce principe ne soit jamais violé grâce à la Curryfication…

Au-delà de deux paramètres ?

Cela fonctionne aussi bien entendu et selon le même principe. On imagine clairement que la Curryfication aide à rendre le code largement plus lisible… S’il fallait écrire toutes les fonctions intermédiaires cela serait difficilement utilisable pour produire des applications.

Toutefois ne nous leurrons pas, comme tout ce qui est automatique il y a parfois des désagréments…  Par exemple il faut se rappeler que F# autorise l’appel à une procédure avec moins de valeurs d’entrée que ce qui est prévu au départ. Disons appeler une fonction à 3 paramètres avec seulement 2 paramètres.

Là ou C# hurlera au bug dès la compilation, F# ne dira rien. Il est tout à fait “légal” de passer moins de valeurs en paramètre. C’est le résultat qui sera différent. Avec tous les paramètres ce dernier sera ce à quoi on s’attend généralement, avec moins de paramètres la valeur retournée sera une fonction partiellement traitée. Cela peut servir mais si c’est un simple oubli et qu’on tente de servir du résultat dans un contexte qui attend une valeur, alors là il y aura un bogue, pas forcément évident à retrouver.

Trop de paramètres tue le paramètre !

Dans le même état d’esprit, si on tente de passer plus de paramètres que le nombre attendu on obtiendra une erreur assez biscornue indiquant que la paramètre supplémentaire n’est pas une fonction et qu’il ne peut pas être appliqué. Ce qui est très sibyllin !

Par exemple :

let add1 x = x + 1
let x = add1 2 3
// ==>   error FS0003: This value is not a function 
//          and cannot be applied

 

Tout cela parce que “add1” est une fonction qui ne prend qu’un paramètre et qu’on tente de l’appeler avec 2 paramètres. Le message d’erreur n’est pas plus simple que dans le cas précédent (pas assez de paramètres).

Lorsque l’on connait la Curryfication on comprend tout de même mieux ce qui se passe, car si on décompose notre appel comme nous l’avons fait tout à l’heure pour simuler la Curryfication (ce qui est obligatoire ici puisque nous appelons avec 2 paramètres) cela donnera :

let add1 x = x + 1
let intermediateFn = add1 2   //returne une valeur simple
let x = intermediateFn 3      //intermediateFn n'est pas une fonction !
// ==>   error FS0003: This value is not a function 
//                     and cannot be applied

 

Et oui, la fonction intermédiaire n’est pas une fonction, c’est une simple valeur puisque “add1 2” retourne la valeur “3” et non pas une fonction. Du coup se servir de cette valeur intermédiaire comme d’une fonction pour appliquer le second paramètre ne passe pas.

Le message d’erreur devient alors clair : ce n’est pas une fonction et elle ne peut pas être appliquée…

Conclusion (partielle)

F# n’a pas que des avantages, oublier un paramètre ou en mettre un de trop est une erreur possible. Au lieu d’obtenir immédiatement une erreur de compilation claire, la souplesse de F# et sa logique mathématique traitant tout comme des fonctions ou des valeurs rend l’erreur plus sournoise, le message plus difficile à comprendre. En C# ces deux cas ne passent pas la compilation et c’est tout. Clair et .NET Sourire

F# n’est pas une panacée, ceux qui l’affirment se trompent. En revanche sa logique fonctionnelle est un excellent exercice de remise en question de sa façon de programmer et une voie très intéressante pour progresser.

Certains apprécieront tellement les avantages de F# qu’ils voudront travailler uniquement dans ce langage, d’autres se contenteront d’être boostés par son apprentissage et enfin certains utiliserons F# ponctuellement lorsque cela leur semblera plus adapté que C#.

Je n’ai pas de conseil à donner sur “le meilleur choix”. Le meilleur choix est celui dans lequel on se sent le plus à l’aise et où on produit le meilleur code.

N’oublions pas qu’en production le meilleur code n’est pas seulement “bon” techniquement mais qu’il doit aussi être maintenable…

A bientôt pour la partie 5 !

Stay Tuned !

Faites des heureux, PARTAGEZ l'article !