Intermédiaire

Design d’un moteur de mapping en .NET – Approche fonctionnelle en F♯

Temps de lecture : 13 minutes

Dans la première partie, nous avons exposé un problème de rectification de données, des équipements automobiles, provenant d’une API externe. Nous avons mis en place une première solution en C♯ de style impératif. Nous avons constaté des similitudes dans le code avec du F♯.

Je commence justement à m’intéresser sérieusement à ce langage. Quelle belle occasion de pratiquer en cherchant des solutions en F♯ ! Aucune crainte à avoir pour ceux qui ne connaissent rien à ce langage : les quelques subtilités employées seront expliquées, en faisant des parallèles avec le C♯ lorsque cela se montre pertinent. Comme dans la première partie, nous commencerons par une solution qui marche avant de chercher à l’améliorer.

🏷 Sommaire
  1. Approche impérative en C♯
  2. Approche fonctionnelle en F♯
  3. Modélisation du domaine en F♯
  4. Approche orientée objet en C♯
  5. Avancée en orienté objet en C♯

Introduction au F♯

☝️  Note
Voici quelques éléments du langage F♯ à destination des développeurs C♯ pour faciliter la lecture du code de cet article et compléter les explications associées. Vous avez la possibilité de passer ce chapitre pour y revenir au besoin. Inversement, si cette brève introduction ne suffit pas pour bien comprendre le code de cet article, je vous recommande de parcourir quelques unes des ressources listées par la fondation F♯ pour apprendre le langage.

Le code F♯ consiste grosso modo en un ensemble de types et de fonctions regroupés en modules. Les modules peuvent être placés dans des namespaces, ce qui est recommandé pour faciliter l’inter-opérabilité avec d’autres langages .NET. Un module F♯ est conceptuellement similaire à une classe statique C♯, ses fonctions aux méthodes statiques de cette classe. C’est d’ailleurs ce que l’on obtient en décompilant du code F♯ en C♯.

Même si C♯ permet de faire de la programmation fonctionnelle (FP), il n’est pas « FP first » et donne le meilleur de lui-même en orienté objet (OO). La réciproque est vraie pour F♯ avec lequel on peut avoir une approche OO mais qui brille plutôt côté FP.

Le F♯ se distingue également du C♯ par :

  • Sa syntaxe, concise, épurée et bénéficiant d’une inférence de type poussée,
  • Son système de types algébrique.

Syntaxe

