Est-il jamais acceptable de violer le LSP?

10

Je poursuis sur cette question , mais je passe mon attention du code à un principe.

D'après ma compréhension du principe de substitution de Liskov (LSP), quelles que soient les méthodes de ma classe de base, elles doivent être implémentées dans ma sous-classe, et selon cette page, si vous remplacez une méthode de la classe de base et qu'elle ne fait rien ou lance un exception, vous êtes en violation du principe.

Maintenant, mon problème peut être résumé comme ceci: j'ai un résumé Weapon class, et deux classes, Swordet Reloadable. Si Reloadablecontient un spécifique method, appelé Reload(), je devrais abattre pour y accéder method, et, idéalement, vous voudriez éviter cela.

J'ai alors pensé à utiliser le Strategy Pattern. De cette façon, chaque arme n'était consciente que des actions qu'elle est capable d'accomplir, donc par exemple, une Reloadablearme peut évidemment se recharger, mais un Swordne peut pas, et n'est même pas au courant d'un Reload class/method. Comme je l'ai indiqué dans mon article Stack Overflow, je n'ai pas à abattre et je peux maintenir une List<Weapon>collection.

Sur un autre forum , la première réponse a suggéré de permettre Swordd'être au courant Reload, ne faites rien. Cette même réponse a été donnée sur la page Stack Overflow que j'ai liée à ci-dessus.

Je ne comprends pas vraiment pourquoi. Pourquoi violer le principe et permettre à Sword d'en être conscient Reloadet de le laisser vide? Comme je l'ai dit dans mon article sur Stack Overflow, le SP a à peu près résolu mes problèmes.

Pourquoi n'est-ce pas une solution viable?

public final Weapon{

    private final String name;
    private final int damage;
    private final List<AttackStrategy> validactions;
    private final List<Actions> standardActions;

    private Weapon(String name, int damage, List<AttackStrategy> standardActions, List<Actions> attacks)
    {
        this.name = name;
        this.damage = damage;
        standardActions = new ArrayList<Actions>(standardActions);
        validAttacks = new ArrayList<AttackStrategy>(validActions);
    }

    public void standardAction(String action){} // -- Can call reload or aim here.  

    public int attack(String action){} // - Call any actions that are attacks. 

    public static Weapon Sword(String name, damage, List<AttackStrategy> standardActions, List<Actions> attacks){
        return new Weapon(name, damage,standardActions, attacks) ;
    }

}

Interface d'attaque et implémentation:

public interface AttackStrategy{
    void attack(Enemy enemy);
}

public class Shoot implements AttackStrategy {
    public void attack(Enemy enemy){
        //code to shoot
    }
}

public class Strike implements AttackStrategy {
    public void attack(Enemy enemy){
        //code to strike
    }
}

la source
2
Tu peux le faire class Weapon { bool supportsReload(); void reload(); }. Les clients testeraient si pris en charge avant le rechargement. reloadest défini contractuellement pour lancer ssi !supportsReload(). Cela adhère au LSP si les classes conduites ne respectent pas le protocole que je viens de décrire.
usr
3
Que vous laissiez reload()vide ou standardActionsne contienne pas d'action de rechargement est juste un mécanisme différent. Il n'y a pas de différence fondamentale. Vous pouvez faire les deux. => Votre solution est viable (ce qui était votre question) .; Sword n'a pas besoin de connaître le rechargement si Weapon contient une implémentation par défaut vide.
usr
27
J'ai écrit une série d'articles explorant une variété de problèmes avec diverses techniques pour résoudre ce problème. La conclusion: n'essayez pas de capturer les règles de votre jeu dans le système de type de la langue . Capturez les règles du jeu dans des objets qui représentent et appliquent des règles au niveau de la logique du jeu, et non au niveau du système de type . Il n'y a aucune raison de croire que le système de type que vous utilisez est suffisamment sophistiqué pour représenter votre logique de jeu. ericlippert.com/2015/04/27/wizards-and-warriors-part-one
Eric Lippert
2
@EricLippert - Merci pour votre lien. Je suis tombé sur ce blog tant de fois, mais certains des points soulevés je ne comprends pas très bien, mais ce n'est pas de votre faute. J'apprends la POO par moi-même et suis tombé sur des principes SOLID. La première fois que j'ai rencontré votre blog, je ne l'ai pas compris du tout, mais j'ai appris un peu plus et j'ai relu votre blog, et j'ai lentement commencé à comprendre certaines parties de ce qui se disait. Un jour, je comprendrai tout de cette série. J'espère: D
6
@SR "si cela ne fait rien ou lève une exception, vous êtes en violation" - je pense que vous avez mal lu le message de cet article. Le problème n'était pas directement que setAltitude n'a rien fait, c'est qu'il n'a pas rempli la postcondition "l'oiseau sera tiré à l'altitude définie". Si vous définissez la postcondition de "rechargement" comme "si suffisamment de munitions étaient disponibles, l'arme peut attaquer à nouveau", alors ne rien faire est une implémentation parfaitement valide pour une arme qui n'utilise pas de munitions.
Sebastian Redl

