Intermédiaire

Design d’un moteur de mapping en .NET – Approche orientée-objet en C♯

Temps de lecture : 15 minutes

Après avoir poussé la modélisation du domaine selon l’approche fonctionnelle en F♯, revenons au C♯ et à la première solution de type impératif afin d’en améliorer le design en suivant une approche orientée objet (OOP). Nous verrons qu’il s’agit d’une « philosophie » bien différente mais permettant de révéler les mêmes concepts du domaine, absents ou cachés de la première solution.

Cela nous permettra en outre de retomber naturellement sur des implémentations intéressantes, modernisées même, des design patterns. Ces derniers apparaissent alors beaucoup plus simples que lorsque l’on les étudie la première fois, car on ne fait juste qu’appliquer les principes de l’OOP. Commençons donc par revoir ces fondamentaux pour partir sur de bonnes bases.

🏷 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♯

Fondamentaux

Prêts pour la question fondamentale ? C’est parti !

✨ Que peut nous apporter ici une approche orientée objet ?

💡 Indices :

  1. La réponse est à chercher parmi les quatres piliers de l’OOP…
  2. La réponse est en relation avec comment est considéré le switch en OOP…

(📣 Auto-promotion) Ceux qui ont suivi les masterclasses données par la communauté Craft de SOAT auront sûrement trouvé 👋

En effet, nous parlons ici du polymorphisme, malgré tout fortement corrélé avec deux autres piliers : l’abstraction (le concept décliné en différentes implémentations polymorphiques) et l’encapsulation (le fait d’offrir certains comportements basés sur un état interne). En fait, c’est en trouvant cette/ces abstraction(s) que notre code bénéficiera du polymorphisme, remplaçant des conditions s’enchaînant dans un switch, voire se mêlant dans une imbrication de bloc if (else).

Abstraction racine

❝ Une bonne abstraction, en éliminant le besoin de connaître les détails d’implémentation, est un outil particulièrement puissant pour diminuer la charge cognitive. ❞ • ✍️ Arnaud Lemaire • 🔗 Source

Pour trouver cette abstraction, regardons ce que font les méthodes comportant un switch, par exemple la méthode ComputeLabelWithDoubleReplacement ci-dessous et cherchons à expliquer ce qu’elle fait d’une manière générique :

  private static string ComputeLabelWithDoubleReplacement(Variation variation, string label) =>
    (variation.Schema, variation.Location) switch
    {
      (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)"),
      _ => null,
    };

Un des aspects que l’on peut voir est que, sous certaines conditions (portant sur le Schema et la Location), on procède à une opération sur le label (ici un remplacement de texte). On tient le comportement de notre objet 🎉

Comment peut-on le nommer ? Difficile ! Procédons par essais successifs :

  • ConditionalOperationOnLabel ? Un peu long mais c’est un début.
  • LabelMapper ? Plus court mais fait trop technique.
  • Rule ? Comme une règle de gestion ? Pas mal ! Le terme est court, métier, même s’il reste un peu vague.

Pas d’autres idées ? Va pour Rule ! De toute façon, il ne faut pas chercher le terme parfait du premier coup. De plus, dans notre cas, on pourra toujours le renommer plus tard si l’on en trouve un meilleur. C’est sûr que cela sera plus difficile

👁‍🗨 Aller plus loin

Voici quelques pistes afin de poursuivre (ou non) cette recherche d’un terme qui conviendrait le mieux :
• S’il s’agit d’un concept métier que l’on n’a pas encore eu besoin de rendre explicite, il conviendra d’en discuter dans l’équipe projet.
• Sinon, ce concept est peut-être juste technique, un détail d’implémentation. Les risques de choisir un terme alambiqué sont plus élevés. On peut alors se rappeler que la qualité du nommage est proportionnelle à sa portée dans le code : si le concept est utilisé à un endroit limité et plutôt caché comme ici avec Rule, on peut être moins exigeant.

Reste à trouver comment modéliser ses membres pour traduire les aspects Condition et Opération, ce qui revient à concevoir le contrat d’API.

Première API

