J'ai entendu dire que le principe de substitution de Liskov (LSP) est un principe fondamental de la conception orientée objet. Qu'est-ce que c'est et quels sont quelques exemples de son utilisation?
J'ai entendu dire que le principe de substitution de Liskov (LSP) est un principe fondamental de la conception orientée objet. Qu'est-ce que c'est et quels sont quelques exemples de son utilisation?
Réponses:
Un bon exemple illustrant le LSP (donné par l'oncle Bob dans un podcast que j'ai entendu récemment) était comment parfois quelque chose qui sonne bien en langage naturel ne fonctionne pas tout à fait dans le code.
En mathématiques, a Square
est a Rectangle
. En effet c'est une spécialisation d'un rectangle. Le "est un" donne envie de modéliser cela avec l'héritage. Cependant, si dans le code dont vous avez Square
dérivé Rectangle
, a Square
devrait être utilisable partout où vous vous attendez a Rectangle
. Cela crée un comportement étrange.
Imaginez que vous aviez SetWidth
et les SetHeight
méthodes de votre Rectangle
classe de base; cela semble parfaitement logique. Cependant, si votre Rectangle
référence pointait vers a Square
, alors SetWidth
et SetHeight
n'a pas de sens car le réglage de l'un changerait l'autre pour qu'il corresponde. Dans ce cas, Square
le test de substitution Liskov échoue Rectangle
et l'abstraction d'avoir Square
hérité Rectangle
est mauvaise.
Vous devriez vérifier les autres affiches de motivation inestimables des principes SOLID .
Square.setWidth(int width)
était implémenté comme ceci this.width = width; this.height = width;
:? Dans ce cas, il est garanti que la largeur est égale à la hauteur.
Le principe de substitution de Liskov (LSP, lsp) est un concept de programmation orientée objet qui stipule:
Les fonctions qui utilisent des pointeurs ou des références à des classes de base doivent pouvoir utiliser des objets de classes dérivées sans le savoir.
Le cœur du LSP concerne les interfaces et les contrats ainsi que la façon de décider quand étendre une classe ou utiliser une autre stratégie telle que la composition pour atteindre votre objectif.
La façon la plus efficace que je l' ai vu pour illustrer ce point était Head First OOA & D . Ils présentent un scénario où vous êtes un développeur sur un projet pour construire un cadre pour les jeux de stratégie.
Ils présentent une classe qui représente un tableau qui ressemble à ceci:
Toutes les méthodes prennent les coordonnées X et Y comme paramètres pour localiser la position de la tuile dans le tableau bidimensionnel de Tiles
. Cela permettra à un développeur de jeu de gérer les unités du plateau au cours du jeu.
Le livre modifie ensuite les exigences pour dire que le cadre de jeu doit également prendre en charge les plateaux de jeu 3D pour accueillir les jeux qui ont un vol. Donc, une ThreeDBoard
classe est introduite qui s'étend Board
.
À première vue, cela semble être une bonne décision. Board
fournit à la fois le Height
et Width
propriétés et ThreeDBoard
fournit l'axe Z.
Où il tombe en panne, c'est quand vous regardez tous les autres membres hérités Board
. Les méthodes pour AddUnit
, GetTile
, GetUnits
et ainsi de suite, toutes les deux paramètres X et Y dans la Board
classe mais a ThreeDBoard
besoin d' un paramètre Z ainsi.
Vous devez donc implémenter à nouveau ces méthodes avec un paramètre Z. Le paramètre Z n'a pas de contexte pour la Board
classe et les méthodes héritées de la Board
classe perdent leur signification. Une unité de code tentant d'utiliser la ThreeDBoard
classe comme classe de base Board
serait très malchanceuse.
Nous devrions peut-être trouver une autre approche. Au lieu de s'étendre Board
, ThreeDBoard
devrait être composé d' Board
objets. Un Board
objet par unité de l'axe Z.
Cela nous permet d'utiliser de bons principes orientés objet comme l'encapsulation et la réutilisation et ne viole pas le LSP.
La substituabilité est un principe de la programmation orientée objet stipulant que, dans un programme informatique, si S est un sous-type de T, alors les objets de type T peuvent être remplacés par des objets de type S
faisons un exemple simple en Java:
public class Bird{
public void fly(){}
}
public class Duck extends Bird{}
Le canard peut voler à cause de c'est un oiseau, mais qu'en est-il:
public class Ostrich extends Bird{}
L'autruche est un oiseau, mais elle ne peut pas voler, la classe d'autruche est un sous-type de la classe Oiseau, mais elle ne peut pas utiliser la méthode de la mouche, ce qui signifie que nous brisons le principe LSP.
public class Bird{
}
public class FlyingBirds extends Bird{
public void fly(){}
}
public class Duck extends FlyingBirds{}
public class Ostrich extends Bird{}
Bird bird
. Vous devez lancer l'objet sur FlyingBirds pour utiliser fly, ce qui n'est pas bien non?
Bird bird
, cela signifie qu'il ne peut pas l'utiliser fly()
. C'est ça. Passer un Duck
ne change rien à ce fait. Si le client aFlyingBirds bird
, alors même s'il est adopté, Duck
il devrait toujours fonctionner de la même manière.
LSP concerne les invariants.
L'exemple classique est donné par la déclaration de pseudo-code suivante (implémentations omises):
class Rectangle {
int getHeight()
void setHeight(int value)
int getWidth()
void setWidth(int value)
}
class Square : Rectangle { }
Nous avons maintenant un problème bien que l'interface corresponde. La raison en est que nous avons violé les invariants issus de la définition mathématique des carrés et des rectangles. La façon dont les getters et setters fonctionnent, Rectangle
devrait satisfaire l'invariant suivant:
void invariant(Rectangle r) {
r.setHeight(200)
r.setWidth(100)
assert(r.getHeight() == 200 and r.getWidth() == 100)
}
Cependant, cet invariant doit être violé par une mise en œuvre correcte deSquare
, il n'est donc pas un substitut valide de Rectangle
.
Robert Martin a un excellent article sur le principe de substitution de Liskov . Il examine les manières subtiles et pas si subtiles dont le principe peut être violé.
Quelques parties pertinentes de l'article (notez que le deuxième exemple est fortement condensé):
Un exemple simple d'une violation de LSP
L'une des violations les plus flagrantes de ce principe est l'utilisation des informations de type d'exécution C ++ (RTTI) pour sélectionner une fonction en fonction du type d'un objet. c'est à dire:
void DrawShape(const Shape& s) { if (typeid(s) == typeid(Square)) DrawSquare(static_cast<Square&>(s)); else if (typeid(s) == typeid(Circle)) DrawCircle(static_cast<Circle&>(s)); }
De toute évidence, la
DrawShape
fonction est mal formée. Il doit connaître tous les dérivés possibles de laShape
classe, et il doit être modifié chaque fois que de nouveaux dérivés deShape
sont créés. En effet, beaucoup considèrent la structure de cette fonction comme un anathème pour la conception orientée objet.Carré et rectangle, une violation plus subtile.
Cependant, il existe d'autres façons, beaucoup plus subtiles, de violer le LSP. Considérez une application qui utilise la
Rectangle
classe comme décrit ci-dessous:class Rectangle { public: void SetWidth(double w) {itsWidth=w;} void SetHeight(double h) {itsHeight=w;} double GetHeight() const {return itsHeight;} double GetWidth() const {return itsWidth;} private: double itsWidth; double itsHeight; };
[...] Imaginez qu'un jour les utilisateurs exigent la possibilité de manipuler des carrés en plus des rectangles. [...]
De toute évidence, un carré est un rectangle à toutes fins utiles. Étant donné que la relation ISA est valide, il est logique de modéliser la
Square
classe comme étant dérivée deRectangle
. [...]
Square
héritera des fonctionsSetWidth
etSetHeight
. Ces fonctions sont tout à fait inappropriées pour aSquare
, car la largeur et la hauteur d'un carré sont identiques. Cela devrait être un indice significatif qu'il y a un problème avec la conception. Cependant, il existe un moyen de contourner le problème. Nous pourrions passer outreSetWidth
etSetHeight
[...]Mais considérez la fonction suivante:
void f(Rectangle& r) { r.SetWidth(32); // calls Rectangle::SetWidth }
Si nous transmettons une référence à un
Square
objet dans cette fonction, l'Square
objet sera corrompu car la hauteur ne sera pas modifiée. Il s'agit clairement d'une violation du LSP. La fonction ne fonctionne pas pour les dérivés de ses arguments.[...]
Now the rule for the preconditions and postconditions for derivatives, as stated by Meyer is: ...when redefining a routine [in a derivative], you may only replace its precondition by a weaker one, and its postcondition by a stronger one.
si une condition préalable de classe enfant est plus forte qu'une condition préalable de classe parent, vous ne pouvez pas substituer un enfant à un parent sans violer la condition préalable. D'où LSP.
LSP est nécessaire lorsque certains codes pensent appeler les méthodes d'un type T
et peuvent, sans le savoir, appeler les méthodes d'un type S
, où S extends T
(c'est-à-dire S
hérite, dérive ou est un sous-type du supertype T
).
Par exemple, cela se produit lorsqu'une fonction avec un paramètre d'entrée de type T
est appelée (c'est-à-dire invoquée) avec une valeur d'argument de type S
. Ou, lorsqu'un identifiant de type T
se voit attribuer une valeur de type S
.
val id : T = new S() // id thinks it's a T, but is a S
LSP nécessite que les attentes (c'est-à-dire les invariants) pour les méthodes de type T
(par exemple Rectangle
) ne soient pas violées lorsque les méthodes de type S
(par exemple Square
) sont appelées à la place.
val rect : Rectangle = new Square(5) // thinks it's a Rectangle, but is a Square
val rect2 : Rectangle = rect.setWidth(10) // height is 10, LSP violation
Même un type avec des champs immuables a toujours des invariants, par exemple les poseurs Rectangle immuables s'attendent à ce que les dimensions soient modifiées indépendamment, mais les poseurs Square immuables violent cette attente.
class Rectangle( val width : Int, val height : Int )
{
def setWidth( w : Int ) = new Rectangle(w, height)
def setHeight( h : Int ) = new Rectangle(width, h)
}
class Square( val side : Int ) extends Rectangle(side, side)
{
override def setWidth( s : Int ) = new Square(s)
override def setHeight( s : Int ) = new Square(s)
}
LSP requiert que chaque méthode du sous-type S
ait des paramètres d'entrée contravariants et une sortie covariante.
Des moyens contravariantes la variance est contraire à la direction de la succession, à savoir le type Si
de chaque paramètre d'entrée de chaque méthode du sous - type S
, doit être le même ou un supertype du type Ti
du paramètre d'entrée correspondant de la méthode correspondante du supertype T
.
La covariance signifie que la variance va dans le même sens que l'héritage, c'est-à-dire que le type So
de la sortie de chaque méthode du sous-type S
doit être le même ou un sous -type To
du type de la sortie correspondante de la méthode correspondante du supertype T
.
En effet, si l'appelant pense qu'il a un type T
, pense qu'il appelle une méthode de T
, il fournit alors des arguments de type Ti
et affecte la sortie au type To
. Lorsqu'il appelle réellement la méthode correspondante de S
, chaque Ti
argument d'entrée est affecté à un Si
paramètre d'entrée et la So
sortie est affectée au type To
. Ainsi, s'ils Si
n'étaient pas contravariants Ti
, alors un sous-type Xi
- qui ne serait pas un sous-type de - Si
pourrait être attribué Ti
.
De plus, pour les langages (par exemple Scala ou Ceylan) qui ont des annotations de variance au site de définition sur les paramètres de polymorphisme de type (c.-à-d. Génériques), la co- ou la contre-direction de l'annotation de variance pour chaque paramètre de type du type T
doit être opposée ou la même direction respectivement à chaque paramètre d'entrée ou de sortie (de chaque méthode de T
) qui a le type du paramètre type.
De plus, pour chaque paramètre d'entrée ou sortie ayant un type de fonction, la direction de variance requise est inversée. Cette règle est appliquée récursivement.
Le sous-typage est approprié lorsque les invariants peuvent être énumérés.
Il existe de nombreuses recherches en cours sur la façon de modéliser les invariants, afin qu'ils soient appliqués par le compilateur.
Typestate (voir page 3) déclare et applique des invariants d'état orthogonaux au type. Alternativement, les invariants peuvent être appliqués en convertissant les assertions en types . Par exemple, pour affirmer qu'un fichier est ouvert avant de le fermer, File.open () peut renvoyer un type OpenFile, qui contient une méthode close () qui n'est pas disponible dans File. Une API tic-tac-toe peut être un autre exemple d'utilisation de la saisie pour appliquer des invariants au moment de la compilation. Le système de type peut même être complet de Turing, par exemple Scala . Les langages et les démonstrateurs de théorèmes dépendants formalisent les modèles de typage d'ordre supérieur.
En raison du besoin de la sémantique d' abstraire sur l'extension , je m'attends à ce que l'utilisation de typage pour modéliser les invariants, c'est-à-dire la sémantique dénotationnelle unifiée d'ordre supérieur, soit supérieure à Typestate. «Extension» signifie la composition illimitée et permutée d'un développement modulaire non coordonné. Parce qu'il me semble être l'antithèse de l'unification et donc des degrés de liberté, d'avoir deux modèles mutuellement dépendants (par exemple types et Typestate) pour exprimer la sémantique partagée, qui ne peuvent pas être unifiés entre eux pour une composition extensible . Par exemple, l' extension de type Problème d'expression a été unifiée dans les domaines de sous-typage, de surcharge de fonctions et de typage paramétrique.
Ma position théorique est que pour que la connaissance existe (voir la section «La centralisation est aveugle et impropre»), il n'y aura jamais de modèle général qui puisse appliquer une couverture à 100% de tous les invariants possibles dans un langage informatique complet de Turing. Pour que la connaissance existe, il existe de nombreuses possibilités inattendues, c'est-à-dire que le désordre et l'entropie doivent toujours augmenter. Ceci est la force entropique. Prouver tous les calculs possibles d'une extension potentielle, c'est calculer a priori toutes les extensions possibles.
C'est pourquoi le théorème de Halting existe, c'est-à-dire qu'il est indécidable que tous les programmes possibles dans un langage de programmation complet de Turing se terminent. Il peut être prouvé que certains programmes spécifiques se terminent (un dont toutes les possibilités ont été définies et calculées). Mais il est impossible de prouver que toute extension possible de ce programme se termine, à moins que les possibilités d'extension de ce programme ne soient pas complètes (par exemple via une saisie dépendante). Puisque l'exigence fondamentale de complétude de Turing est une récursion illimitée , il est intuitif de comprendre comment les théorèmes d'incomplétude de Gödel et le paradoxe de Russell s'appliquent à l'extension.
Une interprétation de ces théorèmes les intègre dans une compréhension conceptuelle généralisée de la force entropique:
Je vois des rectangles et des carrés dans chaque réponse, et comment violer le LSP.
Je voudrais montrer comment le LSP peut être conforme à un exemple réel:
<?php
interface Database
{
public function selectQuery(string $sql): array;
}
class SQLiteDatabase implements Database
{
public function selectQuery(string $sql): array
{
// sqlite specific code
return $result;
}
}
class MySQLDatabase implements Database
{
public function selectQuery(string $sql): array
{
// mysql specific code
return $result;
}
}
Cette conception est conforme au LSP car le comportement reste inchangé quelle que soit l'implémentation que nous choisissons d'utiliser.
Et oui, vous pouvez violer LSP dans cette configuration en faisant un simple changement comme ceci:
<?php
interface Database
{
public function selectQuery(string $sql): array;
}
class SQLiteDatabase implements Database
{
public function selectQuery(string $sql): array
{
// sqlite specific code
return $result;
}
}
class MySQLDatabase implements Database
{
public function selectQuery(string $sql): array
{
// mysql specific code
return ['result' => $result]; // This violates LSP !
}
}
Désormais, les sous-types ne peuvent plus être utilisés de la même manière car ils ne produisent plus le même résultat.
Database::selectQuery
pour prendre en charge uniquement le sous-ensemble de SQL pris en charge par tous les moteurs de base de données. Ce n'est guère pratique ... Cela dit, l'exemple est toujours plus facile à saisir que la plupart des autres utilisés ici.
Il y a une liste de contrôle pour déterminer si vous violez ou non Liskov.
Liste de contrôle:
Contrainte historique : lors de la substitution d'une méthode, vous n'êtes pas autorisé à modifier une propriété non modifiable dans la classe de base. Jetez un oeil à ces codes et vous pouvez voir que le nom est défini comme non modifiable (ensemble privé) mais SubType introduit une nouvelle méthode qui permet de le modifier (par réflexion):
public class SuperType
{
public string Name { get; private set; }
public SuperType(string name, int age)
{
Name = name;
Age = age;
}
}
public class SubType : SuperType
{
public void ChangeName(string newName)
{
var propertyType = base.GetType().GetProperty("Name").SetValue(this, newName);
}
}
Il existe 2 autres éléments: Contravariance des arguments de méthode et Covariance des types de retour . Mais ce n'est pas possible en C # (je suis développeur C #) donc je m'en fous.
Référence:
Le LSP est une règle concernant le contrat des classes: si une classe de base satisfait un contrat, les classes dérivées du LSP doivent également satisfaire ce contrat.
En pseudo-python
class Base:
def Foo(self, arg):
# *... do stuff*
class Derived(Base):
def Foo(self, arg):
# *... do stuff*
satisfait LSP si chaque fois que vous appelez Foo sur un objet dérivé, il donne exactement les mêmes résultats que l'appel de Foo sur un objet Base, tant que arg est le même.
2 + "2"
). Peut-être confondez-vous «fortement tapé» avec «tapé statiquement»?
Pour faire court, laissons les rectangles rectangles et carrés carrés, exemple pratique lors de l'extension d'une classe parent, vous devez soit PRÉSERVER l'API parent exacte, soit l'étendre.
Supposons que vous ayez un référentiel ItemsRepository de base .
class ItemsRepository
{
/**
* @return int Returns number of deleted rows
*/
public function delete()
{
// perform a delete query
$numberOfDeletedRows = 10;
return $numberOfDeletedRows;
}
}
Et une sous-classe qui l'étend:
class BadlyExtendedItemsRepository extends ItemsRepository
{
/**
* @return void Was suppose to return an INT like parent, but did not, breaks LSP
*/
public function delete()
{
// perform a delete query
$numberOfDeletedRows = 10;
// we broke the behaviour of the parent class
return;
}
}
Ensuite, un client pourrait travailler avec l'API Base ItemsRepository et s'y fier.
/**
* Class ItemsService is a client for public ItemsRepository "API" (the public delete method).
*
* Technically, I am able to pass into a constructor a sub-class of the ItemsRepository
* but if the sub-class won't abide the base class API, the client will get broken.
*/
class ItemsService
{
/**
* @var ItemsRepository
*/
private $itemsRepository;
/**
* @param ItemsRepository $itemsRepository
*/
public function __construct(ItemsRepository $itemsRepository)
{
$this->itemsRepository = $itemsRepository;
}
/**
* !!! Notice how this is suppose to return an int. My clients expect it based on the
* ItemsRepository API in the constructor !!!
*
* @return int
*/
public function delete()
{
return $this->itemsRepository->delete();
}
}
Le LSP est rompu lorsque le remplacement de la classe parent par une sous-classe rompt le contrat de l'API .
class ItemsController
{
/**
* Valid delete action when using the base class.
*/
public function validDeleteAction()
{
$itemsService = new ItemsService(new ItemsRepository());
$numberOfDeletedItems = $itemsService->delete();
// $numberOfDeletedItems is an INT :)
}
/**
* Invalid delete action when using a subclass.
*/
public function brokenDeleteAction()
{
$itemsService = new ItemsService(new BadlyExtendedItemsRepository());
$numberOfDeletedItems = $itemsService->delete();
// $numberOfDeletedItems is a NULL :(
}
}
Vous pouvez en savoir plus sur l'écriture de logiciels maintenables dans mon cours: https://www.udemy.com/enterprise-php/
Les fonctions qui utilisent des pointeurs ou des références à des classes de base doivent pouvoir utiliser des objets de classes dérivées sans le savoir.
Lorsque j'ai lu pour la première fois sur LSP, j'ai supposé que cela signifiait dans un sens très strict, l'assimilant essentiellement à l'implémentation d'interface et à la conversion de type sécurisé. Ce qui signifierait que le LSP est assuré ou non par la langue elle-même. Par exemple, au sens strict, ThreeDBoard est certainement substituable à Board, en ce qui concerne le compilateur.
Après avoir lu plus sur le concept, j'ai trouvé que le LSP est généralement interprété plus largement que cela.
En bref, ce que signifie pour le code client de "savoir" que l'objet derrière le pointeur est d'un type dérivé plutôt que le type de pointeur n'est pas limité à la sécurité de type. L'adhésion au LSP est également testable en testant le comportement réel des objets. C'est-à-dire, examiner l'impact des arguments d'état et de méthode d'un objet sur les résultats des appels de méthode ou les types d'exceptions levées à partir de l'objet.
Pour revenir à l'exemple, en théorie, les méthodes de la carte peuvent fonctionner correctement sur ThreeDBoard. Dans la pratique cependant, il sera très difficile d'empêcher les différences de comportement que le client peut ne pas gérer correctement, sans entraver les fonctionnalités que ThreeDBoard est censé ajouter.
Avec ces connaissances en main, l'évaluation de l'adhésion au LSP peut être un excellent outil pour déterminer quand la composition est le mécanisme le plus approprié pour étendre les fonctionnalités existantes, plutôt que l'héritage.
Je suppose que tout le monde a en quelque sorte couvert ce qu'est le LSP sur le plan technique: vous voulez essentiellement pouvoir vous abstenir des détails des sous-types et utiliser les supertypes en toute sécurité.
Donc Liskov a 3 règles sous-jacentes:
Règle de signature: il doit y avoir une implémentation valide de chaque opération du supertype dans le sous-type syntaxiquement. Quelque chose qu'un compilateur pourra vérifier pour vous. Il existe une petite règle pour lever moins d'exceptions et être au moins aussi accessible que les méthodes de supertype.
Méthodes Règle: La mise en œuvre de ces opérations est sémantiquement saine.
Règle des propriétés: cela va au-delà des appels de fonction individuels.
Toutes ces propriétés doivent être préservées et la fonctionnalité de sous-type supplémentaire ne doit pas violer les propriétés de supertype.
Si ces trois choses sont prises en compte, vous vous êtes abstenu de la substance sous-jacente et vous écrivez du code faiblement couplé.
Source: Développement de programmes à Java - Barbara Liskov
Un exemple important de l' utilisation de LSP est dans les tests de logiciels .
Si j'ai une classe A qui est une sous-classe conforme à LSP de B, alors je peux réutiliser la suite de tests de B pour tester A.
Pour tester entièrement la sous-classe A, j'ai probablement besoin d'ajouter quelques cas de test supplémentaires, mais au minimum je peux réutiliser tous les cas de test de la superclasse B.
Une façon de le réaliser est de construire ce que McGregor appelle une "hiérarchie parallèle pour les tests": ma ATest
classe héritera de BTest
. Une certaine forme d'injection est alors nécessaire pour garantir que le scénario de test fonctionne avec des objets de type A plutôt que de type B (un modèle de méthode de modèle simple fera l'affaire).
Notez que la réutilisation de la suite de super-tests pour toutes les implémentations de sous-classe est en fait un moyen de tester que ces implémentations de sous-classe sont conformes au LSP. Ainsi, on peut également affirmer qu'il faut exécuter la suite de tests de superclasse dans le contexte de n'importe quelle sous-classe.
Voir aussi la réponse à la question Stackoverflow " Puis-je implémenter une série de tests réutilisables pour tester l'implémentation d'une interface? "
Illustrons en Java:
class TrasportationDevice
{
String name;
String getName() { ... }
void setName(String n) { ... }
double speed;
double getSpeed() { ... }
void setSpeed(double d) { ... }
Engine engine;
Engine getEngine() { ... }
void setEngine(Engine e) { ... }
void startEngine() { ... }
}
class Car extends TransportationDevice
{
@Override
void startEngine() { ... }
}
Il n'y a pas de problème ici, non? Une voiture est définitivement un moyen de transport, et nous pouvons voir ici qu'elle remplace la méthode startEngine () de sa superclasse.
Ajoutons un autre appareil de transport:
class Bicycle extends TransportationDevice
{
@Override
void startEngine() /*problem!*/
}
Tout ne se passe pas comme prévu maintenant! Oui, un vélo est un appareil de transport, cependant, il n'a pas de moteur et, par conséquent, la méthode startEngine () ne peut pas être implémentée.
Ce sont les types de problèmes que conduit à la violation du principe de substitution de Liskov, et ils peuvent le plus souvent être reconnus par une méthode qui ne fait rien, ou même ne peut pas être implémentée.
La solution à ces problèmes est une hiérarchie d'héritage correcte, et dans notre cas, nous résoudrions le problème en différenciant les classes de dispositifs de transport avec et sans moteur. Même si un vélo est un moyen de transport, il n'a pas de moteur. Dans cet exemple, notre définition de dispositif de transport est erronée. Il ne devrait pas avoir de moteur.
Nous pouvons refactoriser notre classe TransportationDevice comme suit:
class TrasportationDevice
{
String name;
String getName() { ... }
void setName(String n) { ... }
double speed;
double getSpeed() { ... }
void setSpeed(double d) { ... }
}
Nous pouvons maintenant étendre TransportationDevice pour les appareils non motorisés.
class DevicesWithoutEngines extends TransportationDevice
{
void startMoving() { ... }
}
Et étendez TransportationDevice pour les appareils motorisés. Il est plus approprié d'ajouter l'objet Engine.
class DevicesWithEngines extends TransportationDevice
{
Engine engine;
Engine getEngine() { ... }
void setEngine(Engine e) { ... }
void startEngine() { ... }
}
Ainsi, notre classe Car devient plus spécialisée, tout en adhérant au principe de substitution Liskov.
class Car extends DevicesWithEngines
{
@Override
void startEngine() { ... }
}
Et notre classe de vélos est également conforme au principe de substitution Liskov.
class Bicycle extends DevicesWithoutEngines
{
@Override
void startMoving() { ... }
}
Cette formulation du LSP est beaucoup trop forte:
Si pour chaque objet o1 de type S il y a un objet o2 de type T tel que pour tous les programmes P définis en termes de T, le comportement de P reste inchangé lorsque o1 se substitue à o2, alors S est un sous-type de T.
Ce qui signifie essentiellement que S est une autre implémentation complètement encapsulée de la même chose que T. Et je pourrais être audacieux et décider que les performances font partie du comportement de P ...
Donc, fondamentalement, toute utilisation de liaison tardive viole le LSP. C'est tout l'intérêt d'OO pour obtenir un comportement différent quand on substitue un objet d'un genre à un autre!
La formulation citée par wikipedia est meilleure car la propriété dépend du contexte et n'inclut pas nécessairement l'ensemble du comportement du programme.
Dans une phrase très simple, nous pouvons dire:
La classe enfant ne doit pas violer ses caractéristiques de classe de base. Il doit en être capable. Nous pouvons dire que c'est la même chose que le sous-typage.
Principe de substitution de Liskov (LSP)
Tout le temps, nous concevons un module de programme et nous créons des hiérarchies de classes. Ensuite, nous étendons certaines classes en créant des classes dérivées.
Nous devons nous assurer que les nouvelles classes dérivées s'étendent simplement sans remplacer la fonctionnalité des anciennes classes. Sinon, les nouvelles classes peuvent produire des effets indésirables lorsqu'elles sont utilisées dans des modules de programme existants.
Le principe de substitution de Liskov stipule que si un module de programme utilise une classe de base, la référence à la classe de base peut être remplacée par une classe dérivée sans affecter la fonctionnalité du module de programme.
Exemple:
Voici l'exemple classique pour lequel le principe de substitution de Liskov est violé. Dans l'exemple, 2 classes sont utilisées: Rectangle et Carré. Supposons que l'objet Rectangle soit utilisé quelque part dans l'application. Nous étendons l'application et ajoutons la classe Square. La classe carrée est renvoyée par un modèle d'usine, basé sur certaines conditions et nous ne savons pas exactement quel type d'objet sera retourné. Mais nous savons que c'est un rectangle. Nous obtenons l'objet rectangle, définissons la largeur à 5 et la hauteur à 10 et obtenons l'aire. Pour un rectangle de largeur 5 et de hauteur 10, la zone doit être 50. Au lieu de cela, le résultat sera 100
// Violation of Likov's Substitution Principle
class Rectangle {
protected int m_width;
protected int m_height;
public void setWidth(int width) {
m_width = width;
}
public void setHeight(int height) {
m_height = height;
}
public int getWidth() {
return m_width;
}
public int getHeight() {
return m_height;
}
public int getArea() {
return m_width * m_height;
}
}
class Square extends Rectangle {
public void setWidth(int width) {
m_width = width;
m_height = width;
}
public void setHeight(int height) {
m_width = height;
m_height = height;
}
}
class LspTest {
private static Rectangle getNewRectangle() {
// it can be an object returned by some factory ...
return new Square();
}
public static void main(String args[]) {
Rectangle r = LspTest.getNewRectangle();
r.setWidth(5);
r.setHeight(10);
// user knows that r it's a rectangle.
// It assumes that he's able to set the width and height as for the base
// class
System.out.println(r.getArea());
// now he's surprised to see that the area is 100 instead of 50.
}
}
Conclusion:
Ce principe n'est qu'une extension du principe Open Close et cela signifie que nous devons nous assurer que les nouvelles classes dérivées étendent les classes de base sans changer leur comportement.
Voir aussi: Open Close Principle
Quelques concepts similaires pour une meilleure structure: convention sur la configuration
Un addendum:
je me demande pourquoi personne n'a écrit sur l'invariant, les conditions préalables et les conditions de publication de la classe de base qui doivent être respectées par les classes dérivées. Pour qu'une classe D dérivée soit entièrement soutenable par la classe B de base, la classe D doit respecter certaines conditions:
Ainsi, le dérivé doit être conscient des trois conditions ci-dessus imposées par la classe de base. Par conséquent, les règles de sous-typage sont prédéterminées. Ce qui signifie que la relation 'EST A' ne doit être respectée que lorsque certaines règles sont respectées par le sous-type. Ces règles, sous forme d'invariants, de précoditions et de postcondition, devraient être décidées par un « contrat de conception » formel .
D'autres discussions à ce sujet sont disponibles sur mon blog: Principe de substitution de Liskov
Le LSP indique en termes simples que les objets de la même superclasse devraient pouvoir être échangés entre eux sans rien casser.
Par exemple, si nous avons une classe Cat
et une Dog
dérivée d'une Animal
classe, toutes les fonctions utilisant la classe Animal devraient pouvoir utiliser Cat
ou Dog
et se comporter normalement.
Est-ce que la mise en œuvre de ThreeDBoard en termes de tableau de bord serait si utile?
Peut-être voudrez-vous traiter des tranches de ThreeDBoard dans divers plans comme une carte. Dans ce cas, vous souhaiterez peut-être extraire une interface (ou une classe abstraite) pour que Board autorise plusieurs implémentations.
En termes d'interface externe, vous voudrez peut-être prendre en compte une interface de carte pour TwoDBoard et ThreeDBoard (bien qu'aucune des méthodes ci-dessus ne convienne).
Un carré est un rectangle dont la largeur est égale à la hauteur. Si le carré définit deux tailles différentes pour la largeur et la hauteur, il viole l'invariant carré. Ceci est contourné en introduisant des effets secondaires. Mais si le rectangle avait un setSize (hauteur, largeur) avec la condition préalable 0 <hauteur et 0 <largeur. La méthode de sous-type dérivée nécessite hauteur == largeur; une condition préalable plus forte (et qui viole lsp). Cela montre que bien que carré soit un rectangle, ce n'est pas un sous-type valide car la condition préalable est renforcée. Le travail autour (en général une mauvaise chose) provoque un effet secondaire et cela affaiblit la condition de poste (qui viole lsp). setWidth sur la base a une condition de post 0 <largeur. Le dérivé l'affaiblit avec hauteur == largeur.
Par conséquent, un carré redimensionnable n'est pas un rectangle redimensionnable.
Ce principe a été introduit par Barbara Liskov en 1987 et étend le principe ouvert-fermé en se concentrant sur le comportement d'une superclasse et de ses sous-types.
Son importance devient évidente lorsque nous considérons les conséquences de sa violation. Considérez une application qui utilise la classe suivante.
public class Rectangle
{
private double width;
private double height;
public double Width
{
get
{
return width;
}
set
{
width = value;
}
}
public double Height
{
get
{
return height;
}
set
{
height = value;
}
}
}
Imaginez qu'un jour, le client exige la possibilité de manipuler des carrés en plus des rectangles. Puisqu'un carré est un rectangle, la classe carrée doit être dérivée de la classe Rectangle.
public class Square : Rectangle
{
}
Cependant, ce faisant, nous rencontrerons deux problèmes:
Un carré n'a pas besoin à la fois de variables de hauteur et de largeur héritées du rectangle et cela pourrait créer un gaspillage de mémoire important si nous devons créer des centaines de milliers d'objets carrés. Les propriétés de définition de largeur et de hauteur héritées du rectangle sont inappropriées pour un carré car la largeur et la hauteur d'un carré sont identiques. Afin de définir à la fois la hauteur et la largeur sur la même valeur, nous pouvons créer deux nouvelles propriétés comme suit:
public class Square : Rectangle
{
public double SetWidth
{
set
{
base.Width = value;
base.Height = value;
}
}
public double SetHeight
{
set
{
base.Height = value;
base.Width = value;
}
}
}
Maintenant, lorsque quelqu'un définira la largeur d'un objet carré, sa hauteur changera en conséquence et vice-versa.
Square s = new Square();
s.SetWidth(1); // Sets width and height to 1.
s.SetHeight(2); // sets width and height to 2.
Allons de l'avant et considérons cette autre fonction:
public void A(Rectangle r)
{
r.SetWidth(32); // calls Rectangle.SetWidth
}
Si nous transmettons une référence à un objet carré dans cette fonction, nous violerions le LSP car la fonction ne fonctionne pas pour les dérivés de ses arguments. Les propriétés largeur et hauteur ne sont pas polymorphes car elles ne sont pas déclarées virtuelles dans un rectangle (l'objet carré sera corrompu car la hauteur ne sera pas modifiée).
Cependant, en déclarant les propriétés du setter virtuelles, nous ferons face à une autre violation, l'OCP. En fait, la création d'un carré de classe dérivé entraîne des modifications du rectangle de classe de base.
L'explication la plus claire pour le LSP que j'ai trouvée jusqu'à présent a été "Le principe de substitution de Liskov dit que l'objet d'une classe dérivée devrait être capable de remplacer un objet de la classe de base sans apporter d'erreurs dans le système ou modifier le comportement de la classe de base "d' ici . L'article donne un exemple de code pour violer LSP et le corriger.
Disons que nous utilisons un rectangle dans notre code
r = new Rectangle();
// ...
r.setDimensions(1,2);
r.fill(colors.red());
canvas.draw(r);
Dans notre classe de géométrie, nous avons appris qu'un carré est un type spécial de rectangle car sa largeur est la même longueur que sa hauteur. Faisons également un Square
cours sur la base de ces informations:
class Square extends Rectangle {
setDimensions(width, height){
assert(width == height);
super.setDimensions(width, height);
}
}
Si nous remplaçons le Rectangle
par Square
dans notre premier code, il se cassera:
r = new Square();
// ...
r.setDimensions(1,2); // assertion width == height failed
r.fill(colors.red());
canvas.draw(r);
En effet , l' Square
a une nouvelle condition que nous n'avions pas dans la Rectangle
classe: width == height
. Selon LSP, les Rectangle
instances devraient être substituables aux Rectangle
instances de sous-classe. Cela est dû au fait que ces instances réussissent la vérification de type des Rectangle
instances et provoquent donc des erreurs inattendues dans votre code.
C'était un exemple pour la partie "les conditions préalables ne peuvent pas être renforcées dans un sous-type" dans l' article wiki . Donc, pour résumer, la violation de LSP entraînera probablement des erreurs dans votre code à un moment donné.
LSP dit que «les objets doivent être remplaçables par leurs sous-types». D'un autre côté, ce principe
Les classes enfants ne doivent jamais casser les définitions de type de la classe parent.
et l'exemple suivant aide à mieux comprendre le LSP.
Sans LSP:
public interface CustomerLayout{
public void render();
}
public FreeCustomer implements CustomerLayout {
...
@Override
public void render(){
//code
}
}
public PremiumCustomer implements CustomerLayout{
...
@Override
public void render(){
if(!hasSeenAd)
return; //it isn`t rendered in this case
//code
}
}
public void renderView(CustomerLayout layout){
layout.render();
}
Fixation par LSP:
public interface CustomerLayout{
public void render();
}
public FreeCustomer implements CustomerLayout {
...
@Override
public void render(){
//code
}
}
public PremiumCustomer implements CustomerLayout{
...
@Override
public void render(){
if(!hasSeenAd)
showAd();//it has a specific behavior based on its requirement
//code
}
}
public void renderView(CustomerLayout layout){
layout.render();
}
Je vous encourage à lire l'article: Violating Liskov Substitution Principle (LSP) .
Vous pouvez y trouver une explication sur le principe de substitution de Liskov, des indices généraux vous aidant à deviner si vous l'avez déjà violé et un exemple d'approche qui vous aidera à rendre votre hiérarchie de classes plus sûre.
LE PRINCIPE DE SUBSTITUTION DE LISKOV (extrait du livre de Mark Seemann) stipule que nous devrions être en mesure de remplacer une implémentation d'une interface par une autre sans rompre ni client ni implémentation.C'est ce principe qui permet de répondre aux exigences qui se produiront à l'avenir, même si nous le pouvons '' t les prévoir aujourd'hui.
Si nous débranchons l'ordinateur du mur (mise en œuvre), ni la prise murale (interface) ni l'ordinateur (client) ne tombe en panne (en fait, s'il s'agit d'un ordinateur portable, il peut même fonctionner sur ses batteries pendant un certain temps) . Avec un logiciel, cependant, un client s'attend souvent à ce qu'un service soit disponible. Si le service a été supprimé, nous obtenons une exception NullReferenceException. Pour faire face à ce type de situation, nous pouvons créer une implémentation d'une interface qui ne fait «rien». Il s'agit d'un modèle de conception connu sous le nom d'objet nul [4], qui correspond à peu près au débranchement de l'ordinateur du mur. Parce que nous utilisons un couplage lâche, nous pouvons remplacer une implémentation réelle par quelque chose qui ne fait rien sans causer de problèmes.
Le principe de substitution de Likov stipule que si un module de programme utilise une classe de base, la référence à la classe de base peut être remplacée par une classe dérivée sans affecter la fonctionnalité du module de programme.
Intention - Les types dérivés doivent être totalement substituables à leurs types de base.
Exemple - Types de retour co-variant en java.
Voici un extrait de cet article qui clarifie bien les choses:
[..] afin de comprendre certains principes, il est important de savoir quand il a été violé. Voilà ce que je vais faire maintenant.
Que signifie la violation de ce principe? Cela implique qu'un objet ne remplit pas le contrat imposé par une abstraction exprimée avec une interface. En d'autres termes, cela signifie que vous avez mal identifié vos abstractions.
Prenons l'exemple suivant:
interface Account
{
/**
* Withdraw $money amount from this account.
*
* @param Money $money
* @return mixed
*/
public function withdraw(Money $money);
}
class DefaultAccount implements Account
{
private $balance;
public function withdraw(Money $money)
{
if (!$this->enoughMoney($money)) {
return;
}
$this->balance->subtract($money);
}
}
Est-ce une violation du LSP? Oui. C'est parce que le contrat du compte nous dit qu'un compte serait retiré, mais ce n'est pas toujours le cas. Alors, que dois-je faire pour y remédier? Je modifie juste le contrat:
interface Account
{
/**
* Withdraw $money amount from this account if its balance is enough.
* Otherwise do nothing.
*
* @param Money $money
* @return mixed
*/
public function withdraw(Money $money);
}
Voilà, maintenant le contrat est satisfait.
Cette violation subtile impose souvent à un client la capacité de faire la différence entre les objets concrets employés. Par exemple, étant donné le premier contrat du compte, il pourrait ressembler à ceci:
class Client
{
public function go(Account $account, Money $money)
{
if ($account instanceof DefaultAccount && !$account->hasEnoughMoney($money)) {
return;
}
$account->withdraw($money);
}
}
Et, cela viole automatiquement le principe ouvert-fermé [c'est-à-dire pour l'exigence de retrait d'argent. Parce que vous ne savez jamais ce qui se passe si un objet violant le contrat n'a pas assez d'argent. Il ne retourne probablement rien, probablement une exception sera levée. Vous devez donc vérifier s'il ne hasEnoughMoney()
fait pas partie d'une interface. Cette vérification forcée dépendante de la classe béton est donc une violation OCP].
Ce point aborde également une idée fausse que je rencontre assez souvent au sujet de la violation du LSP. Il dit que «si le comportement d'un parent change chez un enfant, alors, il viole le LSP». Cependant, ce n'est pas le cas - tant qu'un enfant ne viole pas le contrat de ses parents.