La syntaxe du F# peut être déroutante quand on est habitué à celle du C# mais elle est en fait plus naturelle : comme en anglais, on utilise avec parcimonie les parenthèses et virgules.

  • Instruction : la fin d’une instruction est signalée par un retour à la ligne. On peut utiliser le ; pour mettre plusieurs instructions sur une même ligne mais cela se fait rarement hormis pour initialiser un tableau ou une liste : [1; 2; 3].
  • Expression : tout est expression en F♯.
    → Tout doit renvoyer une valeur, d’où l’absence de return à la fin des fonctions.
    → Un if/else est une expression dont l’évaluation doit donner une valeur en sortie. Il faut donc définir une valeur de retour pour chacune des deux branches, le if et le else.
  • Bloc : les blocs sont définis non pas entre accolades mais par un retour à la ligne et une indentation.
    → Cela oblige à être plus rigoureux sur les indentations, ce que je trouve une bonne chose pour la lecture du code.
    → En plus de l’indentation, on doit tout de même mettre entre accolades les Records et les Computation expressions comme seq utilisé plus bas.
  • Valeur : en F♯, on parle de valeurs (par défaut immuables) plutôt que de variables ou de constantes. On les déclare avec le mot-clé let.
  • Inférence de type : en F#, on utilise la syntaxe name: type pour indiquer le type d’une valeur. En pratique, on le fait peu pour plutôt se reposer sur l’inférence de type.
    → L’inférence est plus poussée qu’en C# car elle se base sur l’usage d’éléments dont le type est déjà connu ou reconnaissable, pour en déduire le type de l’élément actuellement déclaré.
    → Mais pour que cela marche, on a une contrainte sur l’ordre de déclaration qui doit se faire de haut en bas.
  • Paramètre(s) de fonction :
    Parenthèse : il n’est pas nécessaire de mettre des parenthèses autour des paramètres d’une fonction mais il est parfois nécessaire d’en mettre autour d’une expression passée en paramètre (exemple : myFunc (Some 1))
    Virgule : les paramètres sont juste séparés par des espaces ; il n’y a pas besoin de virgules pour cela, les virgules servant uniquement à l’instanciation des tuples.
  • Fonction : les fonctions sont des valeurs comme les autres.
    → On les déclare avec let.
    Exemple : let add x y = x + y, équivalent à int add(int x, int y) => x + y; en C# 7.
    → On peut les passer en paramètre d’une autre fonction (par exemple le premier paramètre de List.map, équivalent du Select() LINQ) ou comme valeur de retour.
  • Absence d’action : toute fonction doit renvoyer une valeur. Une fonction qui ne renvoie rien (en C# déclarée avec void) est définie avec le type de retour unit, telle que printf.
    → Cela permet d’uniformiser le traitement des fonctions, sans devoir distinguer les fonctions renvoyant une valeur (Func en C♯) de celles n’en renvoyant pas (Action en C♯).
    → Ce type unit a une seule instance, notée astucieusement () de manière à ce que l’appel à une fonction sans paramètre ressemble à du C♯.
    Exemple : let n = randomNumber()

Système de types algébrique

Le système de types est algébrique : on peut définir des types composés d’autres types (y compris primitifs) de manière à ce que le nombre des valeurs possibles du nouveau type soit égal à la somme ou au produit des nombres de valeurs de chaque sous-type.

Type par addition

On parle de sum type, union type ou choice type, pour désigner un type agrégeant un ensemble de choix possibles dits cases. Les sum types sont ainsi similaires aux enums C♯ mais en bien plus évolués :

  • Chaque case peut ou non contenir n’importe quelle donnée, alors que les membres d’une enum ne peuvent qu’être des entiers.
  • Le compilateur garantit l’exhaustivité de traitement de tous les cases sans avoir besoin de recourir à un ultime cas par défaut (le default du switch en C♯).

Exemples :

  • type Label = Label of string
    single case union encapsulant une string dans un type Label ne comportant qu’un seul case également nommé Label ici mais on peut choisir n’importe quel nom.
  • type Label = string
    → Simple alias de string, utilisable pour documenter le code mais sans garantie de type (type safety) comparé au single case union précédent.
  • type Gender = Male = 1 | Female = 2
    → Regroupement de 2 cases avec une valeur entière,
    → Strict équivalent de l’enum C♯ enum Gender { Male = 1, Female = 2 }.
  • type Pencil = Black | Blue | Red | Green
    → Regroupement de 4 stylos selon leur couleur, sans donnée à l’intérieur.
  • type ContactMode = Mail of string | SMS of string
    → Choix d’un mode de contact entre mail (avec une adresse email) et SMS (avec un numéro de mobile).
    → On écrit généralement ces types sur plusieurs lignes, en pouvant commencer le premier case par un | :

    type PaymentMethod =
            | Cash
            | Check of checkNumber: int
            | CreditCard of cardNumber: string * expiration: string

☝️ Note : hormis les “enum union”, les union types sont compilés sous forme d’une hiérarchie de classes. Tout se joue donc au niveau du compilateur F♯ qui permet une vérification exhaustive, contrairement au C♯.

Types par multiplication

On parle de product type. En F♯, ils se présentent sous deux formes possibles :

Tuple

Comme en C♯, on peut créer un tuple à la volée en regroupant plusieurs valeurs délimitées par des virgules, entre parenthèses (si nécessaire pour la compilation ou la lecture). On n’a pas besoin de définir au préalable un type qui utiliserait alors le * pour séparer les éléments dans le tuple.

Exemple :

  • let t = 1,2 → parenthèses généralement omises. Le type est val t: int * int.
  • let s = "a-b-c".Replace("-", "_") → les méthodes .NET prennent des tuples en entrée, qu’il faut alors mettre entre parenthèses, ce qui ressemble à un appel standard en C♯.
Record

On instancie un record un peu comme un objet anonyme en C♯ ou comme un objet littéral en JavaScript, entre accolades mais en séparant les membres par des ; ou des retours à la ligne. Par contre, il faut au préalable avoir défini et nommé le type associé pour que le compilateur l’infère.

type Identity = { FirstName: string; LastName: string; BirthDate: DateTime; }

let john = { FirstName = "John"; LastName = "Doe"; BirthDate = DateTime(2000, 5, 25) }

☝️ Notes :

  • On utilise la convention .NET en nommant les membres en PascalCase.
  • On n’a pas besoin du new pour instancier la date et le Record. La convention est de n’utiliser new que dans le cas d’instanciation d’un objet IDisposable, en conjonction avec le mot-clé use : use a = new A().
  • F♯ permet aussi d’écrire des classes, des interfaces, des structures .NET qui sont également des product types, on peut même ajouter des membres à un type, mais ces constructions orientées objet sont peu courantes. Nous en resterons là sur ce sujet dans le cadre de cette introduction.

Première implémentation opérationnelle

Notre première version se compose d’un module EquipmentMapper contenant une seule fonction, computeLabel. Cette fonction est constituée d’un gros pattern matching sur le tuple Schema * Location :

module EquipmentMapper

let computeLabel (equipment: Equipment) (variation: Variation) =
    let label = equipment.Label.Trim()
    match variation.Schema, variation.Location with
    | 7407, _ -> "Nombre de cylindres"
    | 15304, _ -> "Puissance (ch)"
    | 15305, _ -> "Régime de puissance maxi (tr/mn)"
    | 23502, _ | 24002, _ -> label.Replace("an(s) / km", ": durée (ans)")
    | 23503, _ | 24003, _ -> label.Replace("an(s) / km", ": kilométrage")
    | 7403, _ -> label.Replace("litres / cm3", "litres")
    | 7402, _ -> label.Replace("litres / cm3", "cm3")
    | 23301, 'F' -> label.Replace("AV / AR", "AV")
    | 23301, 'R' -> label.Replace("AV / AR", "AR")
    | 17811, 'D' | 17818, 'D' -> label.Replace("conducteur / passager", "conducteur")
    | 17811, 'P' | 17818, 'P' -> label.Replace("conducteur / passager", "passager")
    | 53405, 'D' -> label.Replace("recharge (rapide) A / V / h", "recharge : ampérage (A)")
    | 53405, 'F' -> label.Replace("recharge (rapide) A / V / h", "recharge rapide : ampérage (A)")
    | 53404, 'D' -> label.Replace("recharge (rapide) A / V / h", "recharge : voltage (V)")
    | 53404, 'F' -> label.Replace("recharge (rapide) A / V / h", "recharge rapide : voltage (V)")
    | 53403, 'D' -> label.Replace("recharge (rapide) A / V / h", "recharge : durée (heures)")
    | 53403, 'F' -> label.Replace("recharge (rapide) A / V / h", "recharge rapide : durée (heures)")
    | 14103, _ -> label + " : largeur"
    | 14104, _ -> label + " : profil"
    | _ -> label

On constate déjà à quel point le F♯ est plus concis que le C♯ : cette fonction ne fait que 23 lignes tout en étant aussi lisible. Si vous avez en tête le début de l’article précédent, cela devrait vous rappeler quelque chose, non ? La table de décision en effet, dont voici un extrait :

Equipment Label Variation Location New Label
7408 Configuration du moteur 7408 (idem)
7407 Nombre de cylindres
15304 Puissance en ch / régime tr/mn 15304 Puissance (ch)
15305 Régime de puissance maxi (tr/mn)
23502 Garantie an(s) / km 23502 Garantie : durée (ans)
23503 Garantie : kilométrage
24002 Assistance an(s) / km 24002 Assistance : durée (ans)
24003 Assistance : kilométrage
7403 Cylindrée (litres / cm3) 7403 Cylindrée (litres)
7402 Cylindrée (cm3)

On retrouve donc dans le code une transcription explicite de la table de décision 🎉 C’est un exemple de living documentation.

Pour toutes ces raisons, on pourrait se contenter de cette version dans un code de production. Mais dans certaines circonstances, selon le contexte métier, il convient de pousser l’implémentation plus loin. De toute manière, pour l’exercice, cherchons des façons d’améliorer le code.

💡 Code source sur le GitHub de SOAT ici.

Décomposition en sous fonctions

On peut à nouveau découper computeLabel en sous-fonctions. Cela permet par exemple de ne faire de pattern matching sur la Location que lorsque c’est nécessaire.

Chacune de ces sous-fonctions n’effectue qu’une partie de l’opération globale. En C♯ comme dans le framework .NET, les fonctions de ce type signalent leur échec en émettant une exception. On dit qu’elles sont partielles car une partie seulement des valeurs possibles en entrée sont traitées.

Lorsqu’il s’agit d’une fonction métier avec des cas d’échecs bien identifiés, il est recommandé de ne pas émettre d’exception. Dans l’article précédent, on avait opté pour le renvoi de null. En F♯, on ne renvoie jamais null (sauf lorsque l’on fait une librairie .NET consommée en C♯). À la place, on renvoie une Option, plus spécifiquement ici une Option‹string›, plutôt écrite en notation ML string option. Un tel objet est créé dans l’un des deux “états” possibles :

  • Some "value" contenant une valeur,
  • None n’en contenant aucune.

Les fonctions renvoyant une Option sont fréquentes en F♯. Dans l’article précédent, nous avons évoqué la convention de nommer ces fonctions avec le préfixe “try”. Cette convention existe aussi en F♯ mais on l’applique surtout pour distinguer un couple de fonctions réalisant la même opération, mais ne signalant pas leur échec de la même façon :

  • La première fonction va échouer en émettant une exception.
    Exemple : List.find<'T> : (predicate: 'T -> bool) ->'T list -> 'T
  • L’autre fonction échouera plus gracieusement en renvoyant soit Option.None en cas d’échec, soit Option.Some result.
    Exemple : List.tryFind<'T> : (predicate: 'T -> bool) ->'T list -> 'T option