La conception d’une API se fait à mon avis plus facilement de l’extérieur, en partant du code client, en cherchant l’expressivité des comportements et la simplicité d’usage. Le code en question apparaît sous cette forme dans le premier article :

  private static string ComputeLabel(string label, Variation variation) =>
    new Func<string>[]
    {
      () => ComputeLabelTotallyNew(variation),
      () => ComputeLabelWithReplacementBySchema(variation, label),
      () => ComputeLabelWithReplacementByLocation(variation, label),
      () => ComputeLabelWithDoubleReplacement(variation, label),
      () => ComputeLabelWithComplementaryInfo(variation, label),
    }
    .Select(f => f())
    .Where(s => s != null)
    .DefaultIfEmpty(label)
    .First();

On peut donc imaginer d’avoir une liste non pas de fonctions mais de règles. Pour le moment, mettons de côté les règles concrètes à mettre dans cette liste, ceci pour nous concentrer sur la suite du code, là où l’on utilise les règles.

  • Les conditions du type if (variation.Schema == 53403 && variation.Location == 'D') sont encapsulées dans les fonctions mais on les retrouve à ce niveau sous la forme de la clause Where(s => s != null). On pourrait donc interroger une règle pour savoir si sa condition est vraie, c’est-à-dire si elle est satisfaite : Where(rule => rule.IsSatisfiedBy(variation)).
  • Une fois que l’on sait que la règle peut s’appliquer, on l’applique, tout simplement : Select(rule => rule.ApplyOn(label)).
  • Par rapport au code précédent, on aura juste à inverser Select et Where vu que la logique est légèrement différente. De plus, en ayant la clause Where en premier, on n’aura plus à gérer de valeurs null.

Cela permet d’ébaucher la méthode ComputeLabel :

  private static string ComputeLabel(string label, Variation variation) =>
    new Rule[]
    {
      // [...]
    }
    .Where(rule => rule.IsSatisfiedBy(variation))
    .Select(rule => rule.ApplyOn(label))
    .Take(1)
    .DefaultIfEmpty(label)
    .First();

☝️ Notes

  • On pourrait utiliser une seule instruction condensée FirstOrDefault(rule => rule.IsSatisfiedBy(variation))?ApplyOn(label) ?? label plutôt que la succession des 5 Where(…).Select(…).Take(1).DefaultIfEmpty(…).First() mais cela oblige à gérer les null, ce qui est un risque (de NullReferenceException) et qui augmente la complexité cyclomatique. Je préfère une requête LINQ un peu plus longue mais avec une complexité cyclomatique de 1 et sans null.
  • On peut terminer par First() sans risque d’exception car l’énumération n’est alors jamais vide du fait du DefaultIfEmpty(…) qui le précède.

Notre abstraction Rule suivra donc l’interface suivante :

public interface Rule {
  bool IsSatisfiedBy(Variation variation);
  string ApplyOn(string label);
}

☝️ Notes

La méthode IsSatisfiedBy() prend en paramètre la Variation plutôt que les int schema, char location. C’est un sujet typiquement Clean code. Ce choix est un compromis :

  • On bénéficie du côté pratique d’avoir à ne spécifier qu’un seul paramètre.
  • Cet objet forme une unité sémantique reliant des éléments fortement corrélés (Schema et Location).
  • En revanche, la méthode ne se sert que deux membres de l’objet. Ne serait-ce pas un non-respect du principe SOLID de ségrégation d’interfaces (ISP) ? Ne vaudrait-il mieux pas avoir un objet avec uniquement les deux membres utilisés ? La réponse est “Oui, en effet !” mais c’est une application trop jusqu’au-boutiste de l’ISP.
  • Pour faire les choses exactement dans les règles, on aurait besoin d’une interface spécifique, par exemple IRuleCriteria { int Schema { get; }; char Location { get; } }, que l’on appliquerait à Variation si l’on contrôlait ce type. Comme ce n’est pas le cas, il faudrait passer par un Adapter implémentant IRuleCriteria et encapsulant la Variation. C’est une quantité de code non négligeable.
  • En fait, c’est un compromis acceptable lorsque l’objet passé en paramètre est un Data object : constitué uniquement de données (propriétés), sans comportement (méthodes). Tel est bien notre cas ici. De plus, à être trop spécifique on peut tomber sur une autre problématique : révéler des détails internes d’implémentation auxquels le code appelant sera alors couplé, ce qui devient une entrave aux refactos !
  • Du coup, pour bien gérer cela dans les tests unitaires de ComputeLabel(Equipment equipment, Variation variation), lorsque l’on instancie la variation correspondant au cas testé, on ne spécifie que les valeurs discriminantes ; les autres propriétés sont pré-initialisées avec des valeurs par défaut. Cela peut s’implémenter en C♯ avec le pattern Test Data Builder.

    En TypeScript, vu que ce langage est à typage structural, Variation implémente implicitement toute interface que l’on peut en extraire. Du coup, on peut spécifier in situ les membres utilisés, ceci de plusieurs manières :

    • isSatisfiedBy(variation: { schema: number; location: string }) → par utilisation d’un type ad hoc,
    • isSatisfiedBy(variation: Pick<Variation, 'schema' | 'location'>) → par utilisation d’un type “mappé”,
    • isSatisfiedBy({ schema, location }: Variation) → par déstructuration, que l’on peut combiner avec l’une des deux premières méthodes…

