Découvrez le principe de substitution de Liskov
Le troisième principe SOLID met en valeur non pas une, mais deux brillantes développeuses !
En effet, le principe de substitution a été formulé par Barbara Liskov et Jeannette Wing dans un article intitulé Family Values: A Behavioral Notion of Subtyping [EN] en 1994.
Vous allez voir qu’il complète le second principe : il doit être possible de substituer une classe "parente" par l’une de ses classes enfants (on dit aussi "dérivées").
Pour cela, nous devons garantir que les classes enfants auront le même comportement que la classe qu’elles étendent. Imaginez, dans l’exemple précédent, si nous avions eu des classes enfants qui renvoient des entiers, et d’autres des chaînes de caractères ("20 €").
En substituant une méthode d’envoi par une autre, nous aurions changé le comportement de la fonction Order::getShippingCost
, et probablement introduit un bug dans notre application.
Je comprends, mais comment garantir que l’on ne va pas tout casser ? Cela demande une sacrée discipline !
C’est vrai, mais contrairement aux autres principes qui sont sujets à interprétation, le principe de substitution de Liskov respecte une liste de règles strictes. Vous allez découvrir les trois principales.
Contrôlez les types passés en paramètres de méthodes
Les types de paramètres dans la méthode d’une classe enfant doivent correspondre ou être plus "abstraits" que les types correspondants dans la classe parente.
Un exemple va vous permettre de bien comprendre cette règle.
<?php
class ParentOrder {}
class Order extends ParentOrder {}
class SubOrder extends Order {}
class Cart
{
public function getShippingCost(Order $order)
{
/*...*/
}
}
// Mauvaise idée!
class BadSubCart extends Cart
{
public function getShippingCost(ParentOrder $order)
{
/*...*/
}
}
// Bonne idée!
class GoodSubCart extends Cart
{
public function getShippingCost(SubOrder $order)
{
/*...*/
}
}
Nous sommes dans la classe Cart
("panier" d’un site e-commerce), et nous cherchons toujours à calculer les coûts de livraison.
Si nous remplaçons la classe Cart
par BadSubCart
, nous risquons de créer un bug. Pourquoi ?
Si la méthode BadSubCart::getShippingCost(SubOrder $order)
peut accepter n’importe quelle instance de la classe Order
( SubOrder
est un Order
!), la réciproque n’est pas vraie !
Pour que la classe SubCart
puisse se substituer sans risque à la classe Cart
, il faut que le paramètre passé soit :
une instance de
Order
;une instance parente de
Order
(qui peut le plus, peut le moins ).
Contrôlez les types passés en retour de méthodes
Le type de retour d’une méthode d’une classe enfant doit correspondre, ou être un "sous-type" du type de retour de la classe parente.
Comme vous pouvez le voir, les prérequis sont cette fois contraires à la règle précédente. Quand on y réfléchit, c’est assez logique : si une classe est capable de gérer une fonction qui retourne un Order
, elle devrait être capable de gérer un SubOrder
!
Un petit exemple pour comprendre la règle :
<?php
class ParentOrder {}
class Order extends ParentOrder {}
class SubOrder extends Order {}
class Cart
{
public function getOrder()
{
/*...*/
return new Order();
}
}
// Bonne idée!
class GoodSubCart extends Cart
{
public function getOrder()
{
/*...*/
return new SubOrder();
}
}
// Mauvaise idée!
class BadSubCart extends Cart
{
public function getOrder()
{
/*...*/
return new ParentOrder();
}
}
Admettons que, pour une raison ou une autre, au moment où on le fait, cela fonctionne, et qu'après cela ne fonctionne plus. Y a-t-il un moyen de forcer le code à respecter certaines de ces règles ?
Eh bien, justement, oui ! C’est le rôle de l’interface (que l’on appelle parfois "contrat") en programmation orientée objet.
Tirez profit des interfaces en programmation orientée objet
Une interface va vous permettre de définir proprement le contrat que doivent respecter les objets qui l’implémentent. De cette façon, il n’est plus possible de se tromper sur les types d’entrée ou de retour de vos fonctions ! L’essentiel des langages de programmation sont capables de contractualiser ces contraintes : pour PHP, c’est depuis la version 7.
<?php
class ShippingType {}
class SubShippingType extends ShippingType {}
class ParentOrder {}
class Order extends ParentOrder {}
class SubOrder extends Order {}
interface Cart
{
public function getOrder(ShippingType $type) : Order;
}
// Fatal error: Declaration of WrongParamCart::getOrder(SubShippingType $type): Order must be compatible with Cart::getOrder(ShippingType $type): Order
class WrongParamCart implements Cart
{
public function getOrder(SubShippingType $type) : Order
{
return new Order();
}
}
// Fatal error: Uncaught TypeError: Return value of WrongReturnCart::getOrder() must be an instance of Order, instance of ParentOrder returned
class WrongReturnCart implements Cart
{
public function getOrder(ShippingType $type) : Order
{
return new ParentOrder();
}
}
// Pas d'erreur ici, les contraintes de l'interface sont respectées
class RightCart implements Cart
{
public function getOrder(ShippingType $type) : Order
{
return new Order();
}
}
Si vous utilisez des interfaces, vous n’aurez plus à vous inquiéter du principe de Liskov : c’est votre langage de programmation favori qui s’en préoccupera pour vous. À noter que le résultat aurait été le même avec une classe abstraite Cart plutôt qu'une interface, le principal étant de bien typer vos paramètres et vos méthodes, comme c'est le cas ici :
public function getOrder(ShippingType $type) : Order
Nous précisons que getOrder prend en paramètre un objet ShippingType
et retourne un objet Order
.
Contrôlez les types d’exceptions lancées par les fonctions
Comme pour les types de retours de fonction, cette règle stipule que si une exception est lancée par une classe parente dans certaines conditions, la classe enfant doit également lancer une exception dans ces conditions, et cette exception doit être de même type ou sous-type que l’exception parente.
C’est une règle assez logique :
si l’on s’attend à attraper une exception de type
InvalidShippingCostException
;que l’on remplace notre implémentation par une autre ;
et que cette fois l’exception n’est ni une
InvalidShippingCostException
ni l’une de ses classes dérivées ;alors nous ne serons pas en capacité de l’attraper.
<?php
class InvalidShippingCostException extends \Exception {}
class BadFormattedCostException extends InvalidShippingCostException {}
class WrongShippingMethodException extends \Exception {}
try {
throw new InvalidShippingCostException();
} catch (InvalidShippingCostException $e) {
echo 'ERREUR!';
}
try {
throw new BadFormattedCostException();
} catch (InvalidShippingCostException $e) {
echo 'ERREUR 2!';
}
try {
throw new WrongShippingMethodException();
} catch (InvalidShippingCostException $e) {
echo 'ERREUR 3!';
}
// ERREUR!ERREUR 2!
Mettez en pratique le principe de Liskov
Si nous résumons tout ce que nous avons vu, nous pouvons en tirer quelques bonnes pratiques :
Une classe devrait implémenter une interface, notamment si elle a pour objectif d’être étendue. Dans ce cas, les paramètres d’entrée et de retour des méthodes seront contrôlés par PHP.
Si nous lançons des exceptions, il vaut mieux avoir une classe d’exception par type d’erreur, puis étendre celle-ci pour chaque cas d’erreur. Nous nous assurons dans ce cas que le code qui dépend de notre implémentation sera en capacité de gérer proprement les cas d’erreur.
Si nous reprenons une nouvelle fois l’exercice sur les coûts de livraison, commençons par définir l’interface pour le type de livraison :
<?php
use Order;
use InvalidShippingCost;
interface ShippingType
{
/**
* @throws InvalidShippingCost
*/
public function getCost(Order $order) : float;
}
Nous n’avons donc plus besoin de la classe abstraite (elle tenait le rôle de "contrat léger" dans l’exercice précédent).
Maintenant, nous avons le contrôle total sur le type d’entrée (un Order
) et le type de retour (une valeur flottante), mais que se passe-t-il en cas d’erreur ? Nous devons lever une exception.
<?php
use Exception;
abstract class InvalidShippingCost extends Exception
{
}
Et maintenant, chaque implémentation de ShippingType
doit implémenter l’interface et lancer une exception de type InvalidShippingCost
! Améliorons l’implémentation pour le transport par drone :
<?php
class TooHeavyOrderException extends InvalidShippingCost {}
class DroneShippingType implements ShippingType
{
public function getCost(Order $order) : float
{
if ($order->getWeight() > 10) {
throw new TooHeavyOrderException();
}
$cost = $order->getWeight() * 3;
if ($order->getPlan() == 'rapide') {
$cost = 1.3 * $cost;
}
return (float) $cost;
}
}
Vous remarquerez aussi que j’ai rajouté de la documentation à notre interface, l’annotationthrow
aidera les utilisateurs de cette interface à l’implémenter correctement. De nombreux éditeurs de code et logiciels d’analyse statique permettent aussi de vous remonter une erreur si l’exception lancée n’a pas le bon type.
Exercez-vous !
Vous pouvez encore améliorer le projet de lecteur de musiques extrait du projet fil rouge. Vous avez dû remarquer que le projet n’utilisait pas du tout d’exceptions ! Pas évident pour corriger les bugs.
Après avoir téléchargé l'archive de l'exercice, vous mettrez en place les interfaces adéquates pour les classes MP3
et Ogg
et vous vous assurerez que si on passe le mauvais type de fichier au lecteur, une exception soit lancée de la classe parente InvalidFileException
:
l'exception retournée sera de type
InvalidExtensionException
si l'on essaie de lire un fichier Ogg avec la classeMP3
(et vice versa) ;l'exception retournée sera de type
UnknownExtensionException
si l'on essaie de lire un fichier sans aucune extension.
N’hésitez pas à insister un peu en cas de difficulté, ce cours demande beaucoup de travail de votre part, mais vos futures applications et vos collègues de travail vous en remercieront !
En résumé
Le principe de substitution de Liskov reprend le principe Open/Closed et l'applique au cas particulier de l'héritage de classes : si une classe enfant est une implémentation valide, alors une classe parent doit également l'être (et vice versa).
D'un point de vue fonctionnel, cela revient à formaliser un contrat sur nos objets au sujet de leur implémentation, c’est-à-dire le comportement de leurs fonctions.
En PHP et dans beaucoup d'autres langages orientés objet, ce sont les interfaces qui permettent de formaliser le contrat que doit respecter une classe.
Pour ma part, je vous attends dans le chapitre suivant, où nous allons compléter vos compétences sur l'utilisation des interfaces !