Question

J'ai entendu dire que le principe de substitution de Liskov (LSP) est un principe fondamental de la conception orientée objet.De quoi s’agit-il et quels sont quelques exemples de son utilisation ?

Était-ce utile?

La solution 2

Le principe de substitution de Liskov (LSP, ) est un concept de programmation orientée objet qui stipule :

Les fonctions qui utilisent des pointeurs ou des références aux classes de base doivent être capables d'utiliser des objets de classes dérivées sans le savoir.

En son cœur, LSP concerne les interfaces et les contrats ainsi que la manière de décider quand étendre une classe par rapport à une classe.utilisez une autre stratégie telle que la composition pour atteindre votre objectif.

La manière la plus efficace que j'ai vue pour illustrer ce point était de Tête première OOA&D.Ils présentent un scénario dans lequel vous êtes développeur sur un projet visant à créer un cadre pour des jeux de stratégie.

Ils présentent une classe qui représente un tableau qui ressemble à ceci :

Class Diagram

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 jeux de gérer les unités du plateau au cours du jeu.

Le livre continue en modifiant les exigences en disant que le cadre de jeu doit également prendre en charge les plateaux de jeu 3D pour s'adapter aux jeux qui ont du vol.Donc un ThreeDBoard une 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.

Là où ça s'effondre, c'est quand on regarde tous les autres membres hérités de Board.Les méthodes pour AddUnit, GetTile, GetUnits et ainsi de suite, tous prennent à la fois les paramètres X et Y dans le Board classe mais le ThreeDBoard a également besoin d'un paramètre Z.

Vous devez donc réimplémenter ces méthodes avec un paramètre Z.Le paramètre Z n'a aucun contexte par rapport au Board classe et les méthodes héritées de la Board la classe perd son sens.Une unité de code tentant d'utiliser le ThreeDBoard classe comme classe de base Board ce serait vraiment pas de chance.

Peut-être devrions-nous trouver une autre approche.Au lieu de prolonger Board, ThreeDBoard devrait être composé de 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 LSP.

Autres conseils

Un bon exemple illustrant le LSP (donné par Oncle Bob dans un podcast que j'ai entendu récemment) était la façon dont parfois quelque chose qui sonne bien en langage naturel ne fonctionne pas tout à fait dans le code.

En mathématiques, un Square est un Rectangle.Il s'agit en effet d'une spécialisation d'un rectangle.Le "est un" vous donne envie de modéliser cela avec héritage.Cependant, si dans le code vous avez fait Square tirer de Rectangle, puis un Square devrait être utilisable partout où vous attendez un Rectangle.Cela entraîne un comportement étrange.

Imaginez que vous aviez SetWidth et SetHeight méthodes sur votre Rectangle classe de base ;cela semble parfaitement logique.Cependant, si votre Rectangle référence pointait vers un Square, alors SetWidth et SetHeight cela n'a pas de sens car définir l'un modifierait l'autre pour qu'il corresponde.Dans ce cas Square échoue au test de substitution de Liskov avec Rectangle et l'abstraction d'avoir Square hériter de Rectangle est un mauvais.

enter image description here

Vous devriez tous vérifier les autres inestimables Affiches de motivation des principes SOLID.

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 fonctionnent les getters et les setters, un Rectangle doit 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 de Square, ce n'est donc pas un substitut valable de Rectangle.

La substituabilité est un principe de programmation orientée objet qui stipule 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 :

Mauvais exemple

public class Bird{
    public void fly(){}
}
public class Duck extends Bird{}

Le canard peut voler parce que c'est un oiseau, mais qu'en est-il de ceci :

public class Ostrich extends Bird{}

L'autruche est un oiseau, mais elle ne peut pas voler, la classe Autruche est un sous-type de la classe Bird, mais elle ne peut pas utiliser la méthode fly, ce qui signifie que nous enfreignons le principe LSP.

Bon exemple

public class Bird{
}
public class FlyingBirds extends Bird{
    public void fly(){}
}
public class Duck extends FlyingBirds{}
public class Ostrich extends Bird{} 

Robert Martin a un excellent article sur le principe de substitution de Liskov.Il examine les manières subtiles et moins subtiles par lesquelles le principe peut être violé.

Quelques parties pertinentes de l'article (notez que le deuxième exemple est fortement condensé) :

Un exemple simple de violation du LSP

L'une des violations les plus flagrantes de ce principe est l'utilisation d'informations de type d'exécution C ++ (RTTI) pour sélectionner une fonction basée sur le 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));
}