Dans cet article, nous aurons des fonctions renvoyant leur résultat encapsulé dans une Option mais sans leur consœur émettant une exception. On a donc le choix d’utiliser ou non le préfixe “try” juste pour indiquer ce type de retour. Conservons ce choix pour nous adapter en fonction des circonstances. Pour l’heure, partons sur un nommage sans préfixe :

let computeLabelTotallyNew (variation: Variation) =
    match variation.Schema with
    | 7407 -> Some "Nombre de cylindres"
    | 15304 -> Some "Puissance (ch)"
    | 15305 -> Some "Régime de puissance maxi (tr/mn)"
    | _ -> None

let computeLabelWithReplacementBySchema (label: string) (variation: Variation) =
    match variation.Schema with
    | 23502 | 24002 -> Some(label.Replace("an(s) / km", ": durée (ans)"))
    | 23503 | 24003 -> Some(label.Replace("an(s) / km", ": kilométrage"))
    | 7403 -> Some(label.Replace("litres / cm3", "litres"))
    | 7402 -> Some(label.Replace("litres / cm3", "cm3"))
    | _ -> None

let computeLabelWithReplacementByLocation (label: string) (variation: Variation) =
    match variation.Schema, variation.Location with
    | 17811, 'D' | 17818, 'D' -> Some(label.Replace("conducteur / passager", "conducteur"))
    | 17811, 'P' | 17818, 'P' -> Some(label.Replace("conducteur / passager", "passager"))
    | 23301, 'F' -> Some(label.Replace("AV / AR", "AV"))
    | 23301, 'R' -> Some(label.Replace("AV / AR", "AR"))
    | 53405, 'D' -> Some(label.Replace("recharge (rapide) A / V / h", "recharge : ampérage (A)"))
    | 53405, 'F' -> Some(label.Replace("recharge (rapide) A / V / h", "recharge rapide : ampérage (A)"))
    | 53404, 'D' -> Some(label.Replace("recharge (rapide) A / V / h", "recharge : voltage (V)"))
    | 53404, 'F' -> Some(label.Replace("recharge (rapide) A / V / h", "recharge rapide : voltage (V)"))
    | 53403, 'D' -> Some(label.Replace("recharge (rapide) A / V / h", "recharge : durée (heures)"))
    | 53403, 'F' -> Some(label.Replace("recharge (rapide) A / V / h", "recharge rapide : durée (heures)"))
    | _ -> None

