Encore une fois sur le principe de substitution de Lisk, ou la sémantique de l'héritage en POO

L'hérédité est l'un des piliers de la POO. L'héritage est utilisé pour réutiliser le code commun. Mais le code commun n'est pas toujours nécessaire pour réutiliser, et l'héritage n'est pas toujours le meilleur moyen de réutiliser le code. Il s'avère souvent, de sorte qu'il existe un code similaire dans deux morceaux de code (classes) différents, mais les exigences pour eux sont différentes, c'est-à-dire les classes héritent en fait les unes des autres et peuvent ne pas en valoir la peine.

Habituellement, pour illustrer ce problème, ils utilisent un exemple d'héritage de la classe Square de la classe Rectangle, ou vice versa.

Ayons une classe rectangle:

class Rectangle: def __init__(self, width, height): self._width = width self._height = height def set_width(self, width): self._width = width def set_height(self, height): self._height = height def get_area(self): return self._width * self._height ... 

Nous voulions maintenant écrire la classe Square, mais afin de réutiliser le code de calcul d'aire, il semble logique d'hériter du Square du Rectangle:

 class Square(Rectangle): def set_width(self, width): self._width = width self._height = width def set_height(self, height): self._width = height self._height = height 

Il semble que le code des classes Square et Rectangle soit cohérent. Il semble que Square préserve les propriétés mathématiques du carré, c'est-à-dire et un rectangle. Cela signifie que nous pouvons passer des objets carrés au lieu de rectangle.

Mais si nous le faisons, nous pouvons violer le comportement de la classe Rectangle:

Par exemple, il existe un code client:

 def client_code(rect): rect.set_height(10) rect.set_width(20) assert rect.get_area() == 200 

Si vous passez une instance de la classe Square comme argument à cette fonction, la fonction se comportera différemment. Ce qui est une violation du contrat pour le comportement de la classe Rectangle, car les actions avec un objet de la classe de base doivent donner exactement le même résultat qu'avec l'objet de la classe descendante.

Si la classe carrée est un descendant de la classe rectangle, alors en travaillant avec le carré et en appliquant les méthodes du rectangle, nous ne devrions même pas remarquer qu'il ne s'agit pas d'un rectangle.

Vous pouvez résoudre ce problème, par exemple, comme ceci:

  1. faire une affirmation pour correspondre exactement à la classe, ou faire un if qui fonctionnera différemment pour différentes classes
  2. dans Square, créez la méthode set_size () et remplacez les méthodes set_height, set_width afin qu'elles lèvent des exceptions
    etc etc

Un tel code et de telles classes fonctionneront, dans le sens où le code fonctionnera.

Une autre question est que le code client qui utilise la classe Square ou la classe Rectangle devra connaître soit la classe de base et son comportement, soit la classe descendante et son comportement.

Au fil du temps, nous pouvons obtenir cela:

  • la classe descendante remplacera la plupart des méthodes
  • la refactorisation ou l'ajout de méthodes à la classe de base rompra le code à l'aide de descendants
  • dans le code qui utilise les objets de la classe de base, il y aura des ifs, vérifiant la classe de l'objet et le comportement des descendants et de la classe de base est différent

Il s'avère que le code client écrit pour la classe de base dépend de l'implémentation de la classe de base et de la classe descendante. Ce qui complique grandement le développement au fil du temps. Et la POO a été créée juste pour que vous puissiez éditer la classe de base et la classe descendante indépendamment l'une de l'autre.

Dans les années 80 du siècle dernier, nous avons remarqué que pour que l'héritage de classe fonctionne bien pour la réutilisation de code, nous devons savoir avec certitude que la classe descendante peut être utilisée à la place de la classe de base. C'est-à-dire sémantique d'héritage - cela ne devrait pas être seulement et pas autant de données que de comportement. Les héritiers ne doivent pas "casser" le comportement de la classe de base.

En fait, c'est le principe de la substitution Lisk ou le principe de la détermination d'un sous-type basé sur le comportement d'un typage comportemental fort des classes: si vous pouvez écrire au moins un code significatif dans lequel le remplacement de l'objet de classe de base par l'objet de classe descendant, il se cassera, alors cela ne vaut pas la peine les hériter les uns des autres. Nous devons étendre le comportement de la classe de base dans les descendants et ne pas le modifier de manière significative. Les fonctions qui utilisent la classe de base doivent pouvoir utiliser des objets de sous-classe sans le savoir. En fait, c'est la sémantique de l'héritage dans la POO.

