Il n'utilise pas le pattern stratégie, et ça tourne mal !!!

écrit par Jérémy Génard publié le vendredi 7 septembre 2018
Présentation du design pattern Stratégie Ce design pattern est employé lorsque l'application doit appliquer un comportement à l'exécution. Qu'est-ce que ça signifie ? Avant de rentrer dans le vif du sujet, un petit point théorique :

En génie logiciel, le patron stratégie est un patron de conception (design pattern) de type comportemental grâce auquel des algorithmes peuvent être sélectionnés à la volée au cours du temps d'exécution selon certaines conditions.

Le patron de conception stratégie est utile pour des situations où il est nécessaire de permuter dynamiquement les algorithmes utilisés dans une application. Le patron stratégie est prévu pour fournir le moyen de définir une famille d'algorithmes, encapsuler chacun d'eux en tant qu'objet, et les rendre interchangeables. Ce patron laisse les algorithmes changer indépendamment des clients qui les emploient.

Wikipedia - https://fr.wikipedia.org/wiki/Stratégie_(patron_de_conception)

Maintenant, imaginons le problème suivant :   On travaille pour une entreprise énergétique chargée de constituer des contrats de vente d'énergie. Notre contrat peut porter soit sur du gaz soit sur de l'électricité. Dernier point, chaque énergie dispose de son propre algorithme pour calculer le tarif du contrat.  Lorsqu'on va lancer le calcul du prix du contrat, on va écrire un code qui va ressembler à ça :  
public decimal GetTarif_1(Contrat contrat)
{
    if (contrat.TypeEnergie == "GAZ")
    {
        ContratGaz c = (ContratGaz)contrat;
        return (c.Volume * c.PrixFournisseur) / c.DureeContractuelle;
    }
    else
    {
        ContratElectricite c = (ContratElectricite)contrat;
        return (c.Volume * c.PrixMegaWatt + c.CoutDistribution) / c.DureeFourniture;
    }
}
Maintenant, notre fournisseur d'énergie décide de faire des contrats à base d'électricité "classique" (nucléaire, gaz, charbon) ou à base d'énergie renouvelable (solaire, éolien, hydraulique) mais également ajouter le biométhane pour le gaz.   On reprend le code et on ajoute notre nouveau besoin :  
public decimal GetTarifContrat_2(Contrat contrat)
{
    if (contrat.TypeEnergie == "GAZ")
    {
        ContratGaz c = (ContratGaz)contrat;
        return (c.Volume * c.PrixFournisseur) / c.DureeContractuelle;
    }
    else if (contrat.TypeEnergie == "BIOMETHANE")
    {
        ContratBiomethane c = (ContratBiomethane)contrat;
        return (c.Volume * c.PrixFournisseur) / c.DureeContractuelle * c.ReductionBio;
    }
    else if (contrat.TypeEnergie == "ELECTRICITE")
    {
        ContratElectricite c = (ContratElectricite)contrat;
        return (c.Volume * c.PrixMegaWatt + c.CoutDistribution) / c.DureeFourniture;
    }
    else if (contrat.TypeEnergie == "ELECTRICITE_RENOUVELABLE")
    {
        ContratElectriciteRenouvelable c = (ContratElectriciteRenouvelable)contrat;
        return (c.Volume * c.PrixMegaWatt + c.CoutDistribution) / c.DureeFourniture * c.ReductionRenouvelable;
    }

    throw new Exception("Le type de contrat ne possède pas de méthode de calcul.");
}
On voit apparaitre un choix du comportement en fonction d'une variable (ex : TypeEnergie). Ce choix peut être fait sur la base d'une énumération, de la présence d'informations ou non dans un objet, etc... Avec une approche naïve, la solution devient de plus en plus complexe quand le nombre de cas augmentent.  

Solutions et implémentations

Design Patterns [...] est un livre de méthodologie appliquée à la conception logicielle écrit par Erich GammaRichard HelmRalph Johnson et John Vlissides (qui forment le surnommé « Gang of Four », abrégé GoF — « Bande des quatre » en français) et publié en 1994 chez Addison-Wesley. Il est devenu un classique de la littérature en génie logiciel avec de nombreuses rééditions. Wikipedia - https://fr.wikipedia.org/wiki/Design_Patterns
Pour éviter l'explosion de la complexité tout en améliorant la maintenance de notre application, on va mettre en place le pattern Stratégie.   On peut imaginer plusieurs implémentations plus ou moins éloignées de la définition donnée par le GOF.   1 – L'approche naïve  C'est l'implémentation qui nous a posé problème au-dessus.   Elle repose sur l'usage de if/else ou de switch. 

2 – L'approche naïve après refactoring 

On reprend le code à base de if/else qu'on refactor. Chaque bloc if est déplacé dans une classe qui lui est propre. Dans notre cas, on a :  
public static class CalculTarifGaz
{
    public static decimal GetTarif(ContratGaz c)
    {
        return (c.Volume * c.PrixFournisseur) / c.DureeContractuelle;
    }
}

