Accueil > Builders contre Challengers, un match difficile
Walid Ammar
9 janvier 2018

Builders contre Challengers, un match difficile

Builders contre Challengers, Un match difficile

Que dire !? Sujet épineux (^_^)

Dans notre petit monde de “dresseurs de clavier”, nous avons sans doute observé/participé (à) de tels débats qui n’en finissaient jamais… Des débats ou de gros barbus nous expliquent que la meilleure manière de bâtir une structure est de commencer par des fondations “SOLID” (:p) C’est aussi dans ce genre de débat qu’on trouve des paresseux (ou pragmatiques, je ne sais pas vraiment comment les qualifier) qui ne se soucient guère du design et ne proposent qu’à franchir le chemin le plus court pour délivrer ce qui est dû.

Qui a raison et qui a tort ? Difficile à dire, nous allons essayer d’énumérer les avantages et inconvénients de ces deux philosophies/idéologies en opposant deux des principes SOLID les plus connus (SRP et OCP) au principe Agile (KISS).

D’abord quelques définitions :

 

Qu’est-ce que  S.O.L.I.D ?

SOLID est un ensemble de principes de POO issus de différentes études élaborées dans les années 1970/1980 sur la cohésion, la sémantique et sur certaines heuristiques de modélisation. Parmi les chercheurs qui ont insufflés les premières idées, on distingue Tom DEMARCO, Bertrand MEYER et Barbara LISKOV. Ces principes furent regroupés et présentés sous une forme agrégée de 5 alinéas par notre cher “Uncle BOB” il y a plus de 15 ans.

 

SRP: le “S” de SOLID qui veut dire “Single Responsibility Principle”

C’est le premier des 5 principes SOLID. Il est en apparence facile et simple à expliquer mais croyez-moi, il est souvent mal compris et/ou mal interprété.
Je ne vais pas reprendre volontairement la définition Wikipédia avec laquelle je suis en désaccord, je vous propose à la place celle d’Uncle BOB qui me semble plus “correcte” :

“A class should have only one reason to change”.

Nous assumerons à partir de là qu’une responsabilité veut dire “raison de changement” et non pas une quelconque forme de cohésion sémantique (comme le soutiendraient nos amis Wikipediens).

 

OCP : le “O” de SOLID qui veut dire “Open/Closed Principle”

Deuxième principe de la famille SOLID, basé sur les propositions de Bertrand MEYER (1988) pour améliorer la stabilité d’un logiciel. Je ne pense pas qu’il y ait de définition exacte à ce principe, toutefois, la plupart (y compris Uncle BOB) l’assimile comme suit :

 “Software entities (classes, modules, functions, etc…) should be open for extension but closed for modification”.

KISS : Keep it Simple, Stupid

Tout est dans le titre (^_^)

 

Cas Pratique Builders contre Challengers

Maintenant, passons à l’illustration et voyons comment ces principes ne font pas toujours bon ménage.

Imaginons ce qui suit :
on va produire un algorithme pour le calcul des prix de jeux vidéo dans une boutique quelconque.

1. Un jeu est caractérisé par 3 éléments :
– Plateform : [Ps3, Ps4, XboxOne, Xbox360, Switch]
– Category : [Sport, Combat, Rpg, Mmo, Strategy]
– Release : [Upcoming, New, Average, Old, VeryOld]

2. Le prix est calculé comme suit
– Tous les nouveaux jeux (Upcoming/New) commencent à 69,99€, leurs prix décroissent de 20€ à chaque baisse de release (Average = 49,99€ / Old = 29,99€ …).
– A Noël (tout le mois de décembre), une remise de 20% est appliquée à tous les jeux.
– Pendant les soldes d’hiver (les mois de janvier et février), une remise de 15% est appliquée à tous les jeux PlayStation (Ps3 et Ps4)
– Pendant les soldes d’été (les mois de juin + juillet), une remise de 25% est appliquée à tous les jeux sportifs (Sport, combat).

 

Solution du “Builder”