let computeLabelWithComplementaryInfo (label: string) (variation: Variation) =
    match variation.Schema with
    | 14103 -> Some(label + " : largeur")
    | 14104 -> Some(label + " : profil")
    | _ -> None

Recomposition du résultat

Pour recomposer le résultat, on appelle ces fonctions en séquence, en s’arrêtant à la première qui renvoie une option contenant une valeur. On peut utiliser la même stratégie qu’en C♯ : construire une énumération de ces fonctions, ici via une expression de calcul seq { ... }. Il suffit pour cela d’adapter ces fonctions pour avoir la même signature. En C♯, en spécifiant tous les paramètres, on tombait sur la signature la plus simple à écrire : Func<string>. En F♯, avec l’inférence de type, on a plus de flexibilité.

On peut ainsi choisir d’adapter la première fonction computeLabelTotallyNew: (variation: Variation) -> string option en lui ajoutant un premier paramètre label: string pour obtenir la signature des trois autres fonctions : (label: string) -> (variation: Variation) -> string option :

let computeLabel (equipment: Equipment) (variation: Variation) =
    let label = equipment.Label.Trim()
    seq {
        fun (_: string) (variation: Variation) -> computeLabelTotallyNew variation
        computeLabelWithReplacementBySchema
        computeLabelWithReplacementByLocation
        computeLabelWithComplementaryInfo
    }
    |> Seq.map (fun tryComputeLabel -> tryComputeLabel label variation)
    |> Seq.find (fun x -> x.IsSome)
    |> (fun x -> defaultArg x label)