public static class CalculTarifBiomethane
{
    public static decimal GetTarif(ContratBiomethane c)
    {
        return (c.Volume * c.PrixFournisseur) / c.DureeContractuelle * c.ReductionBio;
    }
}

public static class CalculTarifElectricite
{
    public static decimal GetTarif(ContratElectricite c)
    {
        return (c.Volume * c.PrixMegaWatt + c.CoutDistribution) / c.DureeFourniture;
    }
}

public static class CalculTarifElectriciteRenouvelable
{
    public static decimal GetTarif(ContratElectriciteRenouvelable c)
    {
        return (c.Volume * c.PrixMegaWatt + c.CoutDistribution) / c.DureeFourniture * c.ReductionRenouvelable;
    }
}
On remplace les if/else par un switch pour améliorer la lisibilité du code. Le type d'énergie devient une énumération à la place des strings en dur. Combinée au switch, l'IDE peut indiquer une valeur d'énumération non gérée dans le switch.  
public decimal GetTarifContrat_3(Contrat contrat)
{
    switch (contrat.TypeEnergie)
    {
        case TypeEnergie.Gaz:
            return CalculTarifGaz.GetTarif((ContratGaz)contrat);
        case TypeEnergie.Biomethane:
            return CalculTarifBiomethane.GetTarif((ContratBiomethane)contrat);
        case TypeEnergie.Electricite:
            return CalculTarifElectricite.GetTarif((ContratElectricite)contrat);
        case TypeEnergie.ElectriciteRenouvelable:
            return CalculTarifElectriciteRenouvelable.GetTarif((ContratElectriciteRenouvelable)contrat);
        default:
            throw new ArgumentException("Le type de contrat ne possède pas de méthode de calcul.");
    }
}
Avec cette approche, on a augmenté la lisibilité du code et on a donné de la responsabilité aux différentes classes de calcul.  

3 – A base de dictionnaire 

Petite parenthèse, on peut utiliser un dictionnaire pour s'affranchir du switch.
public decimal GetTarifContrat_4(Contrat contrat)
{
    return CalculTarif[contrat.TypeEnergie](contrat);
}

private Dictionary<TypeEnergie, Func<Contrat, decimal>> CalculTarif = new Dictionary<TypeEnergie, Func<Contrat, decimal>>
{
    {TypeEnergie.Gaz,  (c) => CalculTarifGaz.GetTarif((ContratGaz)c)},
    {TypeEnergie.Biomethane,  (c) => CalculTarifBiomethane.GetTarif((ContratBiomethane)c)},
    {TypeEnergie.Electricite,  (c) => CalculTarifElectricite.GetTarif((ContratElectricite)c)},
    {TypeEnergie.ElectriciteRenouvelable,  (c) => CalculTarifElectriciteRenouvelable.GetTarif((ContratElectriciteRenouvelable)c)},
};
 

4 – Implémentation du GOF 

On poursuit le refactoring en ajoutant une interface implémentée par nos classes de calcul. Ceci va nous permettre d'aboutir à l'implémentation du GOF.  
public interface ICalculStrategy
{
    decimal GetTarif(Contrat contrat);
}

public class CalculTarifBiomethaneStrategy : ICalculStrategy
{
    public decimal GetTarif(Contrat contrat)
    {
        var c = contrat as ContratBiomethane;
        return (c.Volume * c.PrixFournisseur) / c.DureeContractuelle * c.ReductionBio;
    }
}

public class CalculTarifElectriciteRenouvelableStrategy : ICalculStrategy
{
    public decimal GetTarif(Contrat contrat)
    {
        var c = contrat as ContratElectriciteRenouvelable;
        return (c.Volume * c.PrixMegaWatt + c.CoutDistribution) / c.DureeFourniture * c.ReductionRenouvelable;
    }
}

public class CalculTarifElectriciteStrategy : ICalculStrategy
{
    public decimal GetTarif(Contrat contrat)
    {
        var c = contrat as ContratElectricite;
        return (c.Volume * c.PrixMegaWatt + c.CoutDistribution) / c.DureeFourniture;
    }
}

public class CalculTarifGazStrategy : ICalculStrategy
{
    public decimal GetTarif(Contrat contrat)
    {
        var c = contrat as ContratGaz;
        return (c.Volume * c.PrixFournisseur) / c.DureeContractuelle;
    }
}
On retrouve notre point d'entrée qui porte le contexte du calcul, une classe qui décide de la stratégie à adopter en fonction du contexte, un ensemble de stratégies qui implémentent notre interface. On a fait le choix d'utiliser un dictionnaire mais un switch aurait produit le même résultat.
private Dictionary<TypeEnergie, ICalculStrategy> CalculTarifStrategy = new Dictionary<TypeEnergie, ICalculStrategy>
{
    {TypeEnergie.Gaz,  new CalculTarifGazStrategy()},
    {TypeEnergie.Biomethane,  new CalculTarifBiomethaneStrategy()},
    {TypeEnergie.Electricite,  new CalculTarifElectriciteStrategy()},
    {TypeEnergie.ElectriciteRenouvelable,  new CalculTarifElectriciteRenouvelableStrategy()},
};

