« Un carré est un rectangle ». Voilà ce que les gentils professeurs de géométrie nous ont dit. Mais, peut-on en dire de même en orienté objet ?
Carre extend Rectangle ?
En orienté objet, un rectangle est constitué de 2 attributs : « largeur » et « hauteur » avec leurs « setter » et « getter ». Rien d’incroyable:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
class Rectangle { private $largeur; private $hauteur; public function setHauteur($hauteur) { $this->hauteur = $hauteur; } public function getHauteur() { return $this->hauteur; } public function setLargeur($largeur) { $this->largeur = $largeur; } public function getLargeur() { return $this->largeur; } } |
Un carré, par contre, n’a qu’un attribut : « coté », avec un setter et un getter. Mais imaginons qu’il étende la classe Rectangle, comme dans « la vraie vie ». Comment l’objet Carre va-t-il gérer les méthodes setter et getter de rectangle ? Les méthodes « set/getHauteur » et « set/getLargeur » ne sont pas relevantes ou pertinentes pour un carré ! Essayons de contourner le problème:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class Carre extends Rectangle { //$this->hauteur représentera mon "coté" et $this->largeur ne sera donc plus jamais utilisé public function setLargeur($largeurEtHauteur) { //Tant pis pour largeur qui sera toujours vide :( $this->setHauteur($largeurEtHauteur); } public function getLargeur() { return $this->getHauteur(); } } |
Tout cela « fonctionne », mais avouez qu’on est plus dans de la bidouille pour faire rentrer un carré dans un rectangle. Ceci est une violation du principe de substitution de Liskov. Notre amie Barbara Liskov (oui c’est une dame) a un jour dit :
Si « S » est un sous-type de « T », alors tout objet de type « T » peut être remplacé par un objet de type « S » sans altérer les propriétés désirables du programme concerné.
Pour en revenir à nos carrés et rectangles, cela veut dire que partout où, dans votre code, vous vous attendez à recevoir un rectangle en paramètre, vous pouvez le remplacer par un carré, sans que cela pose un souci.
Ce n’est malheureusement pas le cas, car le développeur qui travaille sur un rectangle veut pouvoir changer la largeur et la hauteur indépendamment l’un de l’autre. C’est impossible avec un carré, ce qui « altère les propriétés d’un programme ».
Exemple concret
Imaginons que vous fassiez un jeu de voitures. Vous avez une classe « Voiture » avec quelques méthodes :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class Voiture { public function avance() { //... } public function recule() { //... } public function tourneAGauche() { //... } public function tourneADroite() { //... } public function ajouteEssence() { //... } public function changePneu() { //... } } |
Votre jeu fait un carton et suite à la pression médiatique, votre patron cède, et vous demande d’ajouter des voitures volantes. « VoitureVolante extend Voiture », ça vous parait pas mal:
1 2 3 |
class VoitureVolante extends Voiture { } |
Sauf que vous avez la méthode « changePneu » qui vous ennuie un peu, car une voiture volante n’a pas de pneus. Que va faire votre méthode dans ce cas-là? Que pouvez-vous faire ? Au choix :
- Réécrire la méthode et ne rien faire (méthode vide)
- Réécrire la méthode et lancer une exception « PasDePneuException ».
- Réécrire la méthode et nettoyer les réacteurs ou changer le train d’atterrissage. Mais est-ce le bon nom de méthode ?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
class VoitureVolante extends Voiture { public function changePneu() { //Solution1: Ne rien faire //Solution2: faire Autre chose qui ressemble $this->changeTrainAterissage(); //Solution3: Lancer une exception throw new PasDePneuException(); } //Dois-je mettre ceci en public étant donné qu'on y accède via "changePneu" ? private function changeTrainAterissage() { //... } } |
Aucune des trois réponses n’est viable. En effet, cette méthode n’a pas lieu d’exister dans le cas d’un voiture qui n’a pas de pneu. Conclusion, une voiture volante n’est pas une voiture.
Ça sent le problème…
Voici donc quelques pistes qui vous feront sentir que vous êtes sur la mauvaise voie :
- Vous avez une méthode qui n’a pas lieu d’être dans votre classe, mais vous devez l’implémenter.
- Vous avez du réécrire une méthode de la classe parente car celle-ci ne doit « surtout pas » être exécutée ou ne rien faire.
- Vous avez réécrit une méthode de votre parent et vous lancez une exception, alors que celle-ci n’était pas prévue dans la classe de base. Du coup, vous changez tous les appels en les englobant par un « try/catch »
- Vous utilisez des « instanceof » pour exécuter (ou non) une méthode spécifique (aussi mauvais que le try/catch)
- Vous devez modifier une classe parente pour que votre nouvelle classe fonctionne. Par exemple, ajouter des arguments optionnels.
C’est liste est non-exhaustive, mais assez complète. Evidemment, il faut rester vigilant : Certaines de ces affirmations ne sont pas toujours une preuve qu’il y a un problème. Par exemple, les méthodes vides sont monnaies courantes lorsque nous utilisons le pattern « Template method » ou « Null object ». Nous parlerons de ces patterns (et des autres) dans un autre article.
Comment faire rouler ma voiture volante ? Utiliser le pattern « Adapteur »
Bon alors évidemment, c’est difficile de dire à votre patron : « Boss ! Vous savez, dans notre jeu, la voiture bonus qui vole… Hum… Je ne peux pas l’implémenter car elle ne répondra pas au principe de substitution de Liskov ». S’il vous répond qu’il comprend et qu’il ne faut pas l’implémenter, c’est que vous avez vraiment un patron très cool!
Pour ce genre de problème, une des solutions est d’utiliser un pattern : L’adaptateur. Je vais vous en parler très brièvement, car je compte aussi en écrire un article. En gros, c’est faire rentrer un carré dans un rond (ou un rectangle!), ou bien transformer le port « Lightning » de votre IPhone en USB. Voici un petit bout de code qui devrait vous faire comprendre le principe.
D’abord nous avons la nouvelle interface IVoiture et la classe VoitureVolante qui n’ont pas ou peu de point commun:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
//Interface très simplifiée interface IVoiture { public function avance(); public function changePneu(); } //La Voiture volante, n'a pas la méthode changePneu, et n'est donc pas une voiture class VoitureVolante { //Similaire à "avance" pour une voiture normale public function volePlusVite() { echo "La voiture volante avance/vole plus vite!"; } } |
Ensuite, on va rendre la voiture volante compatible avec la voiture de base grâce à l’adaptateur:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
//Mon fameux adaptateur class VoitureVolanteAdaptateur implements IVoiture { private $voitureVolante; public function __construct(VoitureVolante $voitureVolante) { $this->voitureVolante = $voitureVolante; } public function avance() { $this->voitureVolante->volePlusVite(); } public function changePneu() { //Rien à faire } } |
Et à l’exécution, c’est assez simple:
1 2 3 |
$voitureVolanteAdaptee = new VoitureVolanteAdaptateur(new VoitureVolante()); $voitureVolanteAdaptee->avance(); $voitureVolanteAdaptee->changePneu(); |
Certes, nous avons créé 2 classes « au lieu » d’une, mais la classe VoitureVolante ne se retrouve pas avec des méthodes poubelles, et ne fait que ce qu’elle doit faire.
Conclusion
Alors, qu’est-ce qu’il en ressort ? Sachez juste que l’orienté objet, ce n’est pas toujours « comme dans la vraie vie ». N’étendez pas une classe qui contient trop de méthodes pour votre nouvelle classe. Le but est de se dire : Si je n’étendais pas cette classe (ou l’interface), est-ce que j’aurais de toute façon écrit cette méthode ? S’appellerait-elle de la même manière ? Aurait-elle le même comportement globale? Est-ce que ma sortie (return) et mes erreurs (throw new Exception) sont identiques à ma classe parente?
Si vous avez répondu « oui » à toutes les questions, alors, félicitations, vous avez répondu au principe de substitution de Liskov.