Décortiquons cette portion de F♯ :

  • L’expression seq { ... } est équivalente à l’appel de la méthode suivante en C♯ :
public static IEnumerable<Func<string, Variation, Option<string>>> Seq()
{
    yield return (string _, Variation variation) => computeLabelTotallyNew(variation);
    yield return computeLabelWithReplacementBySchema;
    yield return computeLabelWithReplacementByLocation;
    yield return computeLabelWithComplementaryInfo;
}
  • Le mot-clé fun est utilisé en début d’une lambda, elle-même définie avec une flèche fine -> (à comparer avec la flèche épaisse => en C♯).
  • Les fonctions Seq.map et Seq.find sont équivalentes aux méthodes LINQ Select et First.
  • Le symbole |> ( avec une police à ligature telle que FiraCode) est l’opérateur pipe. a |> fn est équivalent à fn a, l’appel de la fonction fn avec l’argument a. L’intérêt de |> se manifeste lorsqu’il s’agit d’enchaîner des appels à des fonctions, le résultat de l’une étant passé en argument de la suivante. Cela simplifie l’écriture du code en évitant de passer par des variables intermédiaires. L’équivalent en C♯ s’obtient avec des API fluent telles que celles de LINQ ou celles des Fluent Builders :
new Builder()
    .WithThis()
    .WithThat()
    .Build();

Revenons à notre problème de recomposition du résultat. On peut aussi opter pour s’adapter à la signature de la première fonction. Alors, on n’a pas même pas besoin de lambda, l’application partielle du paramètre label étant possible étant donné que les fonctions sont curryfiées en F♯ :

let computeLabel (equipment: Equipment) (variation: Variation) =
    let label = equipment.Label.Trim()
    seq {
        computeLabelTotallyNew
        computeLabelWithReplacementBySchema label
        computeLabelWithReplacementByLocation label
        computeLabelWithComplementaryInfo label
    }
    |> Seq.map (fun tryComputeLabel -> tryComputeLabel variation)
    |> Seq.find (fun x -> x.IsSome)
    |> (fun x -> defaultArg x label)

Les fonctions dans la séquence sont désormais unaires, ne prenant qu’un paramètre en entrée de type Variation ; leur signature est Variation -> string option.

Recomposition d’une fonction