Chaque Rule suit le pattern Tester-Doer qui consiste en une paire de méthodes bool CanDo() / T Do() qui indique la procédure à suivre par l’appelant : il doit demander s’il peut effectuer l’opération avant de l’exécuter.

  • Dans le cas standard, c’est nécessaire car l’objet appelé doit être dans un état approprié pour réaliser l’opération ; sinon, cela se traduirait par l’émission d’une exception Do() { if (!CanDo()) throw… }.
  • On peut améliorer l’API pour éviter ce couplage temporel de plusieurs manières dont deux déjà évoquées : bool TryDo(out T result), Option<T> TryDo().
  • Dans notre cas, il s’agit juste d’identifier la règle qui s’applique et éventuellement d’épargner un peu de temps de calcul ; la règle n’a pas à faire cette vérification elle-même au début de son application. Le couplage temporel est donc acceptable. En outre, la séparation entre la condition et l’opération va nous servir par la suite.

Première ébauche des règles

Passons à l’implémentation des Rule. Nous sommes partis de cinq méthodes ComputeLabelXxx. Voyons ce que cela donne d’avoir les cinq règles associées :

Méthode Règle
ComputeLabelTotallyNew RuleForLabelTotallyNew
ComputeLabelWithReplacementBySchema RuleForReplacementBySchema
ComputeLabelWithReplacementByLocation RuleForReplacementByLocation
ComputeLabelWithDoubleReplacement RuleForDoubleReplacement
ComputeLabelWithComplementaryInfo RuleForComplementaryInfo

RuleForLabelTotallyNew

Cette règle est satisfaite pour un Schema en particulier et son application consiste à renvoyer une valeur prédéfinie, stockée dans une propriété Value, quel que soit le label en entrée :

public sealed class RuleForLabelTotallyNew : Rule
{
  private int Schema { get; }
  private string Value { get; }

  public RuleForLabelTotallyNew(int schema, string value)
  {
    Schema = schema;
    Value  = value;
  }

  public override bool IsSatisfiedBy(Variation variation) =>
    variation.Schema == Schema;

  public override string ApplyOn(string label) =>
    Value;
}

RuleForReplacementBySchema

Cette règle est également satisfaite pour un Schema en particulier et son application consiste à remplacer un texte Choice représentant un choix entre plusieurs valeurs (par exemple "hauteur / profondeur") par un texte de substitution Substitute :

public sealed class RuleForReplacementBySchema : Rule
{
  private int Schema { get; }
  private string Choice { get; }
  private string Substitute { get; }

  public RuleForReplacementBySchema(int schema, string choice, string substitute)
  {
    Schema     = schema;
    Choice     = choice;
    Substitute = substitute
  }

  public override bool IsSatisfiedBy(Variation variation) =>
    variation.Schema == Schema;

  public override string ApplyOn(string label) =>
    label.Replace(Choice, Substitute);
}

RuleForReplacementByLocation

Cette règle est identique à la précédente, sauf qu’elle est satisfaite pour deux critères, un Schema et une Location :

public sealed class RuleForReplacementByLocation : Rule
{
  private int Schema { get; }
  private char Location { get; }
  private string Choice { get; }
  private string Substitute { get; }

  public RuleForReplacementByLocation(int schema, char location, string choice, string substitute)
  {
    Schema     = schema;
    Location   = location;
    Choice     = choice;
    Substitute = substitute
  }

