Définition
Le design pattern strategy est un design pattern comportementale qui permet de changer dynamiquement le comportement d'un objet en l'encapsulant dans différentes stratégies. Ce pattern donne la possibilité à un objet de choisir entre plusieurs algorithmes et comportements au moment de l'exécution, plutôt que d'en choisir un seul de manière statique.
Quand l'utiliser ?
Le pattern strategy peut être utilisé quand :
- On veut définir une famille d'algorithmes
- On a besoin d'encapsuler et de rendre des algorithmes ou des comportements interchangeables
- On veut permettre au client (l'objet qui consomme l'algorithme) d'en choisir un au moment de l'exécution
- On veut éviter d'utiliser des structures conditionnelles (
if-else
,switch
) - On veut respecter le principe Open/Closed (ouvert aux extensions, fermé aux modifications)
Exemple pratique
Le code dont les sexempls sont tirés est trouvable sur github : Lien du dêpot GIt
Prenons l'exemple d'un site permettant des paiements en ligne. Au moment ou le montant à payer par le client est calculé, on souhaite être en mesure d'appliquer une réduction de 20%. Voici une simple implémentation en C# :
// DiscountCalculator.cs
namespace DiscountLibrary
{
public class DiscountCalculator
{
// On applique une remise de 20%
public static decimal CalculateDiscount(decimal total, decimal discount)
{
return total * 0.80m;
}
}
}
// Program.cs
using System.Text;
using DiscountLibrary;
class Program
{
static void Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
// On demande un prix total
Console.WriteLine("Entrez le prix total : ");
decimal total = decimal.Parse(Console.ReadLine() ?? "0");
// On applique la reduction saisonière de 20%
decimal totalAfterDiscount = DiscountCalculator.CalculateDiscount(total);
Console.WriteLine($"Prix apres réduxtion : {totalAfterDiscount} €");
}
}
Pour l'instant l'approche semble correcte, le calcul de la réduction fonctionne.
Mais que se passe-t-il si on souhaite calculer des réductions de manières différentes ? En effet la seule façon que l'on à de procéder actuellement est d'appliquer un pourcentage de réduction.
Disons qu'à présent, nous voulions également avoir la possibilité d'avoir une remise fixe.
Voici ce que cela donnerait en gardant la même logique d'implémentation :
// DiscountCalculator.cs
namespace DiscountLibrary
{
public class DiscountCalculator
{
public static decimal CalculateDiscount(decimal total, string discountType, decimal discountValue)
{
decimal finalPrice;
if (discountType == "Percentage")
{
// Remise en pourcentage
finalPrice = total - (total * discountValue / 100);
}
else if (discountType == "Fixed")
{
// Remise fixe
finalPrice = total - discountValue;
}
else
{
throw new ArgumentException("Type de remise inconnu");
}
// On s'assure que le prix final n'est pas inférieur à 0 €
return Math.Max(finalPrice, 0);
}
}
}
// Program.cs
using System.Text;
using DiscountLibrary;
class Program
{
static void Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
// On demande un prix total
Console.WriteLine("Entrez le prix total : ");
decimal total = decimal.Parse(Console.ReadLine() ?? "0");
// On applique la reduction en pourcentage
decimal totalAfterSeasonalDiscount = DiscountCalculator.CalculateDiscount(total, "Percentage", 20m);
Console.WriteLine($"Prix après réduction saisonnière : {totalAfterSeasonalDiscount} €");
// On applique la reduction fixe en euros
decimal totalAfterThresholdDiscount = DiscountCalculator.CalculateDiscount(total, "Fixed", 20m);
Console.WriteLine($"Prix après réduction fixe avec seuil : {totalAfterThresholdDiscount} €");
}
}
Les problèmes de cette approche
Bien que notre implémentation soit fonctionnelle, on en voit tout de suite les principaux défauts :
- Violation du principe Open/Closed ( OCP )
Avec cette approche le code n'est pas ouvert aux extensions et fermé aux modifications comme le recommande l'un des principe du SOLID Programming. C'est en effet tout l'inverse, à chaque fois qu'un nouveau type de remise est ajouté ou que l'on souhaite en modifier un, il faut modifier la méthodeCalculateDiscount
. - Compléxité croissante
La méthode CalculateDiscount` devient difficile à lire et à maintenir à mesure que le nombre de types de remises augmente, favorisant l'apparition de bugs et la rendant difficile à lire. - Couplage élevé = code peu réutilisable
Le calcul de chaque type de remise est enfermé dans une seule méthode, rendant la réutilisation du code dans d'autres contexte difficile. - Manque de testabilité
Pour tester un type spécifique de remise, il faut obligatoirement passer par une structure complexe avec plusieurs cas conditionnels. - Augmentation du nombre d'arguments
Pour chaque nouvelle remise on augmente potentiellement le nombre d'argument. Ce qui nous oblige à implémenter encore plus de logique autour de ceux-ci et à complexifier le code.
La solution
Le strategy pattern vise à isoler les comportements spécifiques d'une classe qui peuvent varier et à les encapsuler en classes séparées appelées stratégies. La classe originale qui implémente ces stratégies garde une référence vers une des stratégies, déléguant ainsi la responsabilité de la sélection de la stratégie au client.
Voici le diagramme UML du stratégie pattern :
On peut distinguer les entités suivantes :
- Le contexte : Il s'agit de la classe qui utilise les stratégies. Comme expliqué précédemment, elle va garder une référence vers une des stratégies (dites concrètes). Le contexte délègue donc l'implémentation de l'algorithme au lieu de le faire directement en son sein, ce qui le rend indépendant des variations d'algorithmes.
- L'interface Stratégie : L'interface commune à toutes les stratégies, qu'elles devront obligatoirement implémenter.
- Stratégies concrètes : Ce sont les classes qui implémentent l'interface Stratégie. Chacune d'entre elles fourni une implémentation unique de l'algorithme ou du comportement à décliner, permettant ainsi une gestion de ce dernier à l'exécution, sans avoir à changer le code du client.
- Le client : L'entité qui va interagir avec le contexte en lui fournissant une stratégie et en déclenchant l'exécution de celle-ci
A présent, appliquons ce design pattern à notre cas de figure, voici le diagramme UML de notre programme :
On a donc la distribution de rôles suivante :
- Le contexte : Notre classe DiscountCalculator
- L'interface Stratégie : IDiscountStrategy qui demande d'implémenter une methode CalculateDiscount
- Stratégies concrètes : Les deux différents algorithmes de calcul de réductions ont été encapsulés dans des classes implémentant l'interface IDiscountStrategy
- Le client : Notre classe principale Program, avec sa methode main
L'implementation
L'interface stratégie (IDiscountStrategy)
// IDIscountStrategy.cs
namespace DiscountLibrary.Interfaces
{
public interface IDiscountStrategy
{
decimal CalculateDiscount(decimal total);
}
}
Les stratégies concrètes (FixedDiscountStrategy et PercentageDiscountStrategy)
//FixedDiscountStrategy.cs
using DiscountLibrary.Interfaces;
namespace DiscountLibrary.DiscountStrategies
{
public class FixedDiscountStrategy : IDiscountStrategy
{
private readonly decimal _fixedAmount;
public FixedDiscountStrategy(decimal fixedAmount)
{
_fixedAmount = fixedAmount;
}
public decimal CalculateDiscount(decimal total)
{
return Math.Max(0, total - _fixedAmount); // On s'assure que le resultat ne soit pas inférieur à 0
}
}
}
//PercentageDiscountStrategy.cs
using DiscountLibrary.Interfaces;
namespace DiscountLibrary.DiscountStrategies
{
public class PercentageDiscountStrategy : IDiscountStrategy
{
private readonly decimal _percentage;
public PercentageDiscountStrategy(decimal percentage)
{
if (percentage > 100 || percentage < 0)
{
throw new ArgumentOutOfRangeException(nameof(percentage), "Le pourcentage de reduction doit être en 0 et 100");
}
_percentage = percentage;
}
public decimal CalculateDiscount(decimal total)
{
return total * (1 - _percentage / 100);
}
}
}
Le contexte ( la classe DiscountCalculator)
//DiscountCalculator.cs
using DiscountLibrary.Interfaces;
namespace DiscountLibrary
{
public class DiscountCalculator
{
// Reference vers la stratégie actuelle
private IDiscountStrategy _strategy;
public DiscountCalculator(IDiscountStrategy strategy)
{
_strategy = strategy;
}
// Permet de changer de stratégie
public void SetDiscountStrategy(IDiscountStrategy strategy)
{
_strategy = strategy;
}
// Déclenche l'éxécution de la stratégie
public decimal Calculate(decimal total)
{
return _strategy.CalculateDiscount(total);
}
}
}
Le client (la classe Program)
// Program.cs
using System.Text;
using DiscountLibrary;
using DiscountLibrary.DiscountStrategies;
class Program
{
static void Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
// On demande un prix total
Console.WriteLine("Entrez le prix total : ");
decimal total = decimal.Parse(Console.ReadLine() ?? "0");
// On créé deux stratégies
var tenEurosDiscount = new FixedDiscountStrategy(10m); // Reduction fixe en euros
var fiftyPercentDiscount = new PercentageDiscountStrategy(50m); // Reduction en pourcentages
// On créé notre contexte qu'on initialise avec une stratégie
var discountCalculator = new DiscountCalculator(tenEurosDiscount);
// On execute la stratégie et on affiche le resultat
decimal discountedPrice = discountCalculator.Calculate(total);
Console.WriteLine($"Prix après réduction fixe de 10€ : {discountedPrice}");
// On change de stratégie
discountCalculator.SetDiscountStrategy(fiftyPercentDiscount);
// On l'éxecute et on affiche le résultat
discountedPrice = discountCalculator.Calculate(total);
Console.WriteLine($"Prix après réduction de 50% : {discountedPrice}");
}
}
Avantages du strategy pattern
Une fois le strategy pattern implémenté, on peut tout de suite en constater les principaux avantages :
- Respect du principe Open/Closed : Le code est à présent ouvert à l'extension, mais fermé à la modification. Pour ajouter une nouvelle stratégie, nul besoin de modifier le code déjà existant.
- Réduction de la complexité : Chaque algorithme est encapsulé dans une stratégie qui lui est propre, rendant ainsi le code plus lisible, maintenable et testable.
- Réutilisabilité du code : Les différentes stratégies sont indépendantes et sont donc réutilisables dans d'autres contextes.
- Facilité de test : Les stratégies peuvent dorénavant être testées individuellement, sans dépendre du contexte ou des autres stratégies.
À présent, imaginons que nous voulions rajouter d'autres méthodes de calcul de réductions comme par exemple une réduction fixe qui se déclenche à partir d'un certain seuil et une autre qui permet de combiner plusieurs réductions. Il nous suffit alors simplement d'implémenter deux nouvelles stratégies concrètes ( voir le dépôt Github) et de les définir actives par le biais de notre contexte quand bon nous semble.
Conclusion
En conclusion, le design pattern Strategy se révèle être une solution élégante et efficace pour gérer la variabilité des comportements d'une classe tout en respectant les principes de programmation modernes, il est cependant nécessaire, comme pour tout design pattern, de s'assurer que son implémentation soit nécessaire et qu'elle n'introduise pas une abstraction excessive ou trop prématurée.