Et dans le vrai code industriel, il est fortement recommandé que ce principe soit suivi et respecté la sémantique décrite de l'héritage. Et avec ce principe, il y a plusieurs subtilités.

Le principe devrait être satisfait non pas avec des abstractions du niveau de domaine, mais avec des abstractions de code - classes. D'un point de vue géométrique, un carré est un rectangle. Du point de vue de la hiérarchie d'héritage de classe, le fait que la classe d'un carré soit l'héritière du rectangle de classe dépend du comportement que nous exigeons de ces classes. Cela dépend de la façon dont et dans quelles situations nous utilisons ce code.

Si la classe Rectangle n'a que deux méthodes - calculer la zone et le rendu, sans possibilité de redessiner et de redimensionner, alors dans ce cas, Square avec un constructeur surchargé satisfera le principe de remplacement Liskov.

C'est-à-dire ces classes satisfont au principe de substitution:

 class Rectangle: def draw(): ... def get_area(): ... class Square(Rectangle): pass 

Bien sûr, ce n'est pas un très bon code, et même, probablement, l'anti-modèle de la conception de classes, mais d'un point de vue formel, il satisfait le principe de Liskov.

Un autre exemple . Un ensemble est un sous-type d'un multiset. Il s'agit du rapport des abstractions de domaine. Mais le code peut être écrit de sorte que nous héritons de la classe Set de Bag et que le principe de substitution soit violé, ou nous pouvons écrire pour que le principe soit respecté. Avec la même sémantique du domaine.

En général, l'héritage des classes peut être considéré comme la mise en œuvre de la relation «IS», mais pas entre les entités du domaine, mais entre les classes. Et si la classe descendante est un sous-type de la classe de base est déterminée par les restrictions et les contrats de comportement de classe que le code client utilise (et en principe peuvent utiliser).

Les contraintes, les invariants, un contrat de classe de base ne sont pas figés dans le code, mais figés dans les têtes des développeurs qui éditent et lisent le code. Ce qui «casse», ce qui rompt le «contrat» n'est pas déterminé par le code, mais par la sémantique de la classe dans la tête du développeur.

Tout code significatif pour un objet d'une classe de base ne doit pas se casser si nous le remplaçons par un objet d'une classe descendante. Le code significatif est tout code client qui utilise un objet d'une classe de base (et ses descendants) dans le cadre de la sémantique et des restrictions de la classe de base.

Ce qui est extrêmement important à comprendre, c'est que les limitations de l'abstraction implémentée dans la classe de base ne sont généralement pas contenues dans le code du programme. Ces restrictions sont comprises, connues et prises en charge par le développeur. Il surveille la cohérence de l'abstraction et du code. Pour que le code exprime ce qu'il signifie.

Par exemple, un rectangle a une autre méthode qui renvoie une vue dans json

 class Rectangle: def to_dict(self): return {"height": self.height, "width": self.width} 

Et dans Square, nous le redéfinissons:

 class Square: def to_dict(self): return {"size": self.height} 

Si nous considérons que le contrat de base pour le comportement de la classe Rectangle to_json a une hauteur et une largeur, alors le code

 r = rect.to_dict() log(r['height'], r['width']) 

aura un sens pour un objet de la classe de base Rectangle. Lors du remplacement d'un objet d'une classe de base par une classe, le code héritier Square change son comportement et viole le contrat, et viole ainsi le principe de substitution Lisk.

Si nous pensons que le contrat de base pour le comportement de la classe Rectangle est que to_dict retourne un dictionnaire qui peut être sérialisé sans poser sur des champs spécifiques, alors une telle méthode to_dict sera correcte.

Soit dit en passant, c'est un bon exemple, détruisant le mythe selon lequel l'immuabilité sauve de la violation du principe.

Formellement, tout remplacement d'une méthode dans une classe descendante est dangereux, ainsi que les modifications de la logique dans la classe de base. Par exemple, assez souvent, les classes descendantes s'adaptent au comportement «incorrect» de la classe de base, et lorsque le bogue est corrigé dans la classe de base, elles se cassent.

Il est possible de transférer toutes les conditions du contrat et des invariants dans le code autant que possible, mais dans le cas général, la sémantique du comportement se situe tout de même en dehors du code - dans la zone de problème et est prise en charge par le développeur. L'exemple sur to_dict est un exemple où le contrat peut être décrit dans le code, mais par exemple, pour vérifier que la méthode get_hash renvoie vraiment un hachage avec toutes les propriétés du hachage, et pas seulement une ligne, est impossible.

Lorsqu'un développeur utilise du code écrit par d'autres développeurs, il peut comprendre ce que la sémantique d'une classe n'est que directement par le code, les noms de méthode, la documentation et les commentaires. Mais en tout cas, la sémantique est souvent un domaine humain, et donc erronée. La conséquence la plus importante: uniquement par le code - syntaxiquement - est impossible de vérifier la conformité avec le principe de Liskov, et vous devez vous appuyer sur une sémantique (souvent) vague. Il n'y a aucun moyen formel (mathématique) d'un moyen vérifiable et garanti de vérifier un typage comportemental fort.

Par conséquent, souvent au lieu du principe Liskov, des règles formelles pour les conditions préalables et postconditions de la programmation des contrats sont utilisées:

  • les conditions préalables dans une sous-classe ne peuvent pas être renforcées - une sous-classe ne devrait pas exiger plus que la classe de base
  • les postconditions de la sous-classe ne peuvent pas être assouplies - la sous-classe ne doit pas fournir (promettre) moins que la classe de base
  • les invariants de la classe de base doivent être conservés dans la classe descendante.

Par exemple, dans une méthode de classe descendante, nous ne pouvons pas ajouter un paramètre requis qui n'était pas dans la classe de base, car c'est ainsi que nous renforçons les conditions préalables. Ou nous ne pouvons pas lever d'exceptions dans la méthode substituée, car violer les invariants de la classe de base. Etc.

Ce qui importe n'est pas le comportement actuel de la classe, mais les changements de classe impliquent la responsabilité ou la sémantique de la classe.

Le code est constamment corrigé et changé. Par conséquent, si en ce moment le code satisfait au principe de substitution, cela ne signifie pas que les changements dans le code ne changeront pas cela.

Supposons qu'il existe un développeur de la classe de bibliothèque Rectangle et un développeur d'applications qui hérite de Square de Rectangle. Au moment où le développeur de l'application a hérité Square de Rectangle - tout allait bien, les classes satisfaisaient au principe de substitution.

Et à un moment donné, le développeur en charge de la bibliothèque a ajouté une méthode remodeler ou set_width / set_height à la classe de base Rectangle. De son point de vue, une extension de la classe de base vient de se produire. Mais en fait, il y a eu un changement dans la sémantique et les contrats sur lesquels la classe descendante s'est appuyée. Désormais, les classes ne satisfont plus au principe.

En général, lors de l'héritage dans la POO, les changements dans la classe de base qui ressemblent à une extension de l'interface - une autre méthode ou un nouveau champ peut être ajouté peuvent violer les contrats «naturels» précédents, et donc changer réellement la sémantique ou les responsabilités. Par conséquent, l'ajout d'une méthode à la classe de base est dangereux. Vous pouvez accidentellement par inadvertance modifier le contrat.

Et d'un point de vue pratique, dans l'exemple avec un rectangle et une classe, il est important qu'il y ait maintenant une méthode remodeler ou set_width / set_height. D'un point de vue pratique, il est important de savoir quelle est la probabilité de tels changements dans le code de la bibliothèque. La sémantique ou les limites de la responsabilité de classe impliquent-elles de tels changements? Si cela est implicite, la probabilité d'une erreur et / ou d'un besoin supplémentaire de refactorisation est considérablement augmentée. Et s'il y a même une petite possibilité, il vaut probablement mieux ne pas hériter de telles classes les unes des autres.