Clairement le DrawShape la fonction est mal formée.Il doit connaître chaque dérivé possible du Shape classe, et il doit être modifié chaque fois que de nouveaux dérivés Shape sont créées.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 moyens, bien plus subtils, de violer le LSP.Considérons une application qui utilise le 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 demandent la possibilité de manipuler des carrés en plus des rectangles.[...]

De toute évidence, un carré est un rectangle à toutes fins utiles.Puisque la relation ISA est valable, il est logique de modéliser la Squareclasse comme étant dérivée de Rectangle. [...]

Square héritera du SetWidth et SetHeight les fonctions.Ces fonctions sont tout à fait inappropriées pour un Square, puisque 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 outre SetWidth et SetHeight [...]

Mais considérons la fonction suivante :

void f(Rectangle& r)
{
  r.SetWidth(32); // calls Rectangle::SetWidth
}

Si nous passons une référence à un Square objet dans cette fonction, le Square l'objet sera corrompu car la hauteur ne sera pas modifiée.Il s’agit d’une violation flagrante du LSP.La fonction ne fonctionne pas pour les dérivés de ses arguments.

[...]

LSP est nécessaire lorsqu'un code pense appeler les méthodes d'un type T, et peut sans le savoir appeler les méthodes d'un type S, où S extends T (c'est à dire. S hérite, dérive du supertype ou en est un sous-type T).