  public override bool IsSatisfiedBy(Variation variation) =>
    variation.Schema   == Schema &&
    variation.Location == Location;

  public override string ApplyOn(string label) =>
    label.Replace(Choice, Substitute);
}

RuleForDoubleReplacement

Ce cas concerne le libellé "recharge (rapide) A / V / h" contenant deux parties à remplacer : "recharge (rapide)" et "A / V / h". Il s’avère que l’on peut tout traiter avec la règle précédente RuleForReplacementByLocation, déclinée six fois :

Variation Location New Label
53405 F Informations de recharge rapide : ampérage (A)
53404 F Informations de recharge rapide : voltage (V)
53403 F Informations de recharge rapide : durée (heures)
53405 D Informations de recharge : ampérage (A)
53404 D Informations de recharge : voltage (V)
53403 D Informations de recharge : durée (heures)

On n’a donc pas besoin de la règle RuleForDoubleReplacement.

RuleForComplementaryInfo

Cette règle est très similaire à RuleForLabelTotallyNew. Seule la méthode ApplyOn est légèrement différente :

public sealed class RuleForComplementaryInfo : Rule
{
  private int Schema { get; }
  private string Value { get; }

  public RuleForComplementaryInfo(int schema, string value)
  {
    Schema = schema;
    Value  = value;
  }

  public override bool IsSatisfiedBy(Variation variation) =>
    variation.Schema == Schema;

  public override string ApplyOn(string label) =>
    label + Value;
}

Mise en pratique

On peut désormais remplir le tableau des règles dans la méthode ComputeLabel :

  private static string ComputeLabel(string label, Variation variation) =>
    new Rule[]
    {
      new RuleForLabelTotallyNew(7407, "Nombre de cylindres"),
      new RuleForLabelTotallyNew(15304, "Puissance (ch)"),
      new RuleForLabelTotallyNew(15305, "Régime de puissance maxi (tr/mn)"),

      new RuleForReplacementBySchema(23502, "an(s) / km", ": durée (ans)"),
      new RuleForReplacementBySchema(24002, "an(s) / km", ": durée (ans)"),
      new RuleForReplacementBySchema(23503, "an(s) / km", ": kilométrage"),
      new RuleForReplacementBySchema(24003, "an(s) / km", ": kilométrage"),
      new RuleForReplacementBySchema(7403, "litres / cm3", "litres"),
      new RuleForReplacementBySchema(7402, "litres / cm3", "cm3"),

      new RuleForReplacementByLocation(23301, 'F', "AV / AR", "AV"),
      new RuleForReplacementByLocation(23301, 'R', "AV / AR", "AR"),
      new RuleForReplacementByLocation(17811, 'D', "conducteur / passager", "conducteur"),
      new RuleForReplacementByLocation(17818, 'D', "conducteur / passager", "conducteur"),
      new RuleForReplacementByLocation(17811, 'P', "conducteur / passager", "passager"),
      new RuleForReplacementByLocation(17818, 'P', "conducteur / passager", "passager"),

      new RuleForReplacementByLocation(53405, 'F', "recharge (rapide) A / V / h", "recharge rapide : ampérage (A)"),
      new RuleForReplacementByLocation(53404, 'F', "recharge (rapide) A / V / h", "recharge rapide : voltage (V)"),
      new RuleForReplacementByLocation(53403, 'F', "recharge (rapide) A / V / h", "recharge rapide : durée (heures)"),
      new RuleForReplacementByLocation(53405, 'D', "recharge (rapide) A / V / h", "recharge : ampérage (A)"),
      new RuleForReplacementByLocation(53404, 'D', "recharge (rapide) A / V / h", "recharge : voltage (V)"),
      new RuleForReplacementByLocation(53403, 'D', "recharge (rapide) A / V / h", "recharge : durée (heures)"),

      new RuleForComplementaryInfo(14103, " : largeur"),
      new RuleForComplementaryInfo(14104, " : profil"),
    }
    // [...]

Bilan :

  • ✔️ C’est une retranscription fidèle de la table de décision.
  • ✔️ Aucun null à gérer.
  • ❌ Les règles sont similaires entre-elles : le code de l’une apparaît comme du copier-coller de la précédente à quelques nuances près.