public decimal GetTarifContrat_5(Contrat contrat)
{
    var strategy = CalculTarifStrategy[contrat.TypeEnergie];
    return strategy.GetTarif(contrat);
}

5 – Pour aller plus loin 

On peut également aller plus loin et jouer avec des types génériques pour laisser le système décider de la stratégie à utiliser.   Dans un premier temps, on doit rendre notre interface et nos classes génériques :
public interface ICalculStrategy
{
    decimal GetTarif(object contrat);
}

public interface ICalculStrategy<T> : ICalculStrategy
    where T : Contrat
{
    decimal GetTarif(T contrat);
}

public abstract class CalculStrategy<T> : ICalculStrategy<T> where T : Contrat
{
    public abstract decimal GetTarif(T contrat);

    public decimal GetTarif(object contrat)
    {
        return GetTarif((T)contrat);
    }
}

public class CalculTarifBiomethaneStrategy : CalculStrategy<ContratBiomethane>
{
    public override decimal GetTarif(ContratBiomethane contrat)
    {
        return (contrat.Volume * contrat.PrixFournisseur) / contrat.DureeContractuelle * contrat.ReductionBio;
    }
}

public class CalculTarifElectriciteRenouvelableStrategy : CalculStrategy<ContratElectriciteRenouvelable>
{
    public override decimal GetTarif(ContratElectriciteRenouvelable contrat)
    {
        return (contrat.Volume * contrat.PrixMegaWatt + contrat.CoutDistribution) / contrat.DureeFourniture * contrat.ReductionRenouvelable;
    }
}

public class CalculTarifElectriciteStrategy : CalculStrategy<ContratElectricite>
{
    public override decimal GetTarif(ContratElectricite contrat)
    {
        return (contrat.Volume * contrat.PrixMegaWatt + contrat.CoutDistribution) / contrat.DureeFourniture;
    }
}

public class CalculTarifGazStrategy : CalculStrategy<ContratGaz>
{
    public override decimal GetTarif(ContratGaz contrat)
    {
        return (contrat.Volume * contrat.PrixFournisseur) / contrat.DureeContractuelle;
    }
}
Vous aurez remarqué plusieurs choses :
  • Deux interfaces, une générique et une travaillant avec object
  • Une classe abstraite qui implémente ICalculStrategy<T>
    • Ça va nous permettre de gérer l'implémentation de ICalculStrategy et de laisser les classes héritées de gérer l'implémentation spécifique.
  • Nos classes spécifiques n'implémentent plus ICalculStrategy<T> mais simplement notre classe abstraite CalculStrategy
Il nous manque l'outil de décision :
public static ICalculStrategy GetCalculStrategy<T>(T contrat) where T : Contrat
{
    // On fabrique le type d'interface générique avec le type du contrat.
    Type constructed = typeof(ICalculStrategy<>).MakeGenericType(contrat.GetType());

    // On récupère le type qui manipule le bon type de contrat.
    var strategyType = AppDomain.CurrentDomain.GetAssemblies().SelectMany(x => x.GetTypes())
        .Single(x => !x.IsInterface && !x.IsAbstract && constructed.IsAssignableFrom(x));

    // On fournit une instance de la bonne stratégie à appliquer.
    return (ICalculStrategy)Activator.CreateInstance(strategyType);
}
Ce code nous permet d'obtenir la bonne stratégie à appliquer. Le système de type de C# ne nous permet pas de caster simplement vers quelque chose du genre ICalculStrategy<contrat.GetType()>. C'est pour cette raison qu'on utilise une interface non générique. C'est un fonctionnement bien connue puisqu'elle est la base d'IEnumerable et IEnumerable<T> ! Enfin l'appel final à notre stratégie :
public static decimal GetTarifContrat_6(Contrat contrat)
{
    var calculStrategy = GetCalculStrategy(contrat);
    return calculStrategy.GetTarif(contrat);
}
Avec ce qu'on a produit, on peut même aller plus loin en utilisant un conteneur d'injection type Unity ou Ninject. Le conteneur fera le travail de décision en se basant sur les types.

Conclusion

Pour cet exemple, on abouti sur une implémentation bien trop complexe. En revanche, on voit que le pattern proposé par le GOF est simple et facile à mettre en place. Dans mon quotidien de développeur, j'emploi très régulièrement ce pattern qui offre beaucoup d'avantages :
  • Respect du SRP (Single Responsibility Principle => Pourquoi c'est important ?)
  • Lisibilité du code, chaque élément est correctement nommé
  • Facilité pour tester chaque stratégie
  • Facile à comprendre
En partant d'un code classique que l'on a tous rencontré dans nos projets, on a atteint l'implémentation du pattern Stratégie par le GOF. On a effectué des opérations de refactoring simples pour améliorer la lisibilité, la maintenance et la testabilité du code.