Par exemple, cela se produit lorsqu'une fonction avec un paramètre d'entrée de type T, est appelé (c'est-à-direinvoqué) avec une valeur d'argument de type S.Ou, où 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 les attentes (c'est-à-direinvariants) pour les méthodes de type T (par exemple. Rectangle), ne doit pas être violé lorsque les méthodes de type S (par exemple. Square) sont appelés à 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 champs immuables a toujours des invariants, par ex.le immuable Les poseurs de rectangles s'attendent à ce que les dimensions soient modifiées indépendamment, mais le immuable Les poseurs de carrés 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 exige que chaque méthode du sous-type S doit avoir un ou plusieurs paramètres d’entrée contravariants et une sortie covariante.

Contravariant signifie que la variance est contraire à la direction de l'héritage, c'est-à-direle 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 genre 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-à-direle type So, de la sortie de chaque méthode du sous-type S, doit être le même ou un sous-type du genre To 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, puis il fournit des arguments de type Ti et attribue la sortie au type To.Lorsqu'il appelle réellement la méthode correspondante de S, alors chacun Ti L'argument d'entrée est affecté à un Si paramètre d'entrée et le So la sortie est affectée au type To.Ainsi si Si n'étaient pas contravariants par rapport àà Ti, puis un sous-type Xi-qui ne serait pas un sous-type de Si-pourrait être affecté à Ti.

De plus, pour les langues (par ex.Scala ou Ceylan) qui ont des annotations de variance de 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é ou même direction respectivement à chaque paramètre d'entrée ou 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 possédant un type de fonction, le sens de la variance requis est inversé.Cette règle est appliquée de manière récursive.


Le sous-typage est approprié où les invariants peuvent être énumérés.

De nombreuses recherches sont en cours sur la façon de modéliser les invariants, afin qu'ils soient appliqués par le compilateur.

Type d'état (voir page 3) déclare et applique des invariants d'état orthogonaux au type.Alternativement, les invariants peuvent être appliqués par conversion d'assertions en types.Par exemple, pour affirmer qu'un fichier est ouvert avant de le fermer, File.open() pourrait renvoyer un type OpenFile, qui contient une méthode close() qui n'est pas disponible dans File.UN API tic-tac-toe peut être un autre exemple d'utilisation du typage pour appliquer des invariants au moment de la compilation.Le système de types peut même être Turing-complet, par ex. Échelle.Les langages typés de manière dépendante et les démonstrateurs de théorèmes formalisent les modèles de typage d'ordre supérieur.

En raison du besoin de sémantique pour résumé sur extension, je m'attends à ce qu'en utilisant le typage pour modéliser les invariants, c'est-à-diresémantique dénotationnelle unifiée d'ordre supérieur, est supérieure au Typestate.« Extension » désigne 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 ex.types et Typestate) pour exprimer la sémantique partagée, qui ne peut pas être unifiée les unes avec les autres pour une composition extensible.Par exemple, Problème d'expression-L'extension de type a été unifiée dans les domaines du sous-typage, de la surcharge de fonctions et du typage paramétrique.

Ma position théorique est que pour connaissance pour exister (voir section « La centralisation est aveugle et inadaptée »), il y aura jamais être un modèle général capable d'appliquer une couverture à 100 % de tous les invariants possibles dans un langage informatique complet de Turing.Pour que la connaissance existe, de nombreuses possibilités inattendues existent, c'est-à-direle désordre et l’entropie doivent toujours augmenter.C'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-à-direil est indécis si tous les programmes possibles dans un langage de programmation complet de Turing se terminent.Il peut être prouvé qu'un programme spécifique se termine (un programme 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 prend fin, à moins que les possibilités d'extension de ce programme ne soient pas complètes par Turing (par ex.via le typage dépendant).Puisque l’exigence fondamentale pour la complétude de Turing est récursivité 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 :

  • Théorèmes d'incomplétude de Gödel:toute théorie formelle, dans laquelle toutes les vérités arithmétiques peuvent être prouvées, est incohérente.
  • Le paradoxe de Russell:chaque règle d'appartenance à un ensemble pouvant contenir un ensemble énumère le type spécifique de chaque membre ou se contient elle-même.Ainsi, les ensembles ne peuvent pas être étendus ou constituent une récursion illimitée.Par exemple, l'ensemble de tout ce qui n'est pas une théière, se comprend, qui s'inclut, qui s'inclut, etc….Ainsi, une règle est incohérente si elle (peut contenir un ensemble et) n'énumère pas les types spécifiques (c'est-à-direautorise tous les types non spécifiés) et n'autorise pas l'extension illimitée.Il s’agit de l’ensemble des ensembles qui ne sont pas membres d’eux-mêmes.Cette incapacité à être à la fois cohérente et complètement énumérée sur toutes les extensions possibles est le théorème d'incomplétude de Gödel.
  • Principe de substitution de Liskov:en général, c'est un problème indécidable si un ensemble est le sous-ensemble d'un autre, c'est-à-direl'héritage est généralement indécidable.
  • Référencement Linsky:il est indécidable de savoir ce qu'est le calcul de quelque chose, lorsqu'il est décrit ou perçu, c'est-à-direla perception (la réalité) n’a pas de point de référence absolu.
  • Théorème de Coase:il n’y a pas de point de référence externe, donc toute barrière aux possibilités externes illimitées échouera.
  • Deuxième loi de la thermodynamique:l'univers entier (un système fermé, c'est-à-diretout) tend vers un désordre maximum, c'est-à-direpossibilités indépendantes maximales.

Le LSP est une règle concernant le contrat des classes :si une classe de base satisfait un contrat, alors par le LSP, les classes dérivées 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 Derived, cela donne exactement les mêmes résultats que l'appel de Foo sur un objet Base, tant que l'argument est le même.

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 entendu parler de LSP pour la première fois, j'ai supposé que cela était entendu dans un sens très strict, l'assimilant essentiellement à l'implémentation d'interface et au casting sécurisé.Ce qui signifierait que le LSP est assuré ou non par le langage lui-même.Par exemple, dans ce sens strict, ThreeDBoard est certainement substituable à Board, en ce qui concerne le compilateur.

Après avoir lu davantage sur le concept, j'ai découvert que LSP est généralement interprété de manière plus large 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 du type pointeur ne se limite pas à la sécurité des types.L'adhésion au LSP peut également être testée en sondant le comportement réel des objets.Autrement dit, examiner l'impact de l'état et des arguments de méthode d'un objet sur les résultats des appels de méthode ou sur les types d'exceptions levées depuis l'objet.

Pour revenir à l'exemple, en théorie les méthodes Board peuvent fonctionner parfaitement sur ThreeDBoard.En pratique cependant, il sera très difficile d'éviter des différences de comportement que le client pourrait 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.

Il existe une liste de contrôle pour déterminer si vous violez ou non Liskov.

  • Si vous violez l'un des éléments suivants -> vous violez Liskov.
  • Si vous n’en violez aucun -> vous ne pouvez rien conclure.

Liste de contrôle :

  • Aucune nouvelle exception ne doit être levée dans la classe dérivée:Si votre classe de base lançait ArgumentNullException, vos sous-classes n'étaient autorisées à lancer que des exceptions de type ArgumentNullException ou toute exception dérivée de ArgumentNullException.Lancer IndexOutOfRangeException est une violation de Liskov.
  • Les conditions préalables ne peuvent pas être renforcées:Supposons que votre classe de base fonctionne avec un membre int.Maintenant, votre sous-type exige que int soit positif.Il s'agit de conditions préalables renforcées, et désormais tout code qui fonctionnait parfaitement auparavant avec des entiers négatifs est rompu.
  • Les post-conditions ne peuvent pas être affaiblies:Supposons que votre classe de base exige que toutes les connexions à la base de données soient fermées avant le retour de la méthode.Dans votre sous-classe, vous avez remplacé cette méthode et laissé la connexion ouverte pour une réutilisation ultérieure.Vous avez affaibli les post-conditions de cette méthode.
  • Les invariants doivent être préservés:La contrainte la plus difficile et la plus douloureuse à remplir.Les invariants sont quelque temps cachés dans la classe de base et la seule façon de les révéler est de lire le code de la classe de base.Fondamentalement, vous devez être sûr que lorsque vous remplacez une méthode, tout ce qui est immuable doit rester inchangé après l'exécution de votre méthode remplacée.La meilleure chose à laquelle je puisse penser est d'appliquer ces contraintes invariantes dans la classe de base, mais ce ne serait pas facile.
  • Contrainte d'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 œil à ce code et vous pouvez voir que Name est défini comme étant 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 y a 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 un développeur C#) donc je m'en fiche.

Référence:

Un exemple important de utiliser du LSP est en tests de logiciels.

Si j'ai une classe A qui est une sous-classe de B conforme au LSP, je peux alors réutiliser la suite de tests de B pour tester A.

Pour tester entièrement la sous-classe A, je dois probablement 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 d'y parvenir est de construire ce que McGregor appelle une « hiérarchie parallèle pour les tests » :Mon ATest la 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 simple modèle de méthode de modèle fera l'affaire).

Notez que la réutilisation de la suite de super-tests pour toutes les implémentations de sous-classes est en fait un moyen de tester que ces implémentations de sous-classes sont conformes au LSP.Ainsi, on peut aussi affirmer que l'on devrait exécutez la suite de tests de la 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 ?"

Je suppose que tout le monde a en quelque sorte compris ce qu'est techniquement LSP :Vous voulez essentiellement pouvoir faire abstraction des détails des sous-types et utiliser les supertypes en toute sécurité.

Liskov a donc 3 règles sous-jacentes :

  1. Règle de signature :Il devrait 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 concernant le fait de lancer moins d'exceptions et d'être au moins aussi accessible que les méthodes de supertype.

  2. Règle des méthodes :La mise en œuvre de ces opérations est sémantiquement saine.

    • Conditions préalables plus faibles :Les fonctions de sous-type doivent prendre au moins ce que le supertype a pris en entrée, sinon plus.
    • Postconditions plus fortes :Ils doivent produire un sous-ensemble du résultat produit par les méthodes de supertype.
  3. Règle de propriétés :Cela va au-delà des appels de fonctions individuels.

    • Invariants :Les choses qui sont toujours vraies doivent rester vraies.Par exemple.la taille d'un ensemble n'est jamais négative.
    • Propriétés évolutives :Habituellement, quelque chose à voir avec l'immuabilité ou le type d'état dans lequel l'objet peut se trouver.Ou peut-être que l'objet ne fait que croître et ne rétrécit jamais, donc les méthodes de sous-type ne devraient pas le faire.

Toutes ces propriétés doivent être préservées et la fonctionnalité supplémentaire du sous-type ne doit pas violer les propriétés du supertype.

Si ces trois éléments sont pris en compte, vous avez fait abstraction des éléments sous-jacents et vous écrivez du code faiblement couplé.

Source:Développement de programmes en Java - Barbara Liskov

Long pour faire court, laissons les rectangles, les rectangles et les carrés, exemple pratique lors de l'extension d'une classe parent, il faut soit PRÉSERVER l'API parent exacte, soit l'ÉTENDRE.

Disons que vous avez un base Référentiel d'éléments.

class ItemsRepository
{
    /**
    * @return int Returns number of deleted rows
    */
    public function delete()
    {
        // perform a delete query
        $numberOfDeletedRows = 10;

        return $numberOfDeletedRows;
    }
}

Et une sous-classe l'étendant :

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;
    }
}