On pourrait être tenté de traiter cette duplication en mutualisant le code identique dans une classe mère (Rule ? RuleBase : Rule ?) dont les règles hériteraient. Épargnons-nous cet exercice délicat qui ne ferait que montrer la rigidité d’une telle arborescence de classes. Suivons plutôt ce grand principe en OOP :

❝ Favor ‘object composition’ over ‘class inheritance’. ❞ • ✍️ Gang of Four (1995) • 🔗 Sources #1 #2

Séparation des axes de variation

Cette apparente duplication vient du fait que l’on a qu’un seul axe de variation : la règle elle-même. Avec plusieurs axes de variation que l’on combine au sein d’une règle, on n’aura plus ce problème. C’est donc révélateur d’un manque d’abstraction. Adressons cette problématique.

Les règles varient selon les axes suivants :

  • Les critères : Schema seul, multiple ou combiné avec Location,
  • L’opération effectuée : remplacement total, remplacement partiel, complément.

On peut considérer une règle comme étant juste la réunion d’un critère et d’une opération. Ce double aspect se trouve en fait dès le départ dans l’interface que l’on a choisie, en relation un-pour-un avec ces deux méthodes : bool IsSatisfiedBy(Variation variation) et string ApplyOn(string label).

Ce cas de figure correspond au pattern Strategy. En C♯, nous avons le choix de l’implémenter de manière classique sous la forme d’une interface à une seule méthode ou, de façon alternative et peut-être plus moderne, tout simplement incarnée par une fonction lambda. En Java, cette distinction est encore plus ténue avec les interfaces fonctionnelles.

Optons pour les fonctions lambda pour gagner en cérémonial. Néanmoins, nous allons regrouper ces fonctions au sein de classes statiques Criteria et Operation de Factory Methods.

Règle composée d’un critère et d’une opération

Une classe peut tout à fait avoir des membres de type fonction. On peut les rendre publiques de manière à simuler des méthodes et respecter le contrat :

public class Rule
{
  public Func<Variation, bool> IsSatisfiedBy { get; }

  public Func<string, string> ApplyOn { get; }

  public Rule(Func<Variation, bool> isSatisfiedBy, Func<string, string> applyOn)
  {
    IsSatisfiedBy = isSatisfiedBy;
    ApplyOn       = applyOn;
  }
}

L’implémentation est alors très rapide. Mais cela présente un inconvénient majeur : la signature de telles méthodes devient cryptique :

  • IsSatisfiedBy: Func<Variation, bool>
  • ApplyOn: Func<string, string>

Ce n’est pas idiomatique en C♯ et l’on perd le sens du paramètre de ApplyOn : on sait juste que c’est un string, sans savoir que c’est justement le label à traiter. En F♯, on peut facilement créer un type Label pour gagner en sens, et les signatures des fonctions et des lambdas (aka fonctions anonymes) sont similaires :

let isSatisfiedBy (variation: Variation) = true
// val isSatisfiedBy : (variation: Variation) -> bool

let isSatisfiedLambda = fun (variation: Variation) -> true
// val isSatisfiedLambda : Variation -> bool

En C♯, mieux vaut envelopper nos fonctions dans de vraies méthodes. C’est un petit peu plus long à écrire mais cela rend l’usage plus aisé et masque des détails d’implémentation (ce qui va nous permettre de procéder à des refactos sans impacter le code client) :

public class Rule
{
  private Func<Variation, bool> Criteria { get; }

  private Func<string, string> Operation { get; }

  public Rule(Func<Variation, bool> criteria, Func<string, string> operation)
  {
    Criteria  = criteria;
    Operation = operation;
  }

  public bool IsSatisfiedBy(Variation variation) => Criteria(variation);

  public string ApplyOn(string label) => Operation(label);
}

☝️ Note : nous avons procédé par composition de type Forwarding. Si C♯ supportait l’héritage multiple ou une variante telle que les Mixins, cela serait une option à évaluer.

Critère lambda

Nous avons identifié trois critères possibles :

public static class Criteria
{
  public static Func<Variation, bool> BySchema(int schema) =>
    variation => variation.Schema == schema;

  public static Func<Variation, bool> BySchemaAndLocation(int schema, char location) =>
    variation => variation.Schema == schema && variation.Location == location;