Voila pour l’énoncé, essayons d’implémenter tout ceci en nous mettant dans la peau d’un “Builder” au sang allemand. Cela implique le respect le plus rigoureux des principes SRP et OCP :
1. Nos classes ne doivent pas avoir qu’une seule raison pour changer.
2. L’extension est la seule manière de rajouter de nouvelles règles métier.

Je choisis donc d’employer les design pattern Strategy et Factory ainsi qu’un peu d’abstraction.

D’abord quelques enum :

public enum GamePlatform
{
     Ps3, Ps4, XboxOne, Xbox360, Switch
}

public enum GameCategory
{
     Sport, Combat, Rpg, Mmo, Strategy
}

public enum GameRelease
{
     Upcoming, New, Average, Old, VeryOld
}

Ensuite la classe représentant le jeu :

public class Game
{
     public GamePlatform Platform { get; set; }
     public GameCategory Category { get; set; }
     public GameRelease Release { get; set; }
}

Et maintenant nous attaquons la partie pattern, commençons d’abord par les contrats :

public interface ICalculationStrategy
{
     decimal Calculate();
}

public interface ICalculationFactory
{
     ICalculationStrategy GetCalculationStrategy(Game game);
}

Enchaînons ensuite avec le calcul de prix qui se fait en 2 temps. Un premier calcul commun à toutes les stratégies et un deuxième calcul spécifique. Pour héberger le calcul commun, je passe par une classe abstraite :

public abstract class AbstractPriceCalculation : ICalculationStrategy
{
     protected readonly Game Game;

     protected AbstractPriceCalculation(Game game)
     {
          Game = game;
     }

     public decimal Calculate()
     {
          var startingPrice = CommonStartupCalculation();
          var finalPrice = InternalCalculate(startingPrice);
          return finalPrice;
     }

     private decimal CommonStartupCalculation()
     {
          var startingPrice = 69.99M;
          var discountAmountPerReleaseDowngrade = 20M;

          switch (Game.Release)
          {
               case GameRelease.Upcoming:
               case GameRelease.New:
                    return startingPrice;

               case GameRelease.Average:
                    return startingPrice - discountAmountPerReleaseDowngrade;

               case GameRelease.Old:
                    return startingPrice - (2 * discountAmountPerReleaseDowngrade);

               case GameRelease.VeryOld:
               return startingPrice - (3 * discountAmountPerReleaseDowngrade);

               default:
                    throw new NotSupportedException("Game release not supported by price calculation");
          }
     }

     protected abstract decimal InternalCalculate(decimal price);
}

Ensuite, nos différentes stratégies comme dictées par les règles de calcul plus haut :


public class ChristmasCalculation : AbstractPriceCalculation
{
     public ChristmasCalculation(Game game) : base(game) {}

     protected override decimal InternalCalculate(decimal price)
     {
          var discount = 0.2M;
          return price * (1 - discount);
     }
}

public class WinterSalesCalculation : AbstractPriceCalculation
{
     public WinterSalesCalculation(Game game) : base(game) {}

     protected override decimal InternalCalculate(decimal price)
     {
          var discount = Game.Platform == GamePlatform.Ps3 || Game.Platform == GamePlatform.Ps4
               ? 0.15M
               : 0M;
          return price * (1 - discount);
     }
}

public class SummerSalesCalculation : AbstractPriceCalculation
{
     public SummerSalesCalculation(Game game) : base(game) {}

     protected override decimal InternalCalculate(decimal price)
     {
          var discount = Game.Category == GameCategory.Sport || Game.Category == GameCategory.Combat
               ? 0.25M
               : 0M;
          return price * (1 - discount);
     }
}

public class NormalCalculation : AbstractPriceCalculation
{
     public NormalCalculation(Game game) : base(game) {}

     protected override decimal InternalCalculate(decimal price)
     {
          return price;
     }
}

Il ne reste plus que notre “factory” qui va déterminer la stratégie à employer en fonction d’un jeu quelconque.