Alors tu pourrais avoir un Client travailler avec l'API Base ItemsRepository et s'appuyer sur elle.

/**
 * 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 cassé quand remplacement parent cours avec un la 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 apprendre davantage sur l'écriture de logiciels maintenables dans mon cours : https://www.udemy.com/enterprise-php/

Cette formulation du LSP est bien trop forte :

Si pour chaque objet o1 de type S il existe un objet o2 de type T tel que pour tous les programmes P définis en termes de T, le comportement de P est inchangé lorsque o1 est substitué à 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 exactement la même chose que T.Et je pourrais être audacieux et décider que la performance fait partie du comportement de P...

Donc, fondamentalement, toute utilisation de liaison tardive viole le LSP.C'est tout l'intérêt de OO d'obtenir un comportement différent lorsqu'on substitue un objet d'un genre à un autre d'un autre genre !

La formulation citée par wikipédia C'est mieux puisque la propriété dépend du contexte et n'inclut pas nécessairement l'ensemble du comportement du programme.

Quelques addenda :
Je me demande pourquoi personne n'a écrit sur les Invariant , les préconditions et les post-conditions de la classe de base auxquelles doivent obéir les classes dérivées.Pour qu'une classe dérivée D soit complètement substituable par la classe de base B, la classe D doit obéir à certaines conditions :

  • Les variantes internes de la classe de base doivent être préservées par la classe dérivée
  • Les conditions préalables de la classe de base ne doivent pas être renforcées par la classe dérivée
  • Les post-conditions de la classe de base ne doivent pas être affaiblies par la classe dérivée.

Le dérivé doit donc être conscient des trois conditions ci-dessus imposées par la classe de base.Les règles de sous-typage sont donc 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 postconditions, devraient être décidées par une 'contrat de conception'.

D'autres discussions à ce sujet sont disponibles sur mon blog : Principe de substitution de Liskov

Dans une phrase très simple, nous pouvons dire :

La classe enfant ne doit pas violer les caractéristiques de sa classe de base.Il doit en être capable.Nous pouvons dire que c'est la même chose que le sous-typage.

Je vois des rectangles et des carrés dans chaque réponse, et comment violer le LSP.

J'aimerais montrer comment le LSP peut être conforme avec un exemple concret :

<?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 effectuant 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 puisqu'ils ne produisent plus le même résultat.

Principe de substitution de Liskov (LSP)

Tout le temps, nous concevons un module de programme et nous créons des hiérarchies de classe.Ensuite, nous étendons certaines classes créant des classes dérivées.

Nous devons nous assurer que les nouvelles classes dérivées s'étendent sans remplacer les fonctionnalités des anciennes classes.Sinon, les nouvelles classes peuvent produire des effets indésirables lorsqu'ils sont utilisés dans les modules de programme existants.

Le principe de substitution de Liskov indique 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:

Vous trouverez ci-dessous 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 square est renvoyée par un modèle d'usine, basé sur certaines conditions et nous ne savons pas exactement quel type d'objet sera renvoyé.Mais nous savons que c'est un rectangle.Nous obtenons l'objet rectangle, définissons la largeur sur 5 et la hauteur sur 10 et obtenons la surface.Pour un rectangle de largeur 5 et de hauteur 10, l'aire doit être de 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 de fermeture ouverte et cela signifie que nous devons nous assurer que de nouvelles classes dérivées étendent les classes de base sans modifier leur comportement.

Voir également: Principe d'ouverture et de fermeture

Quelques concepts similaires pour une meilleure structure : Convention sur la configuration

La mise en œuvre de ThreeDBoard en termes de tableau de conseils serait-elle si utile ?

Peut-être souhaiterez-vous traiter des tranches de ThreeDBoard dans différents plans comme un tableau.Dans ce cas, vous souhaiterez peut-être extraire une interface (ou une classe abstraite) pour Board afin de permettre plusieurs implémentations.

En termes d'interface externe, vous souhaiterez peut-être exclure une interface Board 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 du carré.Ceci est contourné en introduisant des effets secondaires.Mais si le rectangle avait un setSize(height, width) avec la condition préalable 0 <hauteur et 0 <largeur.La méthode du sous-type dérivé nécessite height == width ;une condition préalable plus forte (et qui viole lsp).Cela montre que bien que square soit un rectangle, il ne s'agit pas d'un sous-type valide car la condition préalable est renforcée.Le contournement (en général une mauvaise chose) provoque un effet secondaire et cela affaiblit la post-condition (qui viole lsp).setWidth sur la base a la condition de publication 0 <largeur.La dérivée l'affaiblit avec hauteur == largeur.

Un carré redimensionnable n’est donc pas un rectangle redimensionnable.

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 cours 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 un Square classe également 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 avec Square dans notre premier code, alors ça cassera :

r = new Square();
// ...
r.setDimensions(1,2); // assertion width == height failed
r.fill(colors.red());
canvas.draw(r);

C'est parce que le Square a une nouvelle condition préalable que nous n'avions pas dans le Rectangle classe: width == height.Selon LSP, Rectangle les instances devraient être substituables par Rectangle instances de sous-classe.En effet, ces instances réussissent la vérification de type pour Rectangle instances et ainsi elles provoqueront des erreurs inattendues dans votre code.

C'était un exemple pour le "les conditions préalables ne peuvent pas être renforcées dans un sous-type" partie dans le article wiki.Donc, pour résumer, violer LSP entraînera probablement des erreurs dans votre code à un moment donné.

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, n'est-ce pas ?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 moyen 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 moyen 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 auxquels la violation du principe de substitution de Liskov mène, et ils peuvent le plus généralement être reconnus par une méthode qui ne fait rien, ou ne peut même pas être mise en œuvre.

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 du moyen de transport est erronée.Il ne devrait pas y 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 désormais étendre TransportationDevice aux appareils non motorisés.

class DevicesWithoutEngines extends TransportationDevice
{  
   void startMoving() { ... }
}

Et étendez TransportationDevice aux appareils motorisés.Voici qu'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 de Liskov.

class Car extends DevicesWithEngines
{
   @Override
   void startEngine() { ... }
}

Et notre classe Bicycle est également conforme au principe de substitution de Liskov.

class Bicycle extends DevicesWithoutEngines
{
   @Override
   void startMoving() { ... }
}

Je vous encourage à lire l'article : Violation du principe de substitution de Liskov (LSP).

Vous pouvez y trouver une explication de ce qu'est 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.

L'explication la plus claire du LSP que j'ai trouvée jusqu'à présent est « 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 provoquer d'erreurs dans le système ni modifier le comportement de la classe de base. " depuis ici.L'article donne un exemple de code pour violer LSP et le corriger.

LE PRINCIPE DE SUBSTITUTION DE LISKOV (tiré du livre de Mark Seemann) stipule que nous devrions être capables de remplacer une implémentation d'une interface par une autre sans casser ni le client ni l'implémentation. C'est ce principe qui permet de répondre aux exigences qui se produiront dans le futur, même si nous le pouvons. Je ne les prévois pas aujourd’hui.

Si l'on débranche l'ordinateur du mur (Implémentation), ni la prise murale (Interface) ni l'ordinateur (Client) ne tombent en panne (en fait, s'il s'agit d'un ordinateur portable, il peut même fonctionner sur batterie pendant un certain temps) .Cependant, avec un logiciel, un client s'attend souvent à ce qu'un service soit disponible.Si le service a été supprimé, nous obtenons une NullReferenceException.Pour gérer ce type de situation, nous pouvons créer une implémentation d'une interface qui ne fait «rien». Il s'agit d'un motif de conception connu sous le nom d'objet NULL, [4] et il correspond à peu près à débrancher 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 complètement substituables à leurs types de base.

Exemple - Types de retour co-variants en Java.

LSP dit que « les objets doivent être remplaçables par leurs sous-types ».D’un autre côté, ce principe indique

Les classes enfants ne doivent jamais briser les définitions de type de la classe parent.

et l'exemple suivant aide à mieux comprendre 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();
}

Correction 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();
}

Laissez-moi essayer, considérons une interface :

interface Planet{
}

Ceci est implémenté par classe :

class Earth implements Planet {
    public $radius;
    public function construct($radius) {
        $this->radius = $radius;
    }
}

Vous utiliserez la Terre comme :

$planet = new Earth(6371);
$calc = new SurfaceAreaCalculator($planet);
$calc->output();

Considérons maintenant une classe supplémentaire qui étend la Terre :

class LiveablePlanet extends Earth{
   public function color(){
   }
}

Désormais, selon LSP, vous devriez pouvoir utiliser LiveablePlanet à la place de Earth et cela ne devrait pas casser votre système.Comme:

$planet = new LiveablePlanet(6371);  // Earlier we were using Earth here
$calc = new SurfaceAreaCalculator($planet);
$calc->output();

Exemples tirés de ici

Voici un extrait de ce post ça clarifie bien les choses :

[..] Afin de comprendre certains principes, il est important de se rendre compte quand ils ont été violés.C'est 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.En effet, le contrat du compte nous indique qu’un compte sera retiré, mais ce n’est pas toujours le cas.Alors, que dois-je faire pour y remédier ?Je viens de modifier 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 rempli.

Cette violation subtile impose souvent au client la capacité de faire la différence entre les objets concrets utilisés.Par exemple, étant donné le contrat du premier Compte, cela 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 l’exigence de retrait d’argent.Parce qu'on ne sait jamais ce qui se passe si un objet qui viole le contrat n'a pas assez d'argent.Il est probable que cela ne renvoie rien, une exception sera probablement levée.Il faut donc vérifier si hasEnoughMoney() -- qui ne fait pas partie d'une interface.Cette vérification forcée dépendante de la classe concrète est donc une violation OCP].

Ce point répond également à une idée fausse que je rencontre assez souvent à propos de la violation du LSP.Il dit que «si le comportement d'un parent a changé 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.

Licencié sous: CC-BY-SA avec attribution
Non affilié à StackOverflow
scroll top