Dot.Blog

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

Programmer en pensant fonctionnel (F#)–Partie 5

[new:30/08/2014]Continuons cette série sur F# et l’esprit de la programmation fonctionnelle. Plat du jour pour cette 5ème partie : l’application partielle de fonction.

Appliquer une fonction partiellement

flat-27248_640Dans l’épisode précédent, partie 4, nous avons vu comment la curryfication permettait de transformer une fonction à plusieurs paramètres en une fonction à 1 seul paramètre. En réalité le mécanisme - réalisé automatiquement par le compilateur - découpe une fonction à “n” paramètres en une fonction à 1 paramètre dans laquelle s’imbriquent “n-1” sous-fonctions elles aussi à 1 seul paramètre. Lorsqu’on appelle la fonction avec plusieurs paramètres ces derniers sont en quelque sorte distribués à la fonction principale et aux sous-fonctions  dans l’ordre de leur écriture (celui des paramètres et des sous-fonctions).

Nous avons vu à cette occasion les avantages qu’on pouvait tirer des fonctions dites “pures”, c’est à dire à un seul paramètre non modifiable. On se rappellera en outre que ces fonctions pures ne peuvent en aucun cas avoir d’effet de bord ce qui est fondamental en programmation fonctionnelle.

Mais ce mécanisme de curryfication et le principe des fonctions pures permet aussi quelque chose de plus qui en renforce l’intérêt : l’application partielle de la fonction.

Il est essentiel de bien comprendre ce principe car il se trouve à la base de l’esprit de la programmation fonctionnelle.

La règle est simple à comprendre finalement, c’est “penser fonctionnel” en l’appliquant qui devient plus compliqué et qui réellement marque une différence fondamentale avec l’orienté objet. Que nous dit cette règle ? Qu’une fonction possédant “n” paramètres peut toujours être appelée en lui passant moins de “n” valeurs.

Que se passe-t-il alors ? Au lieu de retourner un résultat (une valeur) la fonction renvoie alors une autre fonction possédant les paramètres passés “codés en dur” et pouvant accepter les paramètres manquant.

Exemples

Si vous vous rappelez de l’article précédent vous trouverez l’utilisation des fonctions partielles très logique. Mais quelques exemples nous permettra d’y voir plus clair.

// Un additionneur créé en appliquant
// partiellement une addition
let add50 = (+) 50    // application partielle
printfn "50+10 = %i" (add50 10)
printfn "50+33 = %i" (add50 33)

// création d'une liste en applicant add50 
// à chaque élément
[10;20;30] |> List.map add50 

// création d'un testeur en appliquant partiellement
// une opération "2 est inférieur à"
let DeuxEstInférieurA = (<) 2   // application partielle
DeuxEstInférieurA 1
DeuxEstInférieurA 3

// Filtrer une liste en utilisant DeuxEstInférieurA
[1;2;3] |> List.filter DeuxEstInférieurA |> printfn " items filtrés : %A"

// création d'un "printer" par application partielle
// de printfn
let printer = printfn "Valeur du paramètre = %i" 

// Boucler sur une liste et appliquer le "printer"
[1;2;3] |> List.iter printer

(*
50+10 = 60
50+33 = 83
 items filtrés : [3]
Valeur du paramètre = 1
Valeur du paramètre = 2
Valeur du paramètre = 3

val add50 : (int -> int)
val DeuxEstInférieurA : (int -> bool)
val printer : (int -> unit)
val it : unit = ()

*)

 

Reprenons tout cela calmement… Vous remarquerez d’abord que comme dans les articles précédents je place le résultat de la console en commentaire à la fin du code source. Et vous vous rappellerez qu’ici j’utilise le plus souvent l’utilitaire Tsunami au lieu de Visual Studio et sa console F#, ce qui ne fait aucune différence sauf que Tsunami est plus léger à lancer que VS et qu’il se prête mieux à des petits tests ce qui est parfait pour écrire un article (mais pas pour programmer, dans ce cas utilisez Visual Studio ou Xamarin Studio).

Add50

Le premier exemple définit une fonction Add50 qui ajoute 50 à un paramètre que nous ne spécifions pas. Nous appliquons donc partiellement l’addition. Le “résultat” de Add50 n’est donc pas une valeur mais une fonction qui attend le paramètre manquant.

De fait nous pouvons ensuite appeler Add50 avec les valeurs 10 ou 33 (le tout dans un printfn pour générer une sortie à la console) ce qui nous donne bien les valeurs 60 et 83 (en début de commentaire en fin du code source).

Comme vous le constatez ce n’est finalement pas bien compliqué, cela permet de “cuisiner” des fonctions à l’avance et de les utiliser simplement ensuite. Un peu comme on prépare lentement un bon plat qu’on congèle afin de pouvoir le déguster rapidement après un passage au micro-onde. L’analogie n’est pas très subtile mais elle fonctionne…

Application de Add50 à une liste

Le second exemple illustre la versatilité des fonctions partielles puisqu’après avoir utilisé Add50 pour obtenir des résultats simple nous l’utilisons pour l’appliquer à chaque élément d’une liste d’entiers.

La liste (une constante liste) est passée à la fonction List.map qui mappe chaque élément d’une liste à l’étendue de sortie via une fonction passée en paramètre. L’utilisation du pipe (formé du pipe | – Alt 124 – et du symbole > ou < selon le sens d’utilisation) permet simplement d’écrire la ligne dans l’ordre logique de pensée (à l’envers d’un passage en paramètre donc).

La fonction passée en paramètre est justement Add50. Et pour que le résultat de ce traitement soit lisible on réutilise un pipe pour passer la liste résultat à printfn.

En utilisant la liste 10, 20, 30 on obtient en toute logique la nouvelle liste 60, 70, 80. Le printfn n’est pas dans le code ci-dessus mais si vous l’ajoutez vous-mêmes (petit exercice que j’ai glissé discrètement !) vous obtiendrez la sortie suivante :

Items additionnés de 50 : [60; 70; 80]

Exemples suivants

Le principe reste toujours le même je vais donc faire court.

La fonction DeuxEstInférieurA est créée en appliquant partiellement l’opération “2 <” à un paramètre manquant exactement comme dans le cas de Add50. La différence est qu’ici nous obtiendrons un résultat booléen qui sera vrai si 2 est inférieur au paramètre passé.

On utilise ensuite la fonction partielle en lui passant les valeurs 1 et 3. Là aussi je n’ai pas placé le printfn pour que vous puissiez le faire vous-mêmes… Il n’y a donc rien à la console pour l’instant. Si on ajoute cet affichage on obtiendra les sorties respectives suivantes False et True. En effet “2 < 1” est une proposition fausse, alors que “2 < 3” est vraie.

Selon le principe du traitement de liste avec Add50 on utilise ensuite DeuxEstInférieurA pour générer une liste à partir d’un filtrage d’une première liste. Ici le printfn existe et vous pouvez voir à la console la sortie indiquant “Items filtrés : [3]”. Des trois valeurs de la liste originale (1, 2 et 3) seul le trois passe le filtre, c’est donc bien une liste qui est retournée mais elle ne contient ici qu’un seul élément.

On peut broder sur l’application partielle à l’infini. Le dernier exemple créée une application partielle de printfn qui est ensuite utilisée pour afficher un à un les éléments d’une liste (par la fonction d’itération des listes).

Bref on pourrait en écrire comme cela pendant des siècles…

Extension du principe

Ce que l’on fait pour une fonction et un paramètre on peut bien entendu l’étendre à toute fonction partielle déjà existante qui elle-même sera utilisée pour définir une fonction partielle etc… C’est le principe des poupées russes.

De façon la plus simple si on imagine une application partielle qui ajoute 1 à son paramètre manquant, on peut ensuite définir une autre application partielle qui applique cette fonction à tous les éléments d’une liste qui devient le paramètre manquant du second niveau d’application partielle. Enfin on peut utiliser cette fonction pour traiter une liste passée en paramètre. Dis comme ça c’est un peu indigeste, mais c’est à dessein, car regardez la puissance de F# et comment une poignée d’instructions effectue de façon claire un tel traitement :

let add1 = (+) 1
let add1ToEach = List.map add1
add1ToEach [1;2;3;4]

 

Je vous laisse ajouter le printfn pour vérifier à la console que le résultat est bien une autre liste dont les éléments sont 2, 3, 4 et 5.

On peut aller un cran plus loin en utilisant l’application partielle de fonction pour fixer des paramètres fonctions. Imaginons un filtre de liste ne retournant que les éléments pairs et exécutons-le sur la liste des entiers de 1 à 4 :

let FiltrerPairs = 
   List.filter (fun i -> i%2 = 0)
FiltrerPairs [1;2;3;4] |> List.iter printer

 

Ici je réutilise la fonction partielle “printer” vue plus haut pour afficher chaque élément de la nouvelle liste retournée ce qui donne à la console :

Valeur du paramètre = 2
Valeur du paramètre = 4

Et en effet seuls 2 et 4 sont pairs… Le texte n’est pas très bien adapté mais c’est encore à dessein : lorsqu’on “fabrique” des fonctions partielles il faut bien penser aux utilisations futures ! Ici le texte formaté de “printer” était parfait pour la première utilisation mais sa réutilisation ne colle pas tout à fait au besoin – plutôt “valeur du paramètre” il faudrait afficher “valeur de l’élément”. Tout l’intérêt (ou une grande partie) de F# se trouvant dans la réutilisabilité des fonctions partielles il est essentiel de les définir en gardant ces usages futurs (et souvent inconnus) en tête. Dans notre exemple “printer”, si nous avions voulu conserver son caractère universel et réutilisable devrait soit accepter un texte pour le formatage en paramètre (masi cela ferait perdre de l’intérêt à sa définition) soit afficher quelque chose de plus “neutre” comme “valeur : “ tout simplement. Ici ce n’est que du texte car cela est simple à démontrer, mais lorsqu’il s’agit réellement de code le même problème de généralisation se pose et doit être constamment gardé à l’esprit.

La programmation fonctionnelle se base sur des fonctions, il est normal que leur définition soit subtile pour autoriser toutes les constructions permettant de traiter tous les problèmes. Il est donc tout aussi normal de trouver certaines questions cruciales qui se poseront au développeur au moment même de la définition des fonctions. En cela on voit très nettement le changement de paradigme. Dans la programmation orientée objet ce sont les objets qui sont au cœur du processus de développement, plus exactement les classes d’ailleurs. Et on le sait, c’est lorsqu’on définit les classes et les héritages que se posent les questions les plus essentielles dont les réponses pèseront sur toute l’architecture du logiciel ensuite.

On a bien ici un parallèle entre d’une part la programmation orientée objet dont les principales questions se posent au sujet des objets (des classes) et d’autre part la programmation fonctionnelle dans laquelle le même niveau d’interrogation et la même importance des réponses se situe dans la définition des fonctions.

Application partielle vs paramètres par défaut

A un moment ou un autre vous risquez de vous poser la question suivante – éventuellement en termes plus ou moins similaires ou non : quelle différence y-a-t-il in fine entre la définition de fonctions partielles sous F# et celle de méthodes avec valeur par défaut ou nombre de paramètre variable de C# ?

On peut en effet avoir une vision trop simple de ce mécanisme d’application partielle qui donne l’impression qu’on en fait des tonnes pour un principe bien rodé en C#. Après tout définir une fonction dont certains paramètres sont manquants ne revient-il pas à peu de choses près à définir une méthode acceptant un nombre variables de paramètres ou possédant des valeurs par défaut ?

Dans les deux cas on peut en effet appeler le code (fonction ou méthode) avec un nombre de paramètres inférieur à celui prévu initialement.

Toutefois on s’aperçoit bien vite des différences essentielles. Pour n’en citer que quelques unes :

  • Une méthode appartient à un objet / classe qui doit être défini voire construit pour permettre son utilisation. Une fonction existe seule sans aucune dépendance autres que celles qu’on a bien voulu créer avec d’autres fonctions.
  • Une méthode acceptant un nombre variables de paramètres (mot clé params) n’est que de très loin équivalente à une fonction partielle. Et plus on se rapproche plus cela n’a rien à voir du tout ! Je vous laisse réfléchir à la nature des deux mécanismes.
  • Une méthode proposant des valeurs par défaut pour ses paramètres et pouvant donc être appelée avec un nombre de paramètres inférieur à ceux prévus fixe automatiquement les valeurs des paramètres manquant (c’est le but d’ailleurs) et ces valeurs sont figées par programmation et uniques. Le résultat d’un appel à une telle méthode reste ce qui a été prévu (un entier, une chaine…). Une fonction partielle n’impose aucune valeur par défaut pour les paramètres manquants, elle ne les évoque même pas dans son code, c’est même l’inverse elle fixe tous les paramètres sauf les manquants ! Le résultat d’une application totale d’une fonction est une valeur (qui peut être éventuellement une valeur fonction), le résultat d’une application partielle de fonction est obligatoirement une autre fonction.

 

La question de départ n’est pas stupide, d’ailleurs on le sait bien aucune question n’est bête, seules certaines réponses le sont. Il est naturel de s’interroger, d’une façon ou d’une autre, sur les similarités entre la programmation fonctionnelle et la programmation orientée objet. Nous sommes tellement imprégnés par cette dernière qu’on a tendance à la retrouver partout. Comme on avait l’impression de retrouver de l’impératif simple des langages non objet dans les méthodes des objets. Avec la pratique les nuances entre ces diverses formes de langages deviennent de plus en plus évidentes.

Au début on veut bien admettre qu’il y a une différence pour ne pas fâcher ou passer pour un idiot mais dans son for intérieur on reste persuadé que la différence est purement syntaxique et stylistique. A la fin il devient évident que les différences sont réelles et que la syntaxe et la stylistique ne jouent aucun rôle, c’est bien une façon différente de penser qui s’impose. C’est à ce moment précis qu’on devient un développeur capable d’écrire du code suivant le nouveau paradigme.

Tout est question d’ordre

Lorsque l’on définit des fonctions F# il faut d’emblée penser en décomposant le problème en fonctions élémentaires qui seront réutilisées en mode partiel.

Or l’ordre des paramètres n’est pas innocent dans un tel contexte.

Autant l’ordre des paramètres d’une méthode objet n’a que peu d’importance en dehors de la clarté du code et sa lisibilité autant l’ordre des paramètres d’une fonction F# va conditionner sa possible utilisation future en mode partiel. Et comme c’est cette forme d’application partielle qui fait en partie la puissance du fonctionnel on en arrive très vite à la conclusion que l’ordre des paramètres des fonctions est essentiel…

On revient ici à ce que je disais à propos des questionnements et des réponses qui diffèrent entre OOP et fonctionnel : en OOP on raisonne “object centric”, en fonctionnel on pense “function centric”.

Pour bien comprendre l’importance de l’ordre des paramètres prenons tout simplement les fonctions prédéfinies comme celles des listes. La forme choisie est la suivante :

Fonction-liste [paramètre(s) fonction] [list]

La liste qui doit être traitée est placée à la fin. La fonction à appliquer est indiquée en premier. Il est évident que pour effectuer des applications partielles il faut “dégager” l’espace autour des données qui seront vraisemblablement la partie la plus variable. En plaçant la liste à traiter en fin de définition on se laisse la possibilité de définir des fonctions partielles fixant la fonction paramètre mais laissant “ouvert” le champ pour les données.

Le traitement des listes dans F# offre de nombreuses fonctions intéressantes dont nous avons déjà vu quelques représentantes comme List.iter, List.map, List.filter… Ces fonctions peuvent être utilisées en fixant l’ensemble de leurs paramètres, exemple :

List.map    (fun i -> i+1) [0;1;2;3]
List.filter (fun i -> i>1) [0;1;2;3]
List.sortBy (fun i -> -i ) [0;1;2;3]

 

La première utilisation mappe une fonction sur une liste (de 0 à 3). Cette fonction ajoute 1 à son paramètre. List.map va ainsi effectuer une application au sens mathématique entre l’ensemble des entiers de 0 à 3 et l’étendue de sortie, une nouvelle liste d’entiers mis en correspondance via la fonction paramètre.

La seconde utilisation montre List.filter qui utilise une fonction paramètre booléenne pour filtrer une liste source et retourner une nouvelle liste qui ne contient que les éléments pour lesquels la fonction paramètre retourne vrai. Là encore la liste à traiter se trouve en fin de ligne.

La troisième utilisation montre List.sortBy qui trie une liste en utilisant une fonction booléenne. La liste originale à traiter est encore une fois le dernier élément.

Grâce à ce choix dans la définition des fonctions de List, il nous est possible recréer les mêmes résultats que les trois exemples ci-dessus mais cette fois-ci en exploitant des fonctions partielles :

let eachAdd1 = List.map (fun i -> i+1) 
eachAdd1 [0;1;2;3]

let excludeOneOrLess = List.filter (fun i -> i>1) 
excludeOneOrLess [0;1;2;3]

let sortDesc = List.sortBy (fun i -> -i) 
sortDesc [0;1;2;3]

 

Si les fonctions originales n’avaient pas prévu que placer la liste à traiter en dernier il serait bien difficile, pour ne pas dire impossible, d’utiliser l’application partielle sur ces fonctions de traitement liste et c’est tout l’intérêt même du langage et de ses possibilités qui en auraient souffert…

Il est donc absolument nécessaire de bien envisager la réutilisation des fonctions qu’on écrit en F# car l’application partielle ne peut être envisagée que si le choix de l’ordre des paramètres est pertinent pour une telle utilisation future.

Créer des fonctions F# répond à deux nombreux impératifs, mais quelques guidelines de base se dégagent :

  • Les premiers paramètres sont ceux qui ont toutes les chances d’être statiques
  • Les derniers paramètres sont les structures de données ou les collections ou les éléments les plus variables
  • Pour les opérations bien connues (comme l’addition) on indique les paramètres dans l’ordre attendu

 

La première règle est finalement très simple : on place en premier les paramètres qui auront le plus de chance de se trouver fixer “en dur” dans la définition d’une application partielle. Nous avons vu des exemples où cela transparait de façon évidente.

La seconde règle permet de passer facilement, notamment par le pipe, une collection ou autre structure de données de fonction en fonction dans une chaine d’exécution. En se basant sur des éléments F# déjà présentés on peut imaginer l’exemple suivant que vous comprendrez facilement :

let result = 
   [1..10]
   |> List.map (fun i -> i+1)
   |> List.filter (fun i -> i>5)

 

Le résultat “result” passe la liste des entiers de 1 à 10 par un pipe à la fonction List.map définie partiellement avec sa fonction paramètre (mais pas son dernier paramètre qui représente les données). Un mapping de chaque élément de la liste 1 à 10 sera ainsi réalisé avec la fonction (fun i –> i+1) ce qui créera une nouvelle liste dont les valeurs seront 2 à 11. Puis on pipe encore une fois pour envoyer le résultat précédent vers un List.filter qui va créer une liste en la filtrant grâce à une fonction booléenne comparant un élément avec la valeur 5. Le résultat final sera cette nouvelle liste, et elle comprendra les entiers de 6 à 11.

Au lieu d’utiliser le pipe, on peut aussi composer les fonctions partielles ce qui ouvre encore d’autres horizons. L’exemple précédent peut alors s’écrire :

let compositeOp = List.map (fun i -> i+1) 
                  >> List.filter (fun i -> i>5)
let result = compositeOp [1..10]

 

Cette écriture donnera le même résultat. Mais on remarquera la création (réutilisable) d’une nouvelle fonction partielle, CompositeOp, qui elle-même est définie par la composition de deux autres fonctions partielles… Les mêmes que celles utilisées dans le pipe de l’exemple précédent. Même résultat mais réutilisabilité différente.

Tout cela ne peut fonctionner que si l’ordre des paramètres des fonctions a été choisi avec discernement. La programmation fonctionnelle est plus mathématique que la OOP, elle impose un raisonnement parfois plus rigoureux où même l’ordre des paramètres peut tout changer.

Utiliser .NET en mode application partielle

F# est un langage particulier et singulier en ce sens qu’il dérive de OCaml un pur produit de la recherche (française pour une fois, cocorico !) qui rajoute l’objet à la programmation fonctionnelle pure et qu’il tourne sous .NET, plateforme proposant une objectivation des API de l’OS (Windows ou Linux pour Mono, voire même iOS pour Xamarin F#/C#).

Le framework .NET est objet. Il a été pensé ainsi et fonctionne merveilleusement dans ce mode. F# bien que puisant dans OCaml sa compatibilité avec le monde objet n’est pas un langage OOP. L’application partielle des fonctions est un concept totalement propre aux langages ML, il est donc à la base incompatible avec les API .NET.

Nota : les langages ML – pour Meta Language – sont des langages de programmation généralistes dits fonctionnels. Ce terme est une dérive du nom d’un langage, ML, créé par Robin Milner dans les années 1980. Par la suite on a appelé “langages ML” tous les langages qui dérivent de ce premier ancêtre fonctionnel. Caml, puis OCaml et F# sont des langages ML.

Comment marier la puissance de .NET avec celle de F# sachant que l’application partielle des fonctions est un pilier de ce dernier incompatible avec le premier ?

L’astuce consiste à créer des wrappers. Par exemple on peut transformer la méthode Replace du type String du Framework par une définition fonctionnelle partielle de cette façon :

let replace oldStr newStr (s:string) = 
  s.Replace(oldValue=oldStr, newValue=newStr)

let startsWith lookFor (s:string) = 
  s.StartsWith(lookFor)
  
let result = 
     "hello" 
     |> replace "h" "j" 
     |> startsWith "j"

["the"; "quick"; "brown"; "fox"] 
     |> List.filter (startsWith "f")
     |> printfn "%A"
     
printfn "%O" result

(*
["fox"]
True

val replace : oldStr:string -> newStr:string -> s:string -> string
val startsWith : lookFor:string -> s:string -> bool
val result : bool = true
val it : unit = ()
*)

 

On voit ici que l’astuce consiste à écrire une fonction qui place en dernier paramètre la chaine à traiter. De cette façon la fonction peut ensuite être appliquée partiellement dans divers contextes notamment avec l’utilisation du pipe.

Il aurait aussi été possible d’utiliser la composition des fonctions pour rendre le code encore plus compact :

let compositeOp = replace "h" "j" >> startsWith "j"
let result = compositeOp "hello"

 

Conclusion (partielle !)

Avec l’application partielle des fonctions nous commençons vraiment à toucher du bout du doigt la nature du fonctionnel et de sa puissance. Le voyage n’est pas encore terminé. Il reste beaucoup de choses à découvrir avant de voir comment écrire une véritable application utile avec F#. Alors…

Stay Tuned !

Faites des heureux, partagez l'article !
blog comments powered by Disqus