public class CalculationFactory : ICalculationFactory
{
     public ICalculationStrategy GetCalculationStrategy(Game game)
     {
          switch (DateTime.UtcNow.Month)
          {
               case 1:
               case 2:
                    return new WinterSalesCalculation(game);

               case 6:
               case 7:
                    return new SummerSalesCalculation(game);

               case 12:
                    return new ChristmasCalculation(game);

               default:
                    return new NormalCalculation(game);
          }
     }
}

Et voila, il ne reste plus qu’à raccorder le tout sur notre programme principal.

 
class Program
{
     public static void Main(string[] args)
     {
          var game = new Game
          {
               Category = GameCategory.Sport,
               Release = GameRelease.Average,
               Platform = GamePlatform.Ps4
          };

          var price = new CalculationFactory().GetCalculationStrategy(game).Calculate();

          Console.WriteLine($"The price is {price}");
     }
}

ET voilà c’est fait, qu’en pensez-vous ? C’est bien ? Moyen ? Pas bien du tout ?
Avant de vous prononcer, voyons voir comment l’aurait implémenté notre ami “flemmard” et jugeons ensuite.

 

La solution du “Challenger”

Le “challenger” est une personne simple et partisane du moindre effort. Sa solution est plutôt linéaire et “designless” > Une seule classe pour tout faire.

public class GamePriceCalculator
{
     public decimal Calculate(Game game)
     {
          var price = GetStartingPriceByRelease(game.Release);

          if (IsWinterSales() && IsPlaystationGame(game.Platform))
               return price * 0.85M;

          if (IsSummerSales() && IsSportGame(game.Category))
               return price * 0.75M;

          if (IsChristmas())
               return price * 0.80M;

          return price;
     }

     private bool IsWinterSales() = DateTime.UtcNow.Month == 1 || DateTime.UtcNow.Month == 2;
     private bool IsSummerSales() = DateTime.UtcNow.Month == 6 || DateTime.UtcNow.Month == 7;
     private bool IsChristmas() = DateTime.UtcNow.Month == 12;

     private bool IsPlaystationGame(GamePlatform platform) = platform == GamePlatform.Ps3 || platform == GamePlatform.Ps4;
     private bool IsSportGame(GameCategory category) = category == GameCategory.Sport || category == GameCategory.Combat;

     private decimal GetStartingPriceByRelease(GameRelease release)
     {
          var startingPrice = 69.99M;
          var discountAmountPerReleaseDowngrade = 20M;

          var startingPricesByRelease = new Dictionary<GameRelease, decimal>
          {
               [GameRelease.Upcoming] = startingPrice,
               [GameRelease.New] = startingPrice,
               [GameRelease.Average] = startingPrice - discountAmountPerReleaseDowngrade,
               [GameRelease.Old] = startingPrice - (2 * discountAmountPerReleaseDowngrade),
               [GameRelease.VeryOld] = (3 * discountAmountPerReleaseDowngrade),
          };

          return startingPricesByRelease[release];
     }
}

Comparatif entre les deux solutions

La première méthode inclut quelques design patterns et respecte bien les principes SRP et OCP :

  1. Pour rajouter une variante de calcul (promo de rentrée scolaire, déstockage de printemps…) il nous suffirait de rajouter de nouvelles stratégies sans toucher à celles existantes >> OCP Complient
  2. Les classes sont relativement petites et n’ont qu’une seule raison de changer (je vous laisse vérifier :p) >> SRP Complient.

La deuxième méthode n’emploie que des heuristiques fonctionnelles (f(x)=EXP) pour décrire ces règles métier, il n’y a qu’une seule classe pour tout faire et donc pas vraiment OCP ni SRP.

Essayons maintenant de comparer ces approches en se basant sur ces quatre critères :

 

Testabilité

  • Builder : testabilité optimisée car “SRP Complient”, chaque classe peut-être testée toute seule sans trop de difficulté.
  • Challenger : testabilité qui n’est pas optimisée pour la rédaction des tests unitaires car il faudrait prendre en considération beaucoup de règles métier, également l’initialisation des tests (Arrange) ce sera plus compliquée et intégrera des configurations inutiles.

 