  public static Func<Variation, bool> BySchemas(params int[] schemas)
  {
    var schemaSet = schemas.ToImmutableHashSet();
    return variation => schemaSet.Contains(variation.Schema);
  }
}

Opération lambda

Nous avons également identifié trois opérations possibles :

public static class Operation
{
  public static Func<string, string> Exchange(string value) =>
    _ => value;

  public static Func<string, string> Replace(string part, string by) =>
    label => label.Replace(part, by);

  public static Func<string, string> Supplement(string value) =>
    label => label + value;
}

Listing des règles avec lambda

Voici l’usage que cela donne :

private static string ComputeLabel(Variation variation, string label) =>
  new[]
  {
    new Rule(Criteria.BySchema(7407),  Operation.Exchange("Nombre de cylindres")),
    new Rule(Criteria.BySchema(15304), Operation.Exchange("Puissance (ch)")),
    new Rule(Criteria.BySchema(15305), Operation.Exchange("Régime de puissance maxi (tr/mn)")),

    new Rule(Criteria.BySchemas(23502, 24002), Operation.Replace("an(s) / km", ": durée (ans)")),
    new Rule(Criteria.BySchemas(23503, 24003), Operation.Replace("an(s) / km", ": kilométrage")),

    new Rule(Criteria.BySchema(7403),  Operation.Replace("litres / cm3", "litres")),
    new Rule(Criteria.BySchema(7402),  Operation.Replace("litres / cm3", "cm3")),

    new Rule(Criteria.BySchemaAndLocation(23301, 'F'), Operation.Replace("AV / AR", "AV")),
    new Rule(Criteria.BySchemaAndLocation(23301, 'R'), Operation.Replace("AV / AR", "AR")),

    new Rule(Criteria.BySchemaAndLocation(17811, 'D'), Operation.Replace("conducteur / passager", "conducteur")),
    new Rule(Criteria.BySchemaAndLocation(17818, 'D'), Operation.Replace("conducteur / passager", "conducteur")),
    new Rule(Criteria.BySchemaAndLocation(17811, 'P'), Operation.Replace("conducteur / passager", "passager")),
    new Rule(Criteria.BySchemaAndLocation(17818, 'P'), Operation.Replace("conducteur / passager", "passager")),

    new Rule(Criteria.BySchemaAndLocation(53405, 'F'), Operation.Replace("recharge (rapide) A / V / h", "recharge rapide : ampérage (A)")),
    new Rule(Criteria.BySchemaAndLocation(53404, 'F'), Operation.Replace("recharge (rapide) A / V / h", "recharge rapide : voltage (V)")),
    new Rule(Criteria.BySchemaAndLocation(53403, 'F'), Operation.Replace("recharge (rapide) A / V / h", "recharge rapide : durée (heures)")),
    new Rule(Criteria.BySchemaAndLocation(53405, 'D'), Operation.Replace("recharge (rapide) A / V / h", "recharge : ampérage (A)")),
    new Rule(Criteria.BySchemaAndLocation(53404, 'D'), Operation.Replace("recharge (rapide) A / V / h", "recharge : voltage (V)")),
    new Rule(Criteria.BySchemaAndLocation(53403, 'D'), Operation.Replace("recharge (rapide) A / V / h", "recharge : durée (heures)")),

    new Rule(Criteria.BySchema(14103), Operation.Supplement(" : largeur")),
    new Rule(Criteria.BySchema(14104), Operation.Supplement(" : profil")),
  }
  .Where(rule => rule.IsSatisfiedBy(variation))
  .Select(rule => rule.ApplyOn(label))
  .Take(1)
  .DefaultIfEmpty(label)
  .First();

Bilan :

  • ✔️ Meilleur respect du principe DRY
  • ❌ … sauf entre Criteria.BySchemaAndLocation et Criteria.BySchema.
  • ❌ Un peu plus verbeux.

💡 Code source sur le GitHub de SOAT ici.

Amélioration de l’API de création des règles