Une manière différente de recomposer le résultat consiste à combiner les fonctions en une. Pour cela, on a besoin d’une fonction helper prenant en entrée deux fonctions génériques 'a -> 'b option et renvoyant une fonction de même signature. Son fonctionnement sera le suivant : on commence par appeler la première fonction : si elle renvoie une Option contenant une valeur, on renvoie cette Option, sinon on appelle la deuxième fonction. La fonction prendra la forme d’un opérateur nommé <||> ce qui nous permettra de disposer de la notation infixée qui en simplifie l’usage :

let (<||>) fa fb x =
    match fa x with
    | Some v -> Some v
    | None -> fb x

let computeLabel (equipment: Equipment) (variation: Variation) =
    let label = equipment.Label.Trim()
    let computeLabel' =
        computeLabelTotallyNew
        <||> computeLabelWithReplacementBySchema label
        <||> computeLabelWithReplacementByLocation label
        <||> computeLabelWithComplementaryInfo label
    variation
    |> computeLabel'
    |> (fun x -> defaultArg x label)

Si vous êtes attentif, vous avez remarqué deux choses :

  • En F♯, on peut utiliser dans le nom des fonctions le caractère apostrophe ', prononcé alors Tick en anglais ou Prime en français, de manière à créer une variante d’une autre fonction sans devoir se casser la tête à trouver un nom plus spécifique. C’est d’autant plus approprié que la fonction en question a une portée limitée, ici computeLabel' n’est visible qu’à l’intérieur de computeLabel.
  • L’opérateur <||> accepte un troisième paramètre x. C’est le paramètre à fournir aux fonctions fa et fb. Cette façon de coder est permise parce que l’on peut procéder à l’application partielle des fonctions. De manière alternative, plus explicite mais moins idiomatique, on aurait pu écrire l’opérateur ainsi :
let (<||>) fa fb =
    fun x ->
        match fa x with
        | Some v -> Some v
        | None -> fb x

☝️  Notes :

  • La création d’un opérateur plutôt qu’une fonction normale ne fait pas consensus dans la communauté F♯. En effet, même si cela permet de matérialiser dans le code une suite d’opérations, cela peut être plus difficile à lire. Dans notre cas, on peut lire le code assez bien en disant “OU” : computeLabelTotallyNew <||> computeLabelWithReplacementBySchema label se lit « calcule un libellé totalement nouveau OU calcule le libellé en le remplaçant en fonction du schéma ».
  • Pour ceux qui connaissent le module Option et le comportement monadique, les fonctions bind et map fonctionnent en “ET” logique des “succès” : si l’on a une option avec une valeur, alors on peut “mapper” / “binder”, sinon on reste sur l’option sans valeur. Notre opérateur agit lui comme un “OU” logique des “succès”, la première option renvoyée qui contient une valeur étant celle choisie, d’où le symbole <||> en référence à l’opérateur “OU” (||) en C♯.

Existe-t-il une opération standard correspondant à cet opérateur <||> ?

Avec ce comportement précis, pas à ma connaissance. En revanche, cela correspond à une abstraction plus générale appelée monoïde dont le nom compliqué cache un concept simple et puissant. Pour plus d’informations, voici quelques lectures accessibles sans avoir à se plonger dans l’étude des mathématiques associées (théorie des catégories) :

💡 Code source sur le GitHub de SOAT ici.

Conclusion

Nous avons vu que le F♯ nous a permis d’obtenir une première solution intéressante dans la mesure où l’on y retrouve la table de décision dans un format lisible et compact, ce qui n’était pas le cas en C♯, toujours plus verbeux malgré ses dernières améliorations.

Comme avec l’approche impérative, le traitement a pu être raffiné, décomposé en plusieurs sous-fonctions, mais cette fois-ci en enrobant les résultats partiels dans une Option afin d’éviter de composer avec le null problématique, d’autant plus quand il ne figure pas dans la signature des fonctions (ce qui était le cas jusqu’au C♯ 8).

Nous avons fini en trouvant une manière alternative de recomposer le résultat de manière intrinsèque au moyen de notre propre opérateur <||>, ceci en améliorant la lecture du code (à mon avis).

Afin d’améliorer encore notre base de code, nous n’avons pas encore creusé la piste de la modélisation du domaine par les types, matière où F♯ se distingue clairement de C♯. Voyons cela dans l’article suivant.

Nombre de vue : 61

AJOUTER UN COMMENTAIRE