Dot.Blog

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

Programmer en pensant fonctionnel (F#)–Partie 6

[new:30/08/2014]Le monde du fonctionnel est un peu étrange, passer de la POO au fonctionnel c’est comme découvrir l’étrangeté du monde quantique quand on connait la Relativité. Continuons ce voyage initiatique car il reste encore beaucoup à découvrir !

Le fonctionnel, cet être étrange venu d’ailleurs…

Les fonctions sont tout. Elles sont l’essence, l’âme même de la programmation fonctionnelle. Même si F# ajoute une dose d’objet et le support de .NET il reste un langage ML, un langage basé sur l’appel et l’imbrication de fonctions pures n’ayant aucun effet de bord et ne pouvant pas modifier leurs paramètres. Les valeurs elles-mêmes, qui peuvent d’ailleurs être aussi des fonctions, sont immuables. Le concept de variable n’existe pas, on parle de “binding”, ligature entre un nom symbolique et une valeur. Les opérateurs ne modifient pas les valeurs ils ne font que mapper un ensemble du domaine d’entrée sur l’ensemble de l’étendue de sortie. Il y a sans cesse création de nouvelles valeurs puisque les conteneurs (variables) n’existent pas. Les valeurs pré-existent comme en mathématique (l’ensemble des entiers existe avant même qu’on ne définisse une opération sur ce dernier, tous les résultats de toutes les opérations sur les entiers existent dans l’ensemble des entiers, même ceux d’opérations qui n’ont pas encore été inventées…).

On a du mal à concevoir le fonctionnel quand on vient du monde de la POO. La beauté des objets, les constructions qu’ils permettent, les méthodes, les frameworks créés autour des objets en font un monde si riche où tout est possible, testable, mesurable qu’on se demande bien comment on pourrait écrire le moindre code en se passant totalement de l’approche objet.

N’est-ce pas une façon de revenir à l’impératif pur et dur des assembleurs symboliques que de tout vouloir écrire à partir de fonctions qui ne sont que des espèces de sous-routines éparses ? Cela parait tellement proche d’un Pascal non objet qu’on a du mal à voir où se situe la nouveauté, l’intérêt même.

Peut-être qu’il n’y a aucun intérêt… Mais cela serait étrange que tant de monde s’y intéresse alors. Effet de mode ? Pourquoi pas, notre métier est secoué de tels effets en permanence. Mais tout de même il y a forcément quelque chose dans le fonctionnel qui attire, qui excite la curiosité…

Cette étrangeté certainement joue un rôle dans la fascination qu’exerce les langages fonctionnels. Cette singularité même serait-elle le seul attrait de F# ? Je ne crois pas. Les langages ML ont une beauté, une limpidité qui se rapprochent de celles des mathématiques. Et ce n’est pas pour rien, les langages fonctionnels partent de concepts purement mathématiques.

La question qui se pose à qui commence à s’intéresser à F# est de savoir où cette beauté mathématique mène-t-elle et comment arriver à écrire des logiciels structurés, maintenables et testables avec un tel langage ?

Peut-être que F# n’est pas un langage utilisable en production après tout. Dans un tel contexte on se doit d’utiliser des langages, des EDI, des méthodes qui sont parfaitement compréhensibles par tous pour éviter les erreurs, le code spaghetti, pour permettre d’échanger un développeur qui part par un nouveau qui arrive sans perdre des mois de formation, sans qu’il ne casse tout du code existant dès qu’il y touchera.

Dans un tel contexte industriel F# ne m’apparait pas immédiatement une solution jouable. Trop tôt. Certainement comme la POO est sortie bien avant qu’elle ne soit finalement adoptée par la masse. Il manquait aux langages ML un langage fort sur une plateforme forte, F# est cette réponse grâce sa définition bâtardée d’objets et à son environnement Visual Studio /  .NET. Mais ce n’est qu’une première étape. Il manque certainement encore des librairies, des frameworks, des success stories, et tout ce qui a permis d’ailleurs aux objets de sortir des labos pour devenir des outils de production.

Alors est-il judicieux de s’intéresser maintenant à F# ? Oui. Car les langages fonctionnels ont déjà pénétrés les langages de POO comme C# où nombre de concepts puissants ajoutés après sa première version viennent du monde fonctionnel. Tirer partie totalement de C# implique de bien connaitre les fondements fonctionnels de ces ajouts.

Mais s’il n’y avait que cet aspect là on resterait dans la culture générale, indispensable certes, mais pas prioritaire pour celui qui doit produire du concret tous les jours.

Or l’étude de F# débouche sur quelque chose de plus fort, sur une approche intellectuelle nouvelle du couple problème / solution. Sur la façon même de répondre aux besoins. Etudiez F# c’est certainement s’améliorer en C# mais c’est aussi être capable de voir les problèmes sous un angle neuf et de s’améliorer tout court…

Microsoft avec Visual Studio et .NET offre un langage fonctionnel puissant qu’est F#. Xamarin et ses compilateurs pour Android, iOS et Linux offre désormais aussi à côté de C# le même F# pour développer cross-plateforme… Mode ou signe du temps ? Signe d’un temps nouveau très certainement et d’une nouvelle approche de la programmation même en production. Peut-être pas demain matin, mais dans les mois et années à venir.

imageComment savoir ? Le meilleur moyen c’est de se faire son opinion, de tester soi-même, de s’auto-former. Et le meilleur moyen d’ouvrir la porte vers ce monde étrange c’est encore de suivre cette saga sur F# offerte par Dot.Blog !

Mais reprenons le cours de des épisodes précédents. Il nous reste encore beaucoup à voir.

L’associativité des fonctions

Comme dans tous les langages de programmation l’associativité des opérations est une question un peu clivante. En effet d’un côté vous trouverez toujours les puristes considérant que la connaissance parfaite du langage et de ses règles doivent suffire pour écrire du code, de l’autre vous trouverez les pragmatiques qui vous dirons que seul l’utilisation des parenthèses permet de lever tout doute sur comment interpréter l’ordre d’exécution d’une suite d’opérations.

J’utilise le mot opération car le problème se pose dans tous les langages, des impératifs les plus primitifs aux langages ML en passant par les langages orientés objet, à un niveau ou un autre.

Personnellement je fais partie des pragmatiques. Je me refuse à écrire un code dont la clarté n’est que partielle, se reposant en partie sur une connaissance plus ou moins solide du langage qu’aura celui qui viendra un jour lire ce code. Un code doit être clair et lisible même par un débutant, sauf cas exceptionnels. Je parle de code industriel, de code de production. Dans son coin tout seul l’informaticien professionnel ou l’amateur éclairé a la possibilité d’écrire ce qu’il veut, d’omettre les commentaires, d’écrire des opérations en se basant uniquement sur la précédence des opérateurs, etc. Ici, sur Dot.Blog j’entends, je ne parle jamais de ce code là. Non qu’il manque d’intérêt mais parce que mon métier c’est le code industriel, le code utilisé en production, fiable, lisible et maintenable.

De fait je n’utilise jamais la précédence des opérateurs et j’utilise systématiquement des parenthèses ou autre procédé d’écriture permettant de s’assurer que ce que fait le code est compréhensible à la lecture. Même par un débutant, même deux ans après.

F# soulève lui-aussi ce problème et pas uniquement dans les calculs mathématiques. Enfin si puisque tout est mathématique dans F#… Appliquer une fonction réclame donc de créer des règles fixes d’interprétation utilisées par défaut par le compilateur.

Regardons la fonction suivante :

let F x y z = x y z

 

Ce n’est pas même un code de “démo” abstrait, on a parfaitement le droit d’écrire ce code en F#. Ici on définit une fonction “F” prenant trois paramètres x, y et z et son corps consiste à appliquer “x y z”.

C’est très abstrait, c’est très mathématique. Ceux qui étaient réfractaires à la beauté éthérée des maths à l’école auront certainement du mal à trouver du charme à F#…

Ce code pose un problème d’interprétation dans son corps. En effet “x y z” qu’est-ce que cela signifie ? Avec ce que vous connaissez maintenant de F# en lisant cette série d’articles vous devez être capable d’envisage les significations possibles…

Je vais vous aider un peu… Dans un premier temps on peut considérer qu’on applique la fonction “y” au paramètre “z” et que le résultat devient une valeur appliquée à “x” qui est donc une fonction comme “y”. Ce qui revient en réalité à écrire :

let F x y z = x (y z)

 

Mais on peut aussi se dire qu’on applique le paramètre “y” à la fonction “x”, le résultat étant une fonction qui sera appliquée au paramètre “z” … Ce qui s’écrirait plutôt :

let F x y z = (x y) z

 

Toujours ces fichues parenthèses qui, lorsqu’on les omet, obligent à connaitre les arcanes du langage. Bien connaitre un langage qu’on utilise n’est pas un mal, mais si on peut être sûr de son propre savoir on ne l’est pas de celui des autres. Et comme on ne travaille que rarement seul pour soi uniquement, mieux vaut clarifier l’écriture en ajoutant les parenthèses.

Car ici, que fait F#, interprète-t-il la fonction “F” comme le montre le premier cas ou bien comme le second ?

F# applique en réalité une règle simple : l’associativité gauche. C’est à dire que dans l’exemple ci-dessus la fonction “F” sans parenthèse sera interprétée comme la dernière avec les parenthèses autour des deux premiers paramètres.

La règle s’applique ad libitum, donc si “x y z” doit se comprendre comme “(x y) z”, “w x y z” doit se comprendre comme “((w x) y) z”. Cela est facile à retenir d’autant plus que nous l’avons déjà vu sans le dire dans l’application partielle des fonctions.

Toutefois utiliser la règle d’associativité gauche de F# pour écrire du code lisible et maintenable n’est pas une idée que je suggère. Je conseille vivement d’utiliser les parenthèses pour lever les ambigüités. Ainsi la fonction “F” de l’exemple pourra s’écrire plus proprement :

let F x y z = x (y z)
let F x y z = y z |> x    // forward pipe !
let F x y z = x <| y z    // backward pipe !

 

Au passage on voit comment fonctionne le “pipe” avec ses deux écritures, soit en mode “avant” (forward pipe) soit en mode “arrière” (backward pipe).

Regardez bien ces trois façons d’écrire la fonction “F”… car elles sont parfaitement équivalentes ! C’est un peu le côté rugueux de F# il faut au départ vraiment se forcer pour voir les choses sous l’angle fonctionnel. La syntaxe est légère, tellement que chaque élément condense forcément beaucoup de sens. Il y a une sorte de paradoxe entre la légèreté de l’expression et la lourdeur de la signification…

Quoi qu’il en soit, en attribuant les noms F2 et F3 au deux dernières écritures de F (pour éviter la collision de noms interdite par F#) et en compilant le code on obtient les signatures suivantes :

val F : x:('a -> 'b) -> y:('c -> 'a) -> z:'c -> 'b
val F2 : x:('a -> 'b) -> y:('c -> 'a) -> z:'c -> 'b
val F3 : x:('a -> 'b) -> y:('c -> 'a) -> z:'c -> 'b

 

Et oui… les trois signatures sont rigoureusement identiques. je vous laisse réfléchir un peu à ce que cela signifie, éventuellement en testant un petit de code sous Tsunami, Visual Studio ou Xamarin Studio. Vous avez le choix des armes !

Composition !

Non, rangez vos stylos et la double feuille à grands carreaux, il ne s’agit pas d’une interrogation surprise, je vous ai bien eu ! Je veux vous parler de la composition des fonctions bien entendu.

Il s’agit en réalité d’une chose que nous avons déjà vue au moins dans la partie 5. Mais je n’ai pas préciser la nature exacte de cette fameuse composition.

En réalité il s’agit de quelque chose d’assez simple à comprendre. Imaginons une fonction “F” qui mappe le type “T1” vers le type “T2”. Imaginons une seconde fonction “F2” qui elle mappe le type “T2” vers le type “T3”. On sent bien ici une sorte de transitivité qui autorise le chainage de F et F2. Et bien ce chainage est autorisé et il s’appelle la composition de fonctions. Il existe même un opérateur pour cela : “>>”.

Ainsi on peut créer une troisième fonction F3 qui s’écrira “F >> F2” dont la signature sera “val F3: T1 –> T3”.

Le code suivant rendra tout cela limpide :

let IntVersString (x:int) : string = x.ToString()
let StringVersFloat (x:string) :float = float(System.Single.Parse(x))

let Entier = 123
let Chaine = IntVersString Entier
let Simple = StringVersFloat Chaine

printfn "Entier  = %i" 123
printfn "Chaine  = %s" Chaine
printfn "Longueur= %i" Chaine.Length
printfn "Float   = %f" Simple

let IntVersFloat = IntVersString >> StringVersFloat
let single = IntVersFloat Entier

printfn "Float   = %f" single

(*
Entier  = 123
Chaine  = 123
Longueur= 3
Float   = 123.000000
Float   = 123.000000

val IntVersString : x:int -> string
val StringVersFloat : x:string -> float
val Entier : int = 123
val Chaine : string = "123"
val Simple : float = 123.0
val IntVersFloat : (int -> float)
val single : float = 123.0
val it : unit = ()
*)

 

Le code ci-dessus commence par définir deux fonctions. La première convertit un entier en chaine de caractères. La second une chaine en nombre flottant.

Ensuite on pose une valeur Entier égale à 123 puis on la traite en cascade, d’abord en créant la valeur Chaine qui transforme l’Entier en Chaine. Puis en créant la valeur Simple qui transforme Chaine en flottant. Le tout en utilisant bien entendu les deux fonctions définies juste avant.

Cette première partie plante le décors et permet de s’assurer que tout fonctionne bien. D’ailleurs une série de printfn suit et produit le résultat à la console qu’on peut voir en commentaire à la fin du code. L’entier de départ est bien égal à 123, le second “123” est bien une chaine puisqu’on peut en calculer sa longueur avec “Length”, enfin l’entier est bien devenu un flottant puisqu’on voit clairement des décimales affichées.

C’est ensuite que la composition est effectuée. On créée une troisième fonction qui est définie comme une composition des deux premières fonctions. Elle pourra donc convertir directement des entier en float sans passer par le résultat intermédiaire d’une chaine.

C’est ainsi qu’on créée la valeur “single” qui transforme Entier en un flottant. Ce dernier est afficher par un nouveau printfn et le résultat est le même que celui obtenu par l’application en cascade des deux fonctions avant leur composition…

Tout cela se confirme facilement en vérifiant les signatures (dans le commentaire, en fin de code).

On peut jouer avec la composition de plusieurs façons, c’est un outil très puissant dans les mains du développeur F#. Regardons pour terminer un autre exemple :

let Plus1 x = x + 1
let Fois2 x = x * 2
let Plus1Fois2 x = (>>) Plus1 Fois2 x

printfn "F(25) = %i" (Plus1Fois2 25)

 

Ici aussi nous avons défini deux fonctions. Une première qui ajoute 1 à son paramètre d’entrée, une seconde qui multiplie par deux son paramètre. Cela nous permet de définir une troisième fonction qui ajoute un et multiplie ensuite le résultat par deux. Cette troisième fonction est créée par composition.

La sortie console de ce code est  ainsi :

F(25) = 52

val Plus1 : x:int -> int
val Fois2 : x:int -> int
val Plus1Fois2 : x:int -> int
val it : unit = ()

Aller plus loin avec les fonctions

Comme je le faisais déjà remarquer, forcément lorsqu’on parle d’un langage fonctionnel comme F# on ne parle presque que de fonctions… Et c’est ce que j’ai fait jusqu’à maintenant d’ailleurs. Mais je ne vous ai montré qu’une seule façon de définir une fonction, en utilisant “let”. En réalité il existe d’autres façons de créer des fonctions et certaines vont vous sembler familières !

Fonctions anonymes

Oui, tout cela va vous paraitre bien familier car les fonctions anonymes ou encore appelées Lambda font partie depuis un moment de C#. Et c’est justement un emprunt important fait à la programmation fonctionnelle.

 

 

J’en profite au passage pour préciser que “Lambda” ça fait très sérieux, très mathématique. Mais pour une fonction “anonyme” c’est assez normal même dans le langage courant. On parle ainsi d’un “individu Lambda” pour parler d’un … anonyme. Parfois la terminologie complexe en informatique est connue de tous depuis longtemps sans que personne ne s’en aperçoive…  Lambda, onzième lettre de l’alphabet grec et s’écrivant Λ, est aussi et surtout le symbole qu’on utilise en mathématique assez fréquemment pour désigner un paramètre dont on ne précise pas la valeur, celle-ci pouvant être quelconque. L’objet mathématique défini de cette façon en devient plus générique. Là se trouve l’origine du sens commun dans notre belle langue (individu Lambda) mais de là aussi provient l’usage plus mathématique qui en est fait en programmation fonctionnelle (fonction Lambda).

Nous avons vu la syntaxe dans certains exemples mais je ne l’avais pas expliquée. Voici la syntaxe d’une fonction anonyme :

fun parameter1 parameter2 etc -> expression

 

Le mot clé “fun” débute la définition, suivent les paramètres puis le symbole flèche (->) et enfin l’expression, le corps de la fonction.

Les différences avec les expressions Lambda de C# sont finalement purement syntaxiques :

  • F# réclame le mot clé “fun” pour introduire la Lambda alors que C# n’utilise rien de spécial
  • Le corps de la Lambda est introduit par une simple flèche en F# (->) alors que C# utilise une double flèche (=>)

 

En dehors de ces différences mineures les fonctions anonymes de F# s’utilisent de la même façon qu’en C#, ce qui, nous l’avons vu, n’est pas étonnant.

Par exemple, comme en C#, on les utilisera partout où un code est attendu mais sans que l’on veuille définir une fonction nommée (méthode en C#) pour autant. C’est le cas des filtrage dans les listes ou d’autres circonstances de ce type comme le montre l’exemple ci-dessous :

// Avec définition d'une fonction :
//let Plus1 x = x + 1
//[0..10] |> List.map Plus1

// utilisation d'une Lambda :

[0..10] |> List.map (fun a -> a + 1)

(*
val it : int list = [1; 2; 3; 4; 5; 6; 7; 8; 9; 10; 11]
*)

 

L’utilisation des fonctions anonymes peut aussi paradoxalement clarifier le code en F# comme on peut le voir dans ce court exemple :

// classique
let GenAdditionneur x = (+) x
let Plus1 = GenAdditionneur 1
printfn "10 + 1 = %A" (Plus1 10)

// montre plus clairement que GenAdditionneur
// retourne une fonction
let GenAdditionneur2 x = fun y -> x + y
let Plus2 = GenAdditionneur2 2
printfn "10 + 2 = %A" (Plus2 10)

(*
10 + 1 = 11
10 + 2 = 12

val GenAdditionneur : x:int -> (int -> int)
val Plus1 : (int -> int)
val GenAdditionneur2 : x:int -> y:int -> int
val Plus2 : (int -> int)
val it : unit = ()
*)

 

Dans le premier cas la fonction partielle GenAdditionneur est définie classiquement puis utilisée pour créer une fonction Plus1 et générer un résultat (10+1=11).

Dans le second cas une fonction anonyme est utilisée pour définir GenAdditionneur2 – le 2 est là uniquement pour éviter la collision de nom, cela n’a aucun rapport avec l’utilisation qui est faite de la fonction. Cette fonction est utilisée comme la première pour créer une fonction complète, Plus2 (qui ajoute 2 au lieu de 1) et générer le résultat (10+2=12).

Le code du second cas montre plus clairement que GenAdditionneur est une fonction partielle dont l’appel retourne une fonction.

Pattern matching dans les paramètres

Le but de cette série n’est pas de faire un cours sur F# mais d’en faire un tour d’horizon pour mieux en saisir l’esprit. je n’entrerai donc pas dans tous les détails mais le pattern matching des paramètres est l’une des features remarquables qu’il serait dommage de rater.

Quand une fonction est définie il est possible de passer des paramètres explicites mais il est aussi possible de placer un pattern matching en place et lieu des paramètres. Cela signifie que la section des paramètres peut contenir des patterns et pas uniquement des identificateurs… C’est assez puissant mais toujours un peu abstrait comme tous les concepts de la programmation fonctionnelle… Un exemple rend les choses plus claires :

type Identité = {prénom:string; nom_famille:string}
let Olivier = {prénom="Olivier"; nom_famille="Dahan"}

let AfficheIdentité identité =
    let {prénom=p; nom_famille=nf} = identité
    printfn "**Prénom: %s, Nom de Famille: %s" p nf
    
let Affiche_Identité {prénom=p; nom_famille=nf} =
    printfn  "++Prénom: %s, Nom de Famille: %s" p nf
    
AfficheIdentité Olivier

Affiche_Identité Olivier

(*
**Prénom: Olivier, Nom de Famille: Dahan
++Prénom: Olivier, Nom de Famille: Dahan

type Identité =
  {prénom: string;
   nom_famille: string;}
val Olivier : Identité = {prénom = "Olivier";
                          nom_famille = "Dahan";}
val AfficheIdentité : identité:Identité -> unit
val Affiche_Identité : Identité -> unit
val it : unit = ()
*)

 

Dans ce code on commence par définir un nouveau type “Identité” qui est formé de deux valeurs, le nom de famille et le prénom, toutes deux des chaines.

Ensuite on définit une valeur de ce type, “Olivier” qui contient l’identité de cette personne. On remarque au passage le grand dépouillement syntaxique qu’offre F# pour la définition des types et la création de valeurs utilisant ces derniers, c’en est déconcertant !

Viennent les choses sérieuses… Dans un premier temps on définit une fonction “AfficheIdentité” de façon très classique c’est à dire en utilisant un paramètre qui sera typé par l’inférence de type du compilateur. Si on regarde dans les commentaires du code ci-dessus la sortie console on voit que la signature de cette fonction est “identité:Identité –> unit”. C’est à dire qu’elle attend un paramètre de type Identité qui est appelé identité et qu’elle retourne “unit” (l’équivalent de void en C#).

On appelle ensuite cette fonction et on vérifie que la sortie est correcte sur la console (c’est la version qui affiche l’identité en commençant avec deux étoiles *).

Jusque là rien de bien extraordinaire. C’est dans la définition bis que les choses deviennent plus intéressantes. J’ai redéfinis la fonction (en ajoutant un underscore pour éviter la collision des noms) mais cette fois-ci je n’ai pas indiqué le nom du paramètre j’ai directement placé une pattern qui analysera l’entrée et vérifiera si elle est compatible puis extraira les deux paramètres “p” et “nf” qui sont utilisés ensuite de la même façon. Pour s’en convaincre la nouvelle fonction est appelée et on vérifie à la console qu’elle donne le même résultat (version affichant deux + en début de ligne).

En vérifiant la signature de cette fonction on trouve “Identité –> unit” c’est à dire uniquement le nom du type qui a été inféré par le compilateur mais pas le nom du paramètre puisque nous n’en avons pas mis, tout en extrayant “p” et “nf” de ce pattern matching…

La stratégie du pattern matching offre une fois encore un potentiel nouveau au langage et une façon de penser le code très différente de la POO.

Conclusion

En commençant cette série sur la façon de “penser fonctionnel” il n’était absolument pas dans mon intention de proposer un cours sur F# ni de traiter in extenso de la syntaxe de ce dernier. Après six billets je pense donc qu’il est temps de mettre un terme à cette série. Aller plus loin n’a de sens qu’en allant au bout, ce qui n’est pas mon but.

Bien qu’étant une vision très partielle de F# cette série vous a proposé, en tout cas je l’espère, une vision assez bonne de ce qu’est un langage fonctionnel, comment il fonctionne et pourquoi il est si différent d’un langage classique. Il reste énormément à dire pour développer en F#, la syntaxe révèle encore des surprises !

Mais si cette série vous a permis de saisir un peu de l’esprit du fonctionnel alors l’objectif est atteint. Et si en plus elle vous a donné envie d’en savoir plus sur F# et de vous former à ce langage c’est au-delà de mes espérances !

Que la force du Fa dièse soit avec vous !

image

Stay Tuned !

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