L’idée est de construire une règle en deux temps selon la syntaxe When(criteria1 & criteria2).Operate(…). Cela peut se réaliser à l’aide des éléments techniques suivants :

  • When() est une Factory Method, c’est-à-dire une méthode statique renvoyant une nouvelle instance de RuleBuilder. On pourra accéder directement à When au moyen d’un using static RuleBuilder.
  • Operate(…) désigne une des méthodes de l’instance de RuleBuilder parmi les trois permettant de définir l’une des opérations : ExchangeWith(value), Replace(part, by) et Append(value). Cela viendra remplacer leurs équivalents actuellement dans la classe statique Operation.
  • criteriaN désigne une des méthodes de la classe statique Criteria. Pour que cela se lise bien, placé dans le When(), on peut de même importer statiquement ces méthodes et les renommer. Cela donnera When(SchemaIs(7407)), When(SchemaIsIn(23502, 24002)).
  • L’opérateur & sera surchargé pour composer deux critères entre eux. Cela permettra de remplacer BySchemaAndLocation(53405, 'F') par SchemaIs(53405) & LocationIs('F'). Cela nécessite de passer par une classe Criteria encapsulant le prédicat Func<Variation, bool>.

Critère objet

On a besoin d’encapsuler les prédicats Func<Variation, bool> dans une classe (nommée Criteria, dans une propriété Predicate) afin de pouvoir surcharger l’opérateur &. Par contre, on a le choix d’impacter ou non la classe Rule :

  • Soit on change le type de la propriété Criteria pour passer de Func<Variation, bool> à Criteria et l’on propage le changement dans le corps de la méthode IsSatisfiedBy(),
  • Soit on conserve la propriété Func<Variation, bool> Criteria et cela sera au RuleBuilder de faire la conversion.

Partons sur cette deuxième option, en proposant une conversion implicite Criteria → Func<Variation, bool> :

public class Criteria
{
  public static Criteria LocationIs(char location) =>
    new Criteria(variation => variation.Location == location);

  public static Criteria SchemaIs(int schema) =>
    new Criteria(variation => variation.Schema == schema);

  public static Criteria SchemaIsIn(params int[] schemas)
  {
    var schemaSet = schemas.ToImmutableHashSet();
    return new Criteria(variation => schemaSet.Contains(variation.Schema));
  }

  private Func<Variation, bool> Predicate { get; }

  private Criteria(Func<Variation, bool> predicate)
  {
    Predicate = predicate;
  }

  public static Criteria operator &(Criteria a, Criteria b) =>
    new Criteria(variation => a.Predicate(variation) && b.Predicate(variation));

  public static implicit operator Func<Variation, bool>(Criteria criteria) => criteria.Predicate;
}

Fluent Builder

L’implémentation d’un Fluent Builder à deux étapes est relativement simple :

public class RuleBuilder
{
  public static RuleBuilder When(Criteria criteria) =>
    new RuleBuilder(criteria);

  private readonly Criteria criteria;

  private RuleBuilder(Criteria criteria)
  {
    this.criteria = criteria;
  }

  public Rule ExchangeWith(string value) =>
    Build(_ => value);

  public Rule Replace(string part, string by) =>
    Build(label => label.Replace(part, by));

  public Rule Append(string value) =>
    Build(label => label + value);

  private Rule Build(Func<string, string> operation) =>
    new Rule(criteria, operation);
}

☝️ Notes :

  • La méthode Build() permet d’écrire son paramètre directement sous la forme d’une lambda label => ....
  • La conversion implicite Criteria → Func<Variation, bool> a lieu lors du new Rule(criteria, ...). Ce côté implicite, limite magique 🧙, est un choix personnel : je considère que c’est un détail d’implémentation et je préfère cela à l’écriture de la conversion (Func<Variation, bool>) criteria certes explicite mais peu élégante. Le reste du code étant simple, cela ne devrait pas poser de problème de maintenance.

Utilisation de l’API complète

L’utilisation de toute l’API donne cela :

using static EquipmentFormatter.Criteria;
using static EquipmentFormatter.RuleBuilder;

// [...]

