Substitution de Liskov (LSP)
Principe de substitution de Liskov (LSP)
Le principe LSP énonce qu'une classe dérivée doit pouvoir être utilisée à la place de sa classe de base, sans que nous ayons à modifier le comportement attendu de l'application.
Autrement dit, lorsqu'une classe hérite d'une autre classe ou implémente une interface, elle doit respecter le contrat défini par celle-ci. Le code qui utilise cette abstraction ne doit pas avoir besoin de connaître le type concret manipulé.
Pour illustrer ce principe, utilisons le polymorphisme basé sur les interfaces. Soit la classe Invoice avec l'énumération CustomerType :
public class Invoice
{
public decimal Amount { get; set; }
public CustomerType CustomerType { get; set; }
public Invoice(decimal amount, CustomerType customerType)
{
Amount = amount;
CustomerType = customerType;
}
}
public enum CustomerType
{
Individual = 1,
Professional = 2,
Premium = 3
}
Et l'interface suivante IDiscountStrategy :
Cette interface est un contrat implicite : quelle que soit son implémentation, la méthode doit retourner un montant valide correspondant au calcul de la remise appliquée. Voici deux classes implémentant cette interface :
public class IndividualDiscountStrategy : IDiscountStrategy
{
public decimal Calculate(decimal amount)
=> amount * 0.95m;
}
public class ProfessionalDiscountStrategy : IDiscountStrategy
{
public decimal Calculate(decimal amount)
=> amount * 0.90m;
}
La classe InvoiceService peut alors utiliser n'importe quelle stratégie, sans connaître son implémentation :
public class InvoiceService
{
private readonly IDiscountStrategy _strategy;
public InvoiceService(IDiscountStrategy strategy)
{
_strategy = strategy;
}
public decimal CalculateTotal(Invoice invoice)
{
return _strategy.Calculate(invoice.Amount);
}
}
Cette implémentation respecte le principe LSP : quelle que soit la stratégie injectée, la classe InvoiceService continue de fonctionner correctement. En revanche, imaginons qu'une nouvelle implémentation soit développée :
public class PremiumDiscountStrategy : IDiscountStrategy
{
public decimal Calculate(decimal amount)
{
if (amount < 1000m)
throw new InvalidOperationException("La remise Premium ne s'applique qu'à partir de 1 000 €");
return amount * 0.85m;
}
}
Cette classe implémente bien l'interface IDiscountStrategy, mais son implémentation particulière ne respecte pas le comportement attendu. Au lieu de retourner un montant correspondant à une remise valide, elle lève une exception si le montant non remisé est inférieur à 1000€.
Le bloc d'instructions suivant lèvera donc une exception, car le montant de la facture de ne peut être remisé pour un client Premium si le montant est strictement inférieur à 1000€ :
var invoice = new Invoice(850, CustomerType.Premium);
var strategy = new PremiumDiscountStrategy();
var service = new InvoiceService(strategy);
var total = service.CalculateTotal(invoice);
Cette implémentation viole donc le principe LSP, puisqu'il n'est plus possible de remplacer une implémentation de l'interface IDiscountStrategy par une autre implémentation, sans modifier le comportement attendu de l'application. Pour respecter ce principe, toutes les implémentations de cette interface doivent garantir le même contrat : recevoir un montant et retourner un montant calculé, sans imposer au consommateur un comportement inattendu.
Le principe LSP garantit ainsi que les abstractions restent fiables et interchangeables. Il permet de développer des applications plus robustes, dans lesquelles de nouvelles implémentations peuvent être ajoutées, sans remettre en cause le fonctionnement du code existant.
Pour résumer le principe LSP, le code utilisant l'interface IDiscountStrategy ne devrait jamais avoir à se demander quelle implémentation lui a été donnée. Toutes les classes implémentant cette interface doivent pouvoir être utilisées de manière interchangeable.
Implémentation respectant le principe LSP
Voici une autre implémentation de la classe PremiumDiscountStrategy :
public class PremiumDiscountStrategy : IDiscountStrategy
{
public decimal Calculate(decimal amount)
=> amount < 1000m ? amount : amount * 0.85m;
}
Etant donné qu'elle retourne le montant initial lorsque la remise ne s’applique pas, cette implémentation reste substituable et respecte le principe LSP.