Dot.Blog

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

Programmer en pensant fonctionnel (F#)–Partie 3

[new:30/08/2014]Continuons sur la voie qui mène à penser fonctionnel grâce à l’étude de F#. Cette troisième partie aborde des éléments plus sophistiqués, alors lisez-la de préférence quand vous êtes bien réveillé ! Sourire

Résumé

j’ai commencé à vous parler de F# il y a déjà longtemps puisque le premier article abordant directement le sujet date du 26 mai 2009, plus de cinq ans maintenant, sous le titre “F# et le paradoxe de la secte”. A l’époque il s’agissait de présenter le langage et son côté particulier en s’interrogeant sur sa capacité à devenir “populaire”.

Plus récemment j’ai entamé une nouvelle série de billets consacrés à F# pour entrer un peu plus dans le détail du langage mais surtout dans l’esprit de la programmation fonctionnelle. La question n’est plus aujourd’hui de savoir si F# va devenir populaire, son intégration dans Xamarin prouve que cette question n’est plus d’actualité, mais d’en comprendre les avantages pour mieux programmer, éventuellement en restant en C# (qui intègre pas mal de bonnes idées du fonctionnel).

Cette série a débuté par  “Faut-il utiliser F# ?”. Question posée faussement pour mieux démontrer qu’au moins la connaissance du fonctionnel avait un intérêt indéniable.

Puis l’article “Programmer en pensant fonctionnel (F#)” a complété cette première approche de l’esprit du fonctionnel et de la nécessité aujourd’hui de s’y intéresser.

Puis est venu le moment de mettre les mains dans le code pour réellement voir ce qu’est F# et comment il se programme pour capter cet esprit si particulier qu’est le fonctionnel. L’article “Programmer en pensant fonctionnel (F#)-Partie 2” a ainsi posé les fondations, les premières briques de la syntaxe et de l’esprit de F#. Mais nous n’en sommes qu’au début !

La troisième partie que je vous propose aujourd’hui s’inscrit à la suite de la précédente en vous emmenant à la découverte d’autres éléments du langage. Chacun nous rapprochant un peu plus de l’esprit du fonctionnel  pour en comprendre la nature si spécifiques…

Utiliser les annotations de type pour contraindre les fonctions

C’est un problème que j’ai déjà évoqué quelques fois. A partir du moment où on se repose uniquement sur l’inférence de type du compilateur on peut avoir des surprises. Lorsque celles-ci interviennent à la compilation c’est un moindre mal. Mais elles peuvent aussi se produire à l’exécution lorsqu’un type qui vient d’être inféré est de nouveau inféré dans une version non compatible avec le premier.

Le plus sage, malgré la capacité de F# à inférer presque tout le temps les bons types, est d’annoter les fonctions pour préciser les types, autant ceux du domaine d’entrée que ceux de l’étendue de sortie.

Voici une fonction écrite en utilisant l’inférence :

let evalWith5ThenAdd2 fn = fn 5 +2
    => val evalWith5ThenAdd2 : (int -> int) -> int

 

Dans ce cas bien précis F# peut déduire que “fn” est une fonction qui mappe des entiers vers des entiers. Sa signature est ainsi (int->int).

Mais quelle est la signature de la fonction suivante ?

let evalWith5 fn = fn 5

 

De façon assez évidente le compilateur comme nous-mêmes pouvons déduire que “fn” représente une fonction qui attend un paramètre d’entrée de type entier. Mais que retourne cette fonction ? Peut-être un autre entier. A moins que ce ne soit une chaîne ou un float ?

Dans les cas où l’inférence du compilateur reste sans voix il faut l’aider un peu… L’annotation des types vient tout éclaircir :

let evalWith5AsInt (fn:int->int) = fn 5
let evalWith5AsFloat (fn:int->float) = fn 5

 

Le type de retour peut aussi être préciser pour ne laisser aucune zone d’ombre :

let evalWith5AsFloat (fn:int->float) = fn 5
let t (x:int) : float = float x
evalWith5AsFloat(t)

 

Le type “Unit”

A première vue “unit” est l’équivalent de “void” en C# lorsqu’on désire qu’une fonction ne retourne rien. Mais ce n’est qu’apparence une fois encore… Prenons une fonction qui affiche un message simple à la console et regardons sa signature retournée par le compilateur :

let printInt x = printf "x vaut %i" x        // affichage console
printInt 150

(*
> let printInt x = printf "x vaut %i" x        // affichage console
  printInt 150;;
x vaut 150
val printInt : x:int -> unit
val it : unit = ()
*)

 

La signature de notre fonction est donc val printInt : x:int –> unit

Même que signifie ce “unit” ? Dans le monde mathématique des langages fonctionnels la notion de “void” n’existe pas. Un fonction doit retourner une étendue. Pour une fonction qui ne retourne rien le type de cette étendue est “unit”. Et c’est bien un type car une valeur de ce type peut recevoir la valeur vide “( )” :

let whatIsThis = ()

(*
> > let whatIsThis = ();;

val whatIsThis : unit = ()
*)

 

L’analyse retournée par le compilateur nous indique bien que la valeur whatIsThis initialisée à “( )” est bien de type “unit”.

On comprend désormais un peu mieux la signature indiquée plus haut pour notre fonction “printInt”.

Fonctions sans paramètres

Essayons de créer une fonction dont le seul but est d’imprimer un message à la console :

let afficheBonjour = printf "Bonjour F# !"

(*
> > let afficheBonjour = printf "Bonjour F# !";;
Bonjour F# !
val afficheBonjour : unit = ()
*)

 

Ce n’est pas ce à quoi nous nous attentions… le message est affiché immédiatement et la signature nous montre que nous avons créé une valeur et non une fonction. D’ailleurs la tentative suivante d’appeler la fonction causera une erreur de compilation :

> > let afficheBonjour = printf "Bonjour F# !"
  afficheBonjour();;
> > 
  afficheBonjour();;
  ^^^^^^^^^^^^^^

stdin(102,1): error FS0003: This value is not a function and cannot be applied

 

C’est clair, ce n’est pas une fonction mais une valeur.

Pour transformer la valeur en une fonction elle doit simuler le passage d’un paramètre, donc utiliser des parenthèses à la place du paramètre non utilisé :

let afficheBonjour () = printf "Bonjour F# !"
afficheBonjour()

(*
> > let afficheBonjour () = printf "Bonjour F# !"
  afficheBonjour();;
Bonjour F# !
val afficheBonjour : unit -> unit
val it : unit = ()
*)

 

on le voit ci-dessus la signature a changé, elle est devenue “unit->unit” ce qui signifie que nous avons bien affaire à une fonction qui mappe le domaine d’entrée “unit” vers l’étendue de sortie de type “unit”.

L’appel à la fonction montre les mêmes parenthèses vides. Ce qui ressemble à un appel de méthodes sans paramètre sous C# d’ailleurs.

Les justifications sont différentes mais souvent le résultat est le même…

Forcer le type unit

On arrive ici à ce qui me semble être la face sombre de F#. En effet, on sait très bien “vendre” F# en nous présentant la concision extrême de ce langage. Mais ce qu’on nous cache, le voici : beaucoup de choses deviennent non pas seulement aussi verbeuses que C# mais aussi bien plus ésotériques. Qu’un code C# soit parfois lourd je le veux bien, mais il est explicite, mais être obligé d’ajouter des artéfacts peu lisibles pour faire des choses très simples, ça ne va pas à mon sens. F# échoue a être totalement élégant et c’est dommage. Mais à vous de juger…

Prenons des expressions très simples :

do 1+1     // erreur : FS0020: This expression should have type 'unit'

let something = 
  2+2      // erreur : FS0020: This expression should have type 'unit'
  "hello"

// Correction :

do (1+1 |> ignore)  // ok

let something = 
  2+2 |> ignore     // ok
  "hello"

 

L’expression “do” et la suivante “let” échouent parce que F# n’arrive pas à déterminer le type ou plutôt parce que parlant d’expression qui ne retournent rien elles devraient retourner le type “unit”.

Qu’à cela ne tienne, en C# une méthode qui ne retourne rien devient simplement “void”. Pascal définit des procédures qui ne retournent rien et des fonctions qui retournent quelque chose. C’est clair et net. C# s’est amusé à tout définir sous forme de fonctions en ajoutant le mot clé “void” pour celles qui se comportent en réalité comme des procédures au sens pascalien. C’est déjà du bricolage. Mais F# pousse le bouchon plus loin car comme “void” n’existe pas, il faut retourner absolument “unit”.

Faire 1 + 1 ne peut pas s’écrire aussi simplement. On pourrait se dire “void do 1 + 1” ou “unit do 1+1” ou un truc de ce genre. Un peu lourd mais de toute façon ce n’est pas la solution.

On va en réalité être obligé d’utiliser deux choses, le marqueur pipe “|>” qui transmet une sortie à une entrée, et la fonction “ignore” qui prend n’importe quoi en entrée et qui retourne systématiquement “unit”.

On le voit dans le code corrigé que pour faire 1+1 il faut tout un tralala de parenthèses, de pipe et “ignore”. A vous dégouter de faire 1+1 pour la vie ! Sourire

Bon j’exagère un peu, mais quand j’ai découvert ces aspects là de F# j’ai su que quelque soit la valeur de ce langage je ne l’utiliserai pas au quotidien pour tout faire. Syntaxe trop biscornue pour moi.

Mais une fois encore chacun fait comme il sent : Dot.Blog propose, le lecteur dispose…

Et puis mes remarques ne remettent surtout pas en cause l’intérêt majeur de se pencher sur la façon de programmer en fonctionnel.

Types génériques

Je ne présenterai pas les types génériques dont tout le monde a compris l’intérêt en C#. F# n’est pas en reste et il se repose d’ailleurs sur les génétiques du Framework pour fournir un équivalent. La forme reste toutefois troublante :

let doGeneric x = "***"+ x.ToString() + "***"

 

Cette fonction est conçue pour prendre un objet générique, “x”, et rendre en sortie le résultat du ToString() de ce dernier entouré de 3 étoiles (devant et derrière donc).

En quoi cela change des fonctions déjà écrites dans cet article ou les précédents ? Peu de choses il est vrai. Mais à mieux y regarder si on compile on voit cela comme signature :

val doGeneric : x:'a -> string

Si l’étendue de sortie est claire, c’est un type “string”, le domaine d’entrée est plus bizarre  puisque c’est ‘a (apostrophe suivi de la lettre a). Quel est donc ce mystérieux type ‘a ? Il n’est rien d’autre qu’une convention de F# pour indiquer un type générique comme le <T> en C# un peu.

Comment la fonction a-t-elle décidé que le domaine était générique ? C’est facile, nous utilisons la méthode ToString() qui est commune à tous les objets du Framework puisque venant de Object.

C’est là que F# devient un peu filou puisque tout ce que nous avons dit sur les paramètres uniques, la beauté mathématique de l’immuabilité et des valeurs et tout ça, et bien ce n’est pas tout à fait vrai… F# fait de l’orienté objet se reposant sur le Framework .NET exactement comme C#. De fait c’est un type objet qui est inféré ici.

Mais attention tout de même… Cela ne signifie pas que la fonction F# de notre exemple est identique à une méthode C# qui prendrait le type object en paramètre… Nous parlons bien ici de code générique, c’est à dire de code fortement typé mais dont le type sera connu à l’exécution seulement. Mais une fois celui-ci connu impossible d’en changer dans l’enchainement des opérations sous peine d’erreur !

Ainsi, si on passe un objet de type A à notre fonction, c’est A.ToString() qui sera appelé et non pas object.ToString(). Mieux, si la fonction intervient dans une chaîne d’opérations, c’est bien le type A qu’elle transportera et non le type Object.

Le code C# de la fonction doGeneric serait donc :

string DoGeneric<a>();   

// ou selon les conventions de notation 
string DoGeneric<TObject>();	

 

On notera que si une fonction accepte plusieurs paramètres de type générique ils prendront les noms ‘a puis ‘b, ‘c … voici un exemple :

let concatString x y = x.ToString() + y.ToString()

(*
  signature retournée après compilation :
  val concatString : x:'a -> y:'b -> string
*)

 

Les autres types

Tous les types dont nous avons parlés jusqu’ici sont des types simples, dit primitifs comme les booléens, les entiers, etc. Bien entendu F# autorise la création et l’utilisation de types plus sophistiqués. Sans trop entrer dans les détails qui ne nous intéressent pas ici il est intéressant de présenter quelques types qui laissent présager un peu mieux de la puissance de F#.

Les tuples

Les tuples sont des ensembles de deux, trois, quatre… autres types. Par exemple  (“Coucou”,true) est un tuple de deux types, une chaîne et un booléen. La virgule permet de séparer les valeurs, si vous voyez des virgules en F# c’est que vous êtes en train de voir un tuple !

La notation du type d’un tuple utilise de façon très logique la multiplication (symbole étoile). C’est logique puisque un tuple chaîne / entier représentera une sorte de moule avec lequel nous pourrons créer toutes les combinaisons de chaînes d’entiers. Si on pouvait les compter, on utiliserait bien la multiplication pour indiquer le nombre de valeurs possibles (5 chaînes et 3 entiers possibles, donneraient 15 valeurs. Bien entendu ici on parle d’infinités de valeurs, mais cela ne change rien au raisonnement).

Le tuple chaîne / entier sera ainsi noté string * int une valeur exemple serait (“Abc”,48).

Les collections

Aucun langage ne peut réellement fonctionner sans collections, qu’elles soient programmées à la main dans les vieux langages ou qu’elles fassent partie des outils de base dans les langages modernes. Les plus connues des collections sont les listes, les séquences et les tableaux. Les listes et les tableaux ont des tailles fixes alors que les séquence n’ont pas de limite. De façon interne F# traite les séquences comme des IEnumerable. Lors de la définition d’une signature de fonction ou d’une valeur ces collections ont des mots clés spécifiques pour les représenter “list”, “seq” et “[]” pour les tableaux.

Voici quelques définitions faisant intervenir des collections :

int list		// Liste par ex. [1;2;3]
string list		// Liste par ex. ["a";"b";"c"]
seq<int>		// Séquence par ex. seq{1..10}
int []			// Tableau par ex. [|1;2;3|]

 

Le type Option

En C# on utilise souvent la valeur “null” compatible avec presque tout pour indiquer une valeur manquante. Même les types primitifs accepte le nul si on utilise la notation “?” (les “nullables” ont été ajoutés très à C#, dans sa version 2.0 pour être exact).

F# aime bien les choses claires, et cette utilisation du “null” il est vrai peut être sujette à caution. F# propose le type option qui se comporte un peu comme une énumération à deux valeurs “some” et “none”. Quelques valeurs (ce qui commence à 1) ou bien aucune valeur (none).

Voici quelques définitions et leur résultat de compilation pour mieux comprendre :

let a = Some 1
let b = Some true
let c = None

[1;2;3;4]  |> List.tryFind (fun x-> x = 3)  // Some 3
[1;2;3;4]  |> List.tryFind (fun x-> x = 10) // None

(*
val a : int option = Some 1
val b : bool option = Some true
val c : 'a option
val it : int option = None
*)

 

Les unions discriminées

Il est difficile de trouver des traductions “officielles” et compréhensibles à la fois pour beaucoup de choses dans notre métier dont la terminologie est largement américaine à l’origine. Parler d’unions discriminées n’apporte aucune information de plus qu’utiliser le terme original (Discriminated Unions)) qui a au moins l’avantage de pouvoir se retrouver par une recherche Google !

Mais peu importe le flacon… Et l’ivresse ici provient de la nature exact de ce type.

Les unions discriminées qui sont aussi appelées “taggerd unions” représente un ensemble de possibilités, de choix prédéterminés et figés. On se doute que cette possibilité se retrouve dans la construction de types complexes.

L’exemple le plus simple correspond finalement – une fois les grands mots savants oubliés – à ce que nous appelons énumérations en C#… :

type switchstate =
    | On
    | Off
let b :switchstate = On

(*
type switchstate =
  | On
  | Off
val b : switchstate = On
*)

 

L’exemple complet (provenant de Wikibook.org) est le suivant :

type switchstate =
    | On
    | Off
 
let x = On    (* creates an instance of switchstate *)
let y = Off   (* creates another instance of switchstate *)
 
let main() =
    printfn "x: %A" x
    printfn "y: %A" y
 
main()

(*
x: On
y: Off

type switchstate =
  | On
  | Off
val x : switchstate = On
val y : switchstate = Off
val main : unit -> unit
val it : unit = ()
*)

 

Comme on le voit on peut créer une valeur de type “switchstate” sans indiquer le type (toujours l’inférence utilisée comme façon de programmer et non comme simple possibilité en F#).

L’exemple suivant provient de la même source et montre la sophistication des constructions qui peuvent être crées. Ce qui est intéressant, sachant que nous parlons dans cette série d’articles de l’esprit de la programmation fonctionnelle bien plus que du langage F# en tant de tel, c’est que justement nous pouvons ici voir à l’oeuvre des choses qui n’existent pas en C# et qui ainsi ouvrent des voies nouvelles. Ces voies sont l’essence même de la nouvelle façon de penser, penser fonctionnel.

Regardez bien ce code pourtant simple et imaginez comment utiliser ce qu’il démontre dans des cas pratiques que vous avez en tête :

open System
 
type switchstate =
    | On
    | Off
    | Adjustable of float
 
let toggle = function
    | On -> Off
    | Off -> On
    | Adjustable(brightness) ->
        (* Matches any switchstate of type Adjustable. Binds
        the value passed into the constructor to the variable
        'brightness'. Toggles dimness around the halfway point. *)
        let pivot = 0.5
        if brightness <= pivot then
            Adjustable(brightness + pivot)
        else
            Adjustable(brightness - pivot)
 
let main() =
    let x = On
    let y = Off
    let z = Adjustable(0.25) (* takes a float in constructor *)
 
    printfn "x: %A" x
    printfn "y: %A" y
    printfn "z: %A" z
    printfn "toggle z: %A" (toggle z)
 
    Console.ReadLine |> ignore
 
main()

(*
x: On
y: Off
z: Adjustable 0.25
toggle z: Adjustable 0.75
*)

 

Comme d’habitude je place en commentaire en fin de code ce qu’on voit à la console après exécution. Pour l’instant je me sers toujours de Tsunami pour faire cette série d’articles. Nul doute que Visual Studio irait tout aussi bien. Je me suis même aperçu dernièrement que l’excellent LinqPad comprenait désormais un mode F#. Il y a donc le choix pour s’exercer !

Les enregistrements

Le type Record, ça fait remonter pleins de souvenirs qui sentent le Pascal ! F# possède aussi un type Record. Comme en Pascal il s’agit de regrouper dans une même structure de données plusieurs valeurs de types éventuellement différents. C’est un peu l’équivalent des structures de C#.

Du point de vue de F# un enregistrement peut être vu comme un tuple dont les éléments sont nommés. En voici quelques exemples :

type ComplexNumber = { real: float; imaginary: float }
type GeoCoord = { lat: float; long: float }

// différence entre record et tupe
type ComplexNumberRecord = { real: float; imaginary: float }
type ComplexNumberTuple = float * float

// utilisation
type ComplexNumberRecord = { real: float; imaginary: float }
let myComplexNumber = { real = 1.1; imaginary = 2.2 } 

type GeoCoord = { lat: float; long: float } // use colon in type
let myGeoCoord = { lat = 1.1; long = 2.2 }  

 

S’agissant d’une introduction pour saisir l’esprit de F# je n’entrerai pas plus dans les détails nous aurons certainement l’occasion d’y revenir au sein d’exemples de code plus riches.

Quizz types

Même si nous ne faisons qu’effleurer F# pour l’instant, vous en savez déjà assez pour tenter le petit Quizz suivant… Pas facile comme vous allez le voir. Le but, trouver les signatures des déclarations suivantes :

let testA   = float 2
let testB x = float 2
let testC x = float 2 + x
let testD x = x.ToString().Length
let testE (x:float) = x.ToString().Length
let testF x = printfn "%s" x
let testG x = printfn "%f" x
let testH   = 2 * 2 |> ignore
let testI x = 2 * 2 |> ignore
let testJ (x:int) = 2 * 2 |> ignore
let testK   = "hello"
let testL() = "hello"
let testM x = x=x
let testN x = x  1		//  indice qu'est ce que x ici ?
let testO x:string = x 1	// indice que modifie :string  ici ? 

 

Si vous ne trouvez pas tout et tout de suite ne vous inquiétez pas. On de vient pas un expert d’un langage en lisant deux ou trois articles… L’absence de types, la notation ultra allégée, l’inférence utilisée à outrance, l’absence de nuance visible (à première vue) entre génériques, valeurs, fonctions, tout cela ne rend pas l’exercice facile.

Je vais vous donner la réponse que vous pouvez obtenir en copiant/collant le Quizz dans Tsunami ou toute console F#. La sortie donne les signatures suivantes :

val testA : float = 2.0
val testB : x:'a -> float
val testC : x:float -> float
val testD : x:'a -> int
val testE : x:float -> int
val testF : x:string -> unit
val testG : x:float -> unit
val testH : unit = ()
val testI : x:'a -> unit
val testJ : x:int -> unit
val testK : string = "hello"
val testL : unit -> string
val testM : x:'a -> bool when 'a : equality
val testN : x:(int -> 'a) -> 'a
val testO : x:(int -> string) -> string

 

Pas évident hein ?

Essayez de bien regarder chaque définition et chaque signature, n’oubliez pas la signification du “’a” (voir plus haut dans l’article). L’exercice est salutaire tout autant que distrayant (mais si !).

Conclusion (partielle)

Il est difficile d’aborder l’esprit d’un langage, d’un mode de programmation, sans aborder les types de données qui sont manipulés. Sans ces rudiments il est impossible de comprendre le moindre exemple de code.

Jusqu’ici nous n’avons il est vrai rien vu de bien transcendant je l’avoue mais nous avançons ! La partie III appelle une partie IV dans laquelle nous reviendrons aux fonctions avec un truc qui empêchera vos neurones de ramollir avec la chaleur : la Curryfication. Ce n’est pas un plat indien, c’est tout ce que je peux vous dire. Alors…

… Stay Tuned !

Faites des heureux, PARTAGEZ l'article !