Design d’un moteur de mapping en .NET – Approche orientée-objet en C♯
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
Fondamentaux
Prêts pour la question fondamentale ? C’est parti !
✨ Que peut nous apporter ici une approche orientée objet ?
💡 Indices :
- La réponse est à chercher parmi les quatres piliers de l’OOP…
- 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 avecRule
, 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 clauseWhere(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
etWhere
vu que la logique est légèrement différente. De plus, en ayant la clauseWhere
en premier, on n’aura plus à gérer de valeursnull
.
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 5Where(…).Select(…).Take(1).DefaultIfEmpty(…).First()
mais cela oblige à gérer lesnull
, ce qui est un risque (deNullReferenceException
) 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 sansnull
. - On peut terminer par
First()
sans risque d’exception car l’énumération n’est alors jamais vide du fait duDefaultIfEmpty(…)
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émentantIRuleCriteria
et encapsulant laVariation
. 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é avecLocation
, - 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
etCriteria.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 deRuleBuilder
. On pourra accéder directement àWhen
au moyen d’unusing static RuleBuilder
.Operate(…)
désigne une des méthodes de l’instance deRuleBuilder
parmi les trois permettant de définir l’une des opérations :ExchangeWith(value)
,Replace(part, by)
etAppend(value)
. Cela viendra remplacer leurs équivalents actuellement dans la classe statiqueOperation
.criteriaN
désigne une des méthodes de la classe statiqueCriteria
. Pour que cela se lise bien, placé dans leWhen()
, on peut de même importer statiquement ces méthodes et les renommer. Cela donneraWhen(SchemaIs(7407))
,When(SchemaIsIn(23502, 24002))
.- L’opérateur
&
sera surchargé pour composer deux critères entre eux. Cela permettra de remplacerBySchemaAndLocation(53405, 'F')
parSchemaIs(53405) & LocationIs('F')
. Cela nécessite de passer par une classeCriteria
encapsulant le prédicatFunc<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 deFunc<Variation, bool>
àCriteria
et l’on propage le changement dans le corps de la méthodeIsSatisfiedBy()
, - Soit on conserve la propriété
Func<Variation, bool> Criteria
et cela sera auRuleBuilder
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 lambdalabel => ...
.- La conversion implicite
Criteria → Func<Variation, bool>
a lieu lors dunew 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 deReplace
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.