Réponses:

16

Le LSP se préoccupe du sous-typage et du polymorphisme. Tout le code n'utilise pas réellement ces fonctionnalités, auquel cas le LSP n'est pas pertinent. Deux cas d'utilisation courants des constructions de langage d'héritage qui ne sont pas un cas de sous-typage sont:

  • Héritage utilisé pour hériter de l'implémentation d'une classe de base, mais pas de son interface. Dans presque tous les cas, la composition doit être préférée. Des langages comme Java ne peuvent pas séparer l'héritage de l'implémentation et de l'interface, mais par exemple C ++ a l' privatehéritage.

  • Héritage utilisé pour modéliser un type / union de somme, par exemple: a Baseest soit CaseAou CaseB. Le type de base ne déclare aucune interface pertinente. Pour utiliser ses instances, vous devez les couler dans le type de béton approprié. Le casting peut être fait en toute sécurité et n'est pas le problème. Malheureusement, de nombreux langages POO ne sont pas en mesure de restreindre les sous-types de classe de base aux seuls sous-types prévus. Si le code externe peut créer un CaseC, alors le code en supposant que a Basene peut être qu'un CaseAou CaseBest incorrect. Scala peut le faire en toute sécurité avec son case classconcept. En Java, cela peut être modélisé lorsque la Baseest une classe abstraite avec un constructeur privé, et les classes statiques imbriquées héritent ensuite de la base.

Certains concepts comme les hiérarchies conceptuelles d'objets du monde réel sont très mal mappés dans des modèles orientés objet. Pensées comme « arme A est une arme, et une épée est une arme, donc je vais avoir une Weaponclasse de base à partir de laquelle Gunet Swordinherit » induisent en erreur: mot réel est-une relation ne signifie pas une telle relation dans notre modèle. Un problème connexe est que les objets peuvent appartenir à plusieurs hiérarchies conceptuelles ou peuvent changer leur affiliation de hiérarchie pendant l'exécution, que la plupart des langages ne peuvent pas modéliser car l'héritage est généralement par classe et non par objet, et défini au moment de la conception et non au moment de l'exécution.

Lors de la conception de modèles POO, nous ne devons pas penser à la hiérarchie ni à la manière dont une classe «étend» une autre. Une classe de base n'est pas un endroit pour prendre en compte les parties communes de plusieurs classes. Pensez plutôt à la façon dont vos objets seront utilisés, c'est-à-dire au type de comportement dont les utilisateurs de ces objets ont besoin.

Ici, les utilisateurs peuvent avoir besoin d' attack()armes et peut-être d' reload()eux. Si nous voulons créer une hiérarchie de types, alors ces deux méthodes doivent être dans le type de base, bien que les armes non rechargeables puissent ignorer cette méthode et ne rien faire lorsqu'elles sont appelées. La classe de base ne contient donc pas les parties communes, mais l'interface combinée de toutes les sous-classes. Les sous-classes ne diffèrent pas dans leur interface, mais uniquement dans leur implémentation de cette interface.