Il est difficile de maintenir des définitions de sous-types basées sur le comportement, même pour des classes simples avec une sémantique claire , sans parler d'une entreprise avec une logique métier complexe. Malgré le fait que la classe de base et la classe héritière sont des morceaux de code différents, pour eux, vous devez réfléchir soigneusement et soigneusement aux interfaces et aux responsabilités. Et même avec un léger changement dans la sémantique de la classe - qui ne peut en aucun cas être évité, nous devons regarder le code des classes liées, vérifier si le nouveau contrat ou l'invariant viole ce qui est déjà écrit (!) Et utilisé. Avec presque tout changement dans la hiérarchie des classes branchées, nous devons regarder et vérifier beaucoup d'autres codes.

C'est l'une des raisons pour lesquelles certaines personnes n'aiment pas vraiment l'héritage classique dans la POO. Et donc, ils préfèrent souvent la composition des classes, l'héritage des interfaces, etc., etc. au lieu de l'héritage classique du comportement.

En toute honnêteté, certaines règles ne risquent pas de violer le principe de substitution. Vous pouvez vous protéger autant que possible si vous interdisez toutes les structures dangereuses. Par exemple, pour C ++, Oleg a écrit à ce sujet. Mais en général, de telles règles ne transforment pas les classes en classes au sens classique.

En utilisant des méthodes administratives, la tâche n'est pas non plus très bien résolue. Ici, vous pouvez lire comment l'oncle Martin a fait en C ++ et comment cela n'a pas fonctionné.

Mais dans le vrai code industriel, bien souvent, le principe de Liskov est violé, et ce n'est pas effrayant . Il est difficile de suivre le principe, car 1) la responsabilité et la sémantique d'une classe ne sont souvent pas explicites et ne sont pas exprimées dans le code 2) la responsabilité d'une classe peut changer - à la fois dans la classe de base et dans la classe descendante. Mais cela n'entraîne pas toujours des conséquences vraiment terribles. La violation la plus courante, la plus simple et la plus élémentaire est qu'une méthode substituée modifie le comportement. Comme par exemple ici:

 class Task: def close(self): self.status = CLOSED ... class ProjectTask(Task): def close(self): if status == STARTED: raise Exception("Cannot close a started Project Task") ... 

La méthode close de ProjectTask lèvera une exception dans les cas où les objets de la classe Task fonctionnent correctement. En général, la redéfinition des méthodes d'une classe de base conduit très souvent à une violation du principe de substitution, mais ne devient pas un problème.

En fait, dans ce cas, le développeur ne perçoit pas l'héritage comme une implémentation de la relation «IS», mais simplement comme un moyen de réutiliser le code. C'est-à-dire une sous-classe n'est qu'une sous-classe, pas un sous-type. Dans ce cas, d'un point de vue pragmatique et pratique, cela importe plus - mais quelle est la probabilité qu'il y aura ou existe déjà du code client qui remarquera une sémantique différente des méthodes de la classe descendante et de la classe de base?

Existe-t-il beaucoup de code qui attend un objet d'une classe de base, mais auquel nous passons l'objet de la classe descendante? Pour de nombreuses tâches, un tel code n'existera jamais du tout.

Quand une violation LSP entraîne-t-elle de gros problèmes? Lorsque, en raison de différences de comportement, le code client devra être réécrit avec des modifications dans la classe descendante et vice versa. Cela devient particulièrement problématique si ce code client est un code de bibliothèque qui ne peut pas être modifié. Si la réutilisation du code ne sera pas en mesure de créer des dépendances entre le code client et le code de classe à l'avenir, même en dépit de la violation du principe de substitution Liskov, un tel code ne peut pas causer de gros problèmes.

En général, pendant le développement, l'héritage peut être considéré sous deux angles: les sous-classes sont des sous-types, avec toutes les limites de la programmation par contrat et le principe Lisk, et les sous-classes sont un moyen de réutiliser le code, avec tous ses problèmes potentiels. C'est-à-dire vous pouvez soit penser et concevoir des responsabilités et des contrats de classe et ne pas vous soucier du code client. Soit pensez à ce que pourrait être le code côté client, comment les classes seront utilisées et préparez-vous aux problèmes potentiels, mais dans une moindre mesure, vous vous souciez du respect du principe de substitution. La décision, comme d'habitude, appartient au développeur, le plus important est que le choix dans une situation particulière soit conscient et qu'il y ait une compréhension des avantages, des inconvénients et des pièges qui accompagnent telle ou telle solution.

Source: https://habr.com/ru/post/fr463385/


All Articles