Evolutivité

  • Builder : l’évolutivité est optimisée dans ce sens car “OCP Complient”, on n’aura pas besoin de toucher au code existant pour rajouter de nouvelles règles.
  • Challenger : moyennement optimisé pour l’évolutivité car de nouvelles règles de calcul impliquent le changement du code existant.

 

Lisibilité

  • Builder : la Lisibilité n’est pas optimisée car elle requiert de la navigation entre les classes pour parcourir toute la séquence de calcul.
  • Challenger : linéaire et se trouve dans une même classe, elle est facilement lisible donc facilement compréhensible.

 

Prise en main

  • Builder : peu s’avérer lourde pour de jeunes développeurs non habitués aux design patterns.
  • Challenger : plutôt facile à prendre en main car la structure est linéaire et se lit/comprend comme un scrip.

 

Qu’en pensez vous ?

Pour ma part, et au risque de vous surprendre, j’opterais pour la solution du “Challenger”.

D’abord parce qu’il ne faut jamais appliquer le principe OCP quand on démarre un nouveau code, il faut toujours le garder en tête et penser à l’employer au “renouvellement” de ce dernier (“Refacto” ou “Changement”) !

Je reprends les mots d’Uncle Bob dans son ouvrage “Agile Principles, Patterns and Practices in C# – 2006” – p129

“Conforming to OCP is expensive. It takes development time and effort to create the appropriate abstraction. Those abstractions also increase the complexity of the software design. There is a limit to the amount of abstraction that the developers can afford.” Clearly, we want to limit the application of OCP to changes that are likely.”
“How do we know which changes are likely? We do the appropriate research, we ask the appropriate questions, and we use our experience and common sense. And after all that, we wait until the change happens.

Également, comme je l’ai cité dans la comparaison ci-dessus, la solution du “challenger” a 2 avantages majeurs :

  1. Elle est facile à comprendre par des développeurs peu expérimentés.
  2. Vu qu’en tant que développeur, on passe plus de 80% de notre temps à lire du code plutôt qu’à l’écrire, la lisibilité est indéniablement un facteur clé de la qualité d’un code. La solution du challenger est nettement plus lisible que celle du Builder.

Attention, je ne dénigre pas du tout les design patterns ou SOLID, il est important de comprendre qu’ils sont utiles et même parfois/souvent (tout dépend du contexte) nécessaires à l’établissement d’un Clean Code.

Je dis juste qu’il faut toujours commencer par simplifier, ensuite structurer et non l’inverse (ceux qui ont longtemps essuyé de grosses refacto où l’on arrête pas d’appuyer sur le bouton DEL se reconnaîtront :)).
Faites simple les amis dès que vous le pourrez, la structure viendra naturellement plus tard.

Longue vie à KISS !

Livre Blanc Cell'insight 1 DevOps

Nos autres articles
Commentaires

J’adhère totalement à ta conclusion. Il s’agit d’une conclusion courageuse amenée par ce que j’appelle personnellement “la maturité”. Les design-patterns, éléments de réutilisabilité, ne devraient pas être envisagés tant que l’on ne sait pas si cela vaut le coup de réutiliser le code, si il sera réutilisé, et comme il le sera… YAGNI. Le fait de réutiliser, même à outrance, fait hélas partie des principes Agiles tels qu’ils nous sont présentés (à tort !)… de plus, concernant le S de SOLID, il est aussi dit que chaque responsabilité doit être embarquée dans une classe unique : “responsibility should be entirely encapsulated by the class”. Lorsque l’on ne parvient pas à trouver le métier dans la solution, il faut se poser des questions ! Well done Walid !!

Laisser un commentaire

Restez au courant des dernières actualités !
Le meilleur de l’actualité sur le Cloud, le DevOps, l’IT directement dans votre boîte mail.