private static string ComputeLabel(Variation variation, string label) =>
  new[]
  {
    When(SchemaIs(7407)).ExchangeWith("Nombre de cylindres"),
    When(SchemaIs(15304)).ExchangeWith("Puissance (ch)"),
    When(SchemaIs(15305)).ExchangeWith("Régime de puissance maxi (tr/mn)"),

    When(SchemaIsIn(23502, 24002)).Replace("an(s) / km", by: ": durée (ans)"),
    When(SchemaIsIn(23503, 24003)).Replace("an(s) / km", by: ": kilométrage"),

    When(SchemaIs(7403)).Replace("litres / cm3", by: "litres"),
    When(SchemaIs(7402)).Replace("litres / cm3", by: "cm3"),

    When(SchemaIs(23301) & LocationIs('F')).Replace("AV / AR", by: "AV"),
    When(SchemaIs(23301) & LocationIs('R')).Replace("AV / AR", by: "AR"),

    When(SchemaIs(17811) & LocationIs('D')).Replace("conducteur / passager", by: "conducteur"),
    When(SchemaIs(17818) & LocationIs('D')).Replace("conducteur / passager", by: "conducteur"),
    When(SchemaIs(17811) & LocationIs('P')).Replace("conducteur / passager", by: "passager"),
    When(SchemaIs(17818) & LocationIs('P')).Replace("conducteur / passager", by: "passager"),

    When(SchemaIs(53405) & LocationIs('F')).Replace("recharge (rapide) A / V / h", by: "recharge rapide : ampérage (A)"),
    When(SchemaIs(53404) & LocationIs('F')).Replace("recharge (rapide) A / V / h", by: "recharge rapide : voltage (V)"),
    When(SchemaIs(53403) & LocationIs('F')).Replace("recharge (rapide) A / V / h", by: "recharge rapide : durée (heures)"),
    When(SchemaIs(53405) & LocationIs('D')).Replace("recharge (rapide) A / V / h", by: "recharge : ampérage (A)"),
    When(SchemaIs(53404) & LocationIs('D')).Replace("recharge (rapide) A / V / h", by: "recharge : voltage (V)"),
    When(SchemaIs(53403) & LocationIs('D')).Replace("recharge (rapide) A / V / h", by: "recharge : durée (heures)"),

    When(SchemaIs(14103)).Append(" : largeur"),
    When(SchemaIs(14104)).Append(" : profil"),
  }
  .Where(rule => rule.IsSatisfiedBy(variation))
  .Select(rule => rule.ApplyOn(label))
  .Take(1)
  .DefaultIfEmpty(label)
  .First();

💡 Astuce : on a spécifié le nom by: du deuxième paramètre de Replace pour faciliter la lecture. De la sorte, on peut littéralement lire :

  • Le code When(SchemaIs(17811) & LocationIs('D')).Replace("conducteur / passager", by: "conducteur")
  • En français : « Quand (le) schéma est 17811 et (la) localisation est D, remplacer “conducteur / passager” par “conducteur”. »

Bilan :

  • ✔️ L’API est succincte et facile à utiliser.
  • ✔️ Les règles se lisent quasiment comme de l’anglais courant. C’est même plus expressif que la table de décision ! 🎉
  • ❌ Les valeurs (schéma, localisation, texte) restent en dur dans le code. Améliorer les choses en C♯ requiert beaucoup de code. Cela devient plus envisageable en F♯, la fin de l’article précédent montrant un tel exercice.
  • ❌ La logique de sélection de la règle repose encore sur une requête LINQ.

💡 Code source sur le GitHub de SOAT ici.

Conclusion

Nous avons vu comment on pouvait mettre en place en orienté objet une API expressive grâce à un mini DSL mettant en valeur les concepts du domaine tels que les opérations (de rectification des libellés) et leur condition associée. À l’usage, la lisibilité est accrue grâce aux imports statiques et à la surcharge de l’opérateur &, ce qui donne un côté « langage naturel » et allège le cérémonial habituel du C♯.

Cela montre que l’on peut employer ou retrouver des design patterns sans que cela fasse artificiel, bancal ou maladroit, à condition d’exploiter pleinement les fonctionnalités du langage de programmation, en l’occurrence C♯. Les design patterns en question ont pris une forme « modernisée », teintée de programmation fonctionnelle :

  • Builder sous une forme Fluent,
  • Factory au travers de méthodes statiques,
  • Strategy un temps exprimée sous la forme épurée d’une simple lambda.

Reste qu’il est possible d’aller plus loin dans l’approche orientée objet pour intégrer la requête LINQ dans le comportement proposé par l’API même. Cela n’est pas toujours pertinent sur de vrais projets, mais il est ici intéressant de faire cet exercice. Cela fera l’objet de notre prochain article.

Nombre de vue : 71

AJOUTER UN COMMENTAIRE