Il n'est pas nécessaire de créer une hiérarchie. Les deux types Gunet Swordpeuvent être totalement indépendants. Alors qu'un Gunbidon fire()et reload()un Swordseul peut strike(). Si vous devez gérer ces objets de manière polymorphe, vous pouvez utiliser le modèle d'adaptateur pour capturer les aspects pertinents. Dans Java 8, cela est possible de manière assez pratique avec des interfaces fonctionnelles et des références lambdas / méthode. Par exemple, vous pourriez avoir une Attackstratégie pour laquelle vous fournissez myGun::fireou () -> mySword.strike().

Enfin, il est parfois judicieux d'éviter toute sous-classe, mais modélisez tous les objets via un seul type. Ceci est particulièrement pertinent dans les jeux car de nombreux objets de jeu ne s'intègrent pas bien dans une hiérarchie et peuvent avoir de nombreuses capacités différentes. Par exemple, un jeu de rôle peut avoir un objet qui est à la fois un objet de quête, améliore vos statistiques avec une force de +2 lorsqu'il est équipé, a 20% de chances d'ignorer les dégâts reçus et fournit une attaque de mêlée. Ou peut-être une épée rechargeable car c'est * magique *. Qui sait ce que l'histoire requiert.

Au lieu d'essayer de comprendre une hiérarchie de classes pour ce gâchis, il est préférable d'avoir une classe qui fournit des emplacements pour diverses capacités. Ces emplacements peuvent être modifiés au moment de l'exécution. Chaque emplacement serait une stratégie / rappel comme OnDamageReceivedou Attack. Avec vos armes, nous pouvons avoir MeleeAttack, RangedAttacket Reloadmachines à sous. Ces emplacements peuvent être vides, auquel cas l'objet ne fournit pas cette capacité. Les créneaux horaires sont alors appelés sous conditions: if (item.attack != null) item.attack.perform().

amon
la source
Un peu comme le SP en quelque sorte. Pourquoi la fente doit-elle être vide? Si le dictionnaire ne contient pas l'action, ne faites rien
@SR Qu'un emplacement soit vide ou n'existe pas n'a pas vraiment d'importance et dépend du mécanisme utilisé pour implémenter ces emplacements. J'ai écrit cette réponse avec les hypothèses d'un langage assez statique où les slots sont des champs d'instance et existent toujours (ie conception de classe normale en Java). Si vous choisissez un modèle plus dynamique où les emplacements sont des entrées dans un dictionnaire (comme l'utilisation d'un HashMap en Java ou d'un objet Python normal), alors les emplacements n'ont pas besoin d'exister. Notez que des approches plus dynamiques abandonnent beaucoup de sécurité de type, ce qui n'est généralement pas souhaitable.
amon
Je suis d'accord que les objets du monde réel ne se modélisent pas bien. Si je comprends votre message, vous dites que je peux utiliser le modèle de stratégie?
2
@SR Oui, le modèle de stratégie sous une forme ou une autre est probablement une approche sensée. Comparez aussi le type d'objet lié Motif: gameprogrammingpatterns.com/type-object.html
amon
3

Parce qu'avoir une stratégie pour attackne suffit pas à vos besoins. Bien sûr, cela vous permet d'abstraire les actions que l'objet peut faire, mais que se passe-t-il lorsque vous avez besoin de connaître la portée de l'arme? Ou la capacité en munitions? Ou quelle sorte de munitions cela prend? Vous êtes de retour au downcasting pour y arriver. Et avoir ce niveau de flexibilité rendra l'interface utilisateur un peu plus difficile à mettre en œuvre, car elle devra avoir un modèle de stratégie similaire pour gérer toutes les capacités.

Cela dit, je ne suis pas particulièrement d'accord avec les réponses à vos autres questions. Ayant swordHériter weaponest horrible, naïve OO qui invariablement conduit à des méthodes non-op ou contrôles de type jonché sur le code.

Mais à l'origine du problème, aucune des deux solutions n'est fausse . Vous pouvez utiliser les deux solutions pour créer un jeu fonctionnel et amusant à jouer. Chacun vient avec son propre ensemble de compromis, comme toute solution que vous choisissez.

Telastyn
la source
Je pense que c'est parfait. Je peux utiliser le SP, mais ce sont des compromis, il suffit d'en être conscient. Voir mon montage, pour ce que j'ai en tête.
1
Fwiw: une épée a des munitions infinies: vous pouvez continuer à l'utiliser sans lire pour toujours; recharger ne fait rien parce que vous avez une utilisation infinie pour commencer; une gamme d'un / mêlée: c'est une arme de mêlée. Il n'est pas impossible de penser à toutes les statistiques / actions d'une manière qui fonctionne à la fois en mêlée et à distance. Pourtant, à mesure que je vieillis, j'utilise de moins en moins l'héritage en faveur des interfaces, de la concurrence et quel que soit le nom utilisé pour utiliser une seule Weaponclasse avec une instance d'épée et de pistolet.
2017 CAD97
Les épées Fwiw dans Destiny 2 utilisent des munitions pour une raison quelconque!
@ CAD97 - C'est le type de réflexion que j'ai vu concernant ce problème. Avoir une épée avec des munitions infinies, donc pas de rechargement. Cela pousse simplement le problème ou le cache. Et si j'introduis une grenade, et alors? Les grenades n'ont pas de munitions ni de tirs et ne devraient pas être au courant de telles méthodes.
1
Je suis avec CAD97 à ce sujet. Et créerait un WeaponBuilderqui pourrait construire des épées et des fusils en composant une arme de stratégies.
Chris Wohlert
3

Bien sûr, c'est une solution viable; c'est juste une très mauvaise idée.

Le problème n'est pas si vous avez cette seule instance où vous mettez reload sur votre classe de base. Le problème est que vous devez également mettre le "swing", "shoot" "parry", "knock", "polish", "démonter", "sharpen", and "replace the nailes of the pointy end of the club" " sur votre classe de base.

Le point de LSP est que vos algorithmes de haut niveau doivent fonctionner et avoir du sens. Donc, si j'ai un code comme celui-ci:

if (isEquipped(weapon)) {
   reload();
}

Maintenant, si cela lève une exception non implémentée et fait planter votre programme, c'est une très mauvaise idée.

Si votre code ressemble à ceci,

if (canReload(weapon)) {
   reload();
}
else if (canSharpen(weapon)) {
  sharpen();
}
else if (canPollish(weapon)) {
  polish();
}

alors votre code peut devenir encombré de propriétés très spécifiques qui n'ont rien à voir avec l'idée abstraite d '«arme».

Cependant, si vous implémentez un jeu de tir à la première personne et que toutes vos armes peuvent tirer / recharger sauf qu'un couteau, alors (dans votre contexte spécifique), il est très logique que le rechargement de votre couteau ne fasse rien, car c'est l'exception et les chances d'avoir votre classe de base encombrée de propriétés spécifiques est faible.

Mise à jour: Essayez de penser au cas / termes abstraits. Par exemple, peut-être que chaque arme a une action de "préparation" qui est une recharge pour les fusils et une gaine pour les épées.

Batavia
la source
Disons que j'ai un dictionnaire d'armes interne qui contient les actions pour les armes, et quand l'utilisateur passe dans "Recharger" il vérifie le dictionnaire, ex, weaponActions.containsKey (action) si c'est le cas, saisissez l'objet qui lui est associé, et faites il. Plutôt qu'une classe d'armes avec plusieurs instructions if
Voir modification ci-dessus. C'est ce que j'avais à l'esprit lors de l'utilisation du SP
0

Évidemment, c'est OK si vous ne créez pas de sous-classe avec l'intention de remplacer une instance de la classe de base, mais si vous créez une sous-classe en utilisant la classe de base comme référentiel pratique de fonctionnalités.

Maintenant, que ce soit une bonne idée ou non est très discutable, mais si vous ne substituez jamais la sous-classe à la classe de base, le fait que cela ne fonctionne pas ne pose aucun problème. Vous pouvez avoir des problèmes, mais LSP n'est pas le problème dans ce cas.

gnasher729
la source
0

Le LSP est bon car il permet au code appelant de ne pas se soucier du fonctionnement de la classe.

par exemple. Je peux appeler Weapon.Attack () sur toutes les armes montées sur mon BattleMech et ne pas craindre que certaines d'entre elles puissent lever une exception et planter mon jeu.

Maintenant, dans votre cas, vous souhaitez étendre votre type de base avec de nouvelles fonctionnalités. Attack () n'est pas un problème, car la classe Gun peut garder une trace de ses munitions et arrêter de tirer quand elle est épuisée. Mais Reload () est quelque chose de nouveau et ne fait pas partie d'une arme.

La solution simple consiste à abattre, je ne pense pas que vous ayez à vous soucier excessivement des performances, vous n'allez pas le faire à chaque image.

Alternativement, vous pouvez réévaluer votre architecture et considérer que dans l'abstrait toutes les armes sont rechargeables, et certaines armes n'ont tout simplement jamais besoin d'être rechargées.

Ensuite, vous n'étendez plus la classe pour les armes à feu ou ne violez pas le LSP.

Mais c'est problématique à long terme parce que vous êtes obligé de penser à des cas plus spéciaux, Gun.SafteyOn (), Sword.WipeOffBlood () etc. et si vous les mettez tous dans Weapon, alors vous avez une classe de base généralisée super compliquée que vous gardez avoir à changer.

edit: pourquoi le modèle de stratégie est mauvais (tm)

Ce n'est pas le cas, mais considérez la configuration, les performances et le code global.

Je dois avoir une configuration quelque part qui me dit qu'un pistolet peut recharger. Lorsque j'instancie une arme, je dois lire cette configuration et ajouter dynamiquement toutes les méthodes, vérifier qu'il n'y a pas de noms en double, etc.

Lorsque j'appelle une méthode, je dois parcourir cette liste d'actions et faire une correspondance de chaîne pour voir laquelle appeler.

Lorsque je compile le code et appelle Weapon.Do ("atack") au lieu de "attack", je n'obtiendrai pas d'erreur lors de la compilation.

Cela peut être une solution appropriée pour certains problèmes, disons que vous avez des centaines d'armes, toutes avec différentes combinaisons de méthodes aléatoires, mais vous perdez beaucoup des avantages de l'OO et du typage fort. Cela ne vous sauve vraiment rien par rapport au downcasting

Ewan
la source
Je pense que le SP peut gérer tout cela (voir modifier ci-dessus), le pistolet aurait SafteyOn()et Swordaurait wipeOffBlood(). Chaque arme n'est pas au courant des autres méthodes (et elles ne devraient pas l'être)
le SP est bien, mais son équivalent à downcasting sans sécurité de type. Je suppose que je répondais un peu à une autre question, permettez-moi de mettre à jour
Ewan
2
En soi, le modèle de stratégie n'implique pas la recherche dynamique d'une stratégie dans une liste ou un dictionnaire. C'est-à-dire les deux weapon.do("attack")et le type sûr weapon.attack.perform()peuvent être des exemples du modèle de stratégie. La recherche de stratégies par nom n'est nécessaire que lors de la configuration de l'objet à partir d'un fichier de configuration, bien que l'utilisation de la réflexion soit tout aussi sûre pour le type.
amon
cela ne fonctionnera pas dans cette situation car il y a deux actions distinctes d'attaque et de rechargement, que vous devez lier à une entrée utilisateur
Ewan