Ist es jemals in Ordnung, den LSP zu verletzen?

10

Ich verfolge diese Frage , aber ich konzentriere mich von Code auf ein Prinzip.

Nach meinem Verständnis des Liskov-Substitutionsprinzips (LSP) müssen alle Methoden in meiner Basisklasse in meiner Unterklasse implementiert werden, und laut dieser Seite, wenn Sie eine Methode in der Basisklasse überschreiben und sie nichts tut oder eine auslöst Ausnahme, Sie verstoßen gegen das Prinzip.

Nun kann mein Problem folgendermaßen zusammengefasst werden: Ich habe eine Zusammenfassung Weapon classund zwei Klassen Swordund Reloadable. Wenn es Reloadableein bestimmtes method, genanntes Reload()Element enthält , müsste ich einen Downcast durchführen, um darauf zuzugreifen method, und im Idealfall möchten Sie dies vermeiden.

Ich dachte dann daran, das zu benutzen Strategy Pattern. Auf diese Weise war sich jede Waffe nur der Aktionen bewusst, die sie ausführen kann, so dass beispielsweise eine ReloadableWaffe offensichtlich nachladen kann, aber eine Swordnicht und nicht einmal eine Reload class/method. Wie ich in meinem Beitrag zum Stapelüberlauf angegeben habe, muss ich keinen Downcast durchführen und kann eine List<Weapon>Sammlung verwalten.

In einem anderen Forum schlug die erste Antwort vor, Swordsich bewusst zu sein Reload, einfach nichts zu tun. Dieselbe Antwort wurde auf der Seite "Stapelüberlauf" gegeben, auf die ich oben verlinkt habe.

Ich verstehe nicht ganz warum. Warum gegen das Prinzip verstoßen und Sword erlauben, sich dessen bewusst zu sein Reloadund es leer zu lassen? Wie ich in meinem Beitrag zum Stapelüberlauf sagte, hat der SP meine Probleme so ziemlich gelöst.

Warum ist es keine praktikable Lösung?

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

}

Angriffsschnittstelle und Implementierung:

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

quelle
2
Das kannst du machen class Weapon { bool supportsReload(); void reload(); }. Clients würden vor dem erneuten Laden testen, ob dies unterstützt wird. reloadwird vertraglich definiert, um iff zu werfen !supportsReload(). Das entspricht dem LSP, wenn getriebene Klassen dem gerade beschriebenen Protokoll entsprechen.
usr
3
Ob Sie reload()leer lassen oder standardActionskeine Reload-Aktion enthalten, ist nur ein anderer Mechanismus. Es gibt keinen grundsätzlichen Unterschied. Sie können beides tun. => Ihre Lösung ist realisierbar (was Ihre Frage war).; Sword muss nichts über das Nachladen wissen, wenn Weapon eine leere Standardimplementierung enthält.
usr
27
Ich habe eine Reihe von Artikeln geschrieben, in denen verschiedene Probleme mit verschiedenen Techniken zur Lösung dieses Problems untersucht wurden. Das Fazit: Versuchen Sie nicht, die Spielregeln im Typensystem der Sprache zu erfassen . Erfassen Sie die Spielregeln in Objekten, die Regeln auf der Ebene der Spiellogik darstellen und durchsetzen, nicht auf der Ebene des Typsystems . Es gibt keinen Grund zu der Annahme, dass das von Ihnen verwendete System ausgereift genug ist, um Ihre Spielelogik darzustellen. ericlippert.com/2015/04/27/wizards-and-warriors-part-one
Eric Lippert
2
@EricLippert - Danke für deinen Link. Ich bin so oft auf diesen Blog gestoßen, aber einige der Punkte, die ich gemacht habe, verstehe ich nicht ganz, aber es ist nicht deine Schuld. Ich lerne OOP alleine und bin auf SOLID Principals gestoßen. Als ich zum ersten Mal auf Ihr Blog stieß, verstand ich es überhaupt nicht, aber ich lernte etwas mehr und las Ihr Blog erneut und begann langsam, Teile dessen zu verstehen, was gesagt wurde. Eines Tages werde ich alles in dieser Serie vollständig verstehen. Ich hoffe: D
6
@SR "Wenn es nichts tut oder eine Ausnahme auslöst, verstoßen Sie" - Ich denke, Sie haben die Nachricht aus diesem Artikel falsch verstanden. Das Problem war nicht direkt, dass setAltitude nichts getan hat, sondern dass es die Nachbedingung "Vogel wird in der eingestellten Höhe gezeichnet" nicht erfüllte. Wenn Sie die Nachbedingung "Nachladen" als "Wenn genügend Munition verfügbar war, kann die Waffe erneut angreifen" definieren, ist das Nichtstun eine absolut gültige Implementierung für eine Waffe, die keine Munition verwendet.
Sebastian Redl

Antworten:

16

Der LSP ist besorgt über Subtypisierung und Polymorphismus. Nicht jeder Code verwendet diese Funktionen tatsächlich. In diesem Fall ist der LSP irrelevant. Zwei häufige Anwendungsfälle von Vererbungssprachenkonstrukten, bei denen es sich nicht um Subtypen handelt, sind:

  • Vererbung, die verwendet wird, um die Implementierung einer Basisklasse zu erben, nicht jedoch deren Schnittstelle. In fast allen Fällen sollte die Zusammensetzung bevorzugt werden. Sprachen wie Java können die Vererbung von Implementierung und Schnittstelle nicht trennen, aber z. B. hat C ++ privateVererbung.

  • Vererbung zur Modellierung eines Summentyps / einer Summenunion, z. B.: A Baseist entweder CaseAoder CaseB. Der Basistyp deklariert keine relevante Schnittstelle. Um seine Instanzen zu verwenden, müssen Sie sie auf den richtigen Betontyp umwandeln. Das Casting kann sicher durchgeführt werden und ist nicht das Problem. Leider können viele OOP-Sprachen die Basisklassen-Subtypen nicht nur auf die beabsichtigten Subtypen beschränken. Wenn externer Code a erstellen kann CaseC, dann Code unter der Annahme, dass a Basenur a sein kann CaseAoder CaseBfalsch ist. Scala kann dies mit seinem case classKonzept sicher tun . In Java kann dies modelliert werden, wenn Basees sich um eine abstrakte Klasse mit einem privaten Konstruktor handelt und verschachtelte statische Klassen dann von der Basis erben.

Einige Konzepte wie konzeptionelle Hierarchien realer Objekte lassen sich sehr schlecht in objektorientierte Modelle abbilden. Gedanken wie „Eine Pistole ist eine Waffe, und ein Schwert ist eine Waffe, daher werde ich eine hat WeaponBasisklasse , von der Gunund Swordvererben“ sind irreführend: real-Wort-a - Beziehungen nicht so implizieren eine Beziehung in unserem Modell. Ein verwandtes Problem besteht darin, dass Objekte möglicherweise zu mehreren konzeptionellen Hierarchien gehören oder ihre Hierarchiezugehörigkeit während der Laufzeit ändern, was die meisten Sprachen nicht modellieren können, da die Vererbung normalerweise pro Klasse und nicht pro Objekt erfolgt und zur Entwurfszeit und nicht zur Laufzeit definiert wird.

Beim Entwerfen von OOP-Modellen sollten wir nicht an die Hierarchie denken oder daran, wie eine Klasse eine andere „erweitert“. Eine Basisklasse ist kein Ort, um die gemeinsamen Teile mehrerer Klassen herauszufiltern . Überlegen Sie sich stattdessen, wie Ihre Objekte verwendet werden, dh welches Verhalten die Benutzer dieser Objekte benötigen.

Hier müssen Benutzer möglicherweise attack()mit Waffen und vielleicht auch mit reload()ihnen. Wenn wir eine Typhierarchie erstellen möchten, müssen beide Methoden vom Basistyp sein, obwohl nicht nachladbare Waffen diese Methode möglicherweise ignorieren und beim Aufruf nichts tun. Die Basisklasse enthält also nicht die gemeinsamen Teile, sondern die kombinierte Schnittstelle aller Unterklassen. Die Unterklassen unterscheiden sich nicht in ihrer Schnittstelle, sondern nur in der Implementierung dieser Schnittstelle.

Es ist nicht erforderlich, eine Hierarchie zu erstellen. Die beiden Typen Gunund Swordkönnen völlig unabhängig sein. Während eine GunDose fire()und reload()eine Sworddarf nur strike(). Wenn Sie diese Objekte polymorph verwalten müssen, können Sie das Adaptermuster verwenden, um die relevanten Aspekte zu erfassen. In Java 8 ist dies mit funktionalen Schnittstellen und Lambdas / Methodenreferenzen recht bequem möglich. AttackZum Beispiel könnten Sie eine Strategie haben, für die Sie liefern myGun::fireoder () -> mySword.strike().

Schließlich ist es manchmal sinnvoll, Unterklassen überhaupt zu vermeiden, aber alle Objekte durch einen einzigen Typ zu modellieren. Dies ist besonders in Spielen relevant, da viele Spielobjekte nicht gut in eine Hierarchie passen und viele verschiedene Funktionen haben können. Zum Beispiel kann ein Rollenspiel einen Gegenstand haben, der sowohl ein Questgegenstand ist als auch Ihre Statistiken mit +2 Stärke verbessert, wenn er ausgerüstet ist, eine 20% ige Chance hat, erlittenen Schaden zu ignorieren, und einen Nahkampfangriff bietet. Oder vielleicht ein nachladbares Schwert, weil es * magisch * ist. Wer weiß, was die Geschichte erfordert.

Anstatt zu versuchen, eine Klassenhierarchie für dieses Durcheinander herauszufinden, ist es besser, eine Klasse zu haben, die Slots für verschiedene Funktionen bereitstellt. Diese Slots können zur Laufzeit geändert werden. Jeder Slot wäre eine Strategie / ein Rückruf wie OnDamageReceivedoder Attack. Mit Waffen, so können wir haben MeleeAttack, RangedAttackund ReloadSlots. Diese Steckplätze können leer sein. In diesem Fall bietet das Objekt diese Funktion nicht. Die Slots werden dann bedingt aufgerufen : if (item.attack != null) item.attack.perform().

amon
quelle
In gewisser Weise wie der SP. Warum muss der Steckplatz leer sein? Wenn das Wörterbuch die Aktion nicht enthält, tun Sie einfach nichts
@SR Ob ein Slot leer ist oder nicht existiert, spielt keine Rolle und hängt vom Mechanismus ab, der zur Implementierung dieser Slots verwendet wird. Ich habe diese Antwort mit den Annahmen einer ziemlich statischen Sprache geschrieben, in der Slots Instanzfelder sind und immer existieren (dh normales Klassendesign in Java). Wenn Sie ein dynamischeres Modell auswählen, bei dem Slots Einträge in einem Wörterbuch sind (z. B. mithilfe einer HashMap in Java oder eines normalen Python-Objekts), müssen die Slots nicht vorhanden sein. Beachten Sie, dass dynamischere Ansätze viel Typensicherheit aufgeben, was normalerweise nicht wünschenswert ist.
Amon
Ich stimme zu, dass Objekte der realen Welt nicht gut modellieren. Wenn ich Ihren Beitrag verstehe, sagen Sie, dass ich das Strategiemuster verwenden kann?
2
@SR Ja, das Strategiemuster in irgendeiner Form ist wahrscheinlich ein vernünftiger Ansatz. Vergleichen Sie auch das zugehörige Typobjektmuster
amon
3

Weil eine Strategie attackfür Ihre Bedürfnisse nicht ausreicht. Sicher, es ermöglicht Ihnen zu abstrahieren, welche Aktionen der Gegenstand ausführen kann, aber was passiert, wenn Sie die Reichweite der Waffe kennen müssen? Oder die Munitionskapazität? Oder welche Art von Munition braucht es? Du bist zurück zum Downcasting, um das zu erreichen. Durch diese Flexibilität wird die Implementierung der Benutzeroberfläche etwas schwieriger, da ein ähnliches Strategiemuster erforderlich ist, um alle Funktionen nutzen zu können.

Trotzdem stimme ich den Antworten auf Ihre anderen Fragen nicht besonders zu. Das swordErben von weaponist eine schreckliche, naive OO, die ausnahmslos zu No-Op-Methoden oder Typprüfungen führt, die über den Code verstreut sind.

Aber an der Wurzel der Sache ist keine der beiden Lösungen falsch . Sie können beide Lösungen verwenden, um ein funktionierendes Spiel zu erstellen, das Spaß macht. Jeder hat seine eigenen Kompromisse, genau wie jede Lösung, die Sie auswählen.

Telastyn
quelle
Ich finde das perfekt. Ich kann den SP verwenden, aber sie sind Kompromisse, müssen sich nur ihrer bewusst sein. Siehe meine Bearbeitung für das, was ich vorhabe.
1
Fwiw: Ein Schwert hat unendlich viel Munition: Sie können es weiter verwenden, ohne für immer zu lesen. reload macht nichts, weil Sie zunächst unendlich viel Nutzen haben; eine Reichweite von eins / Nahkampf: Es ist eine Nahkampfwaffe. Es ist nicht unmöglich, über alle Statistiken / Aktionen so nachzudenken, dass sie sowohl für Nahkampf als auch für Fernkampf funktionieren. Mit zunehmendem Alter verwende ich jedoch immer weniger Vererbung zugunsten von Schnittstellen, Konkurrenz und was auch immer der Name für die Verwendung einer einzelnen WeaponKlasse mit einer Schwert- und Waffeninstanz ist.
CAD97
Fwiw in Destiny 2 Schwerter verwenden aus irgendeinem Grund Munition!
@ CAD97 - Dies ist die Art von Denken, die ich in Bezug auf dieses Problem gesehen habe. Habe Schwert mit unendlicher Munition, also kein Nachladen. Dies schiebt das Problem nur herum oder verbirgt es. Was ist, wenn ich eine Granate einführe? Was dann? Granaten haben weder Munition noch Schuss und sollten sich solcher Methoden nicht bewusst sein.
1
Ich bin mit CAD97 dabei. Und würde eine schaffen WeaponBuilder, die Schwerter und Waffen bauen könnte, indem sie eine Waffe aus Strategien zusammensetzt.
Chris Wohlert
3

Natürlich ist es eine praktikable Lösung; Es ist nur eine sehr schlechte Idee.

Das Problem ist nicht, wenn Sie diese einzelne Instanz haben, in der Sie Ihre Basisklasse neu laden. Das Problem ist, dass Sie auch die "Schaukel", "schießen", "parieren", "klopfen", "polieren", "zerlegen", "schärfen" und "die Nägel des spitzen Endes des Schlägers ersetzen" müssen. Methode auf Ihrer Basisklasse.

Der Punkt von LSP ist, dass Ihre Top-Level-Algorithmen funktionieren und Sinn machen müssen. Also, wenn ich Code wie diesen habe:

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

Wenn dies eine nicht implementierte Ausnahme auslöst und Ihr Programm zum Absturz bringt, ist dies eine sehr schlechte Idee.

Wenn Ihr Code so aussieht,

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

dann kann Ihr Code mit sehr spezifischen Eigenschaften überfüllt sein, die nichts mit der abstrakten "Waffen" -Idee zu tun haben.

Wenn Sie jedoch einen Ego-Shooter implementieren und alle Ihre Waffen außer diesem einen Messer schießen / nachladen können, ist es (in Ihrem speziellen Kontext) sehr sinnvoll, das Nachladen Ihres Messers nichts tun zu lassen, da dies die Ausnahme und die Wahrscheinlichkeit ist Es ist gering, wenn Ihre Basisklasse mit bestimmten Eigenschaften überfüllt ist.

Update: Versuchen Sie, über den abstrakten Fall / die abstrakten Begriffe nachzudenken. Zum Beispiel hat vielleicht jede Waffe eine "Vorbereitungs" -Aktion, die ein Nachladen für Waffen und eine Enthüllung für Schwerter ist.

Batavia
quelle
Angenommen, ich habe ein internes Waffenwörterbuch, das die Aktionen für die Waffen enthält. Wenn der Benutzer "Reload" übergibt, überprüft er das Wörterbuch, z. B. WaffeActions.containsKey (Aktion), wenn dies der Fall ist, greift nach dem zugehörigen Objekt und führt es aus es.
Siehe oben bearbeiten. Dies ist, was ich bei der Verwendung des SP
0

Natürlich ist es in Ordnung, wenn Sie keine Unterklasse mit der Absicht erstellen, eine Instanz der Basisklasse zu ersetzen, sondern wenn Sie eine Unterklasse erstellen, indem Sie die Basisklasse als praktisches Repository für Funktionen verwenden.

Ob das eine gute Idee ist oder nicht, ist sehr umstritten. Wenn Sie jedoch die Unterklasse niemals durch die Unterklasse ersetzen, ist die Tatsache, dass sie nicht funktioniert, kein Problem. Möglicherweise haben Sie Probleme, aber LSP ist in diesem Fall nicht das Problem.

gnasher729
quelle
0

Der LSP ist gut, da der aufrufende Code sich keine Gedanken darüber machen muss, wie die Klasse funktioniert.

z.B. Ich kann Weapon.Attack () für alle auf meinem BattleMech montierten Waffen aufrufen und mache mir keine Sorgen, dass einige von ihnen eine Ausnahme auslösen und mein Spiel zum Absturz bringen könnten.

In Ihrem Fall möchten Sie jetzt Ihren Basistyp um neue Funktionen erweitern. Attack () ist kein Problem, da die Gun-Klasse ihre Munition verfolgen und aufhören kann zu schießen, wenn sie leer ist. Aber Reload () ist etwas Neues und gehört nicht dazu, eine Waffe zu sein.

Die einfache Lösung ist Downcasting. Ich glaube nicht, dass Sie sich übermäßig um die Leistung sorgen müssen. Sie werden es nicht in jedem Frame tun.

Alternativ können Sie Ihre Architektur neu bewerten und berücksichtigen, dass abstrakt alle Waffen nachladbar sind und einige Waffen einfach nie nachgeladen werden müssen.

Dann erweitern Sie die Klasse für Waffen nicht mehr oder verletzen den LSP.

Aber es ist auf lange Sicht problematisch, weil Sie sich zwangsläufig mehr Sonderfälle ausdenken müssen, Gun.SafteyOn (), Sword.WipeOffBlood () usw., und wenn Sie sie alle in Weapon einfügen, haben Sie eine super komplizierte verallgemeinerte Basisklasse, die Sie behalten sich ändern müssen.

bearbeiten: warum das Strategiemuster schlecht ist (tm)

Dies ist nicht der Fall, aber berücksichtigen Sie das Setup, die Leistung und den Gesamtcode.

Ich muss irgendwo eine Konfiguration haben, die mir sagt, dass eine Waffe nachladen kann. Wenn ich eine Waffe instanziiere, muss ich diese Konfiguration lesen und alle Methoden dynamisch hinzufügen, überprüfen, ob es keine doppelten Namen usw. Gibt

Wenn ich eine Methode aufrufe, muss ich diese Liste von Aktionen durchlaufen und eine Zeichenfolgenübereinstimmung durchführen, um zu sehen, welche aufgerufen werden sollen.

Wenn ich den Code kompiliere und Weapon.Do ("atack") anstelle von "attack" aufrufe, wird beim Kompilieren kein Fehler angezeigt.

Es kann eine geeignete Lösung für einige Probleme sein, zum Beispiel, dass Sie Hunderte von Waffen mit unterschiedlichen Kombinationen von Zufallsmethoden haben, aber Sie verlieren viele Vorteile von OO und starkem Tippen. Es erspart Ihnen nicht wirklich etwas über Downcasting

Ewan
quelle
Ich denke, der SP kann mit all dem umgehen (siehe oben), die Waffe hätte SafteyOn()und Swordhätte wipeOffBlood(). Jede Waffe kennt die anderen Methoden nicht (und sollte es auch nicht sein)
Der SP ist in Ordnung, entspricht aber dem Downcasting ohne Typensicherheit. Ich glaube, ich habe eine andere Frage beantwortet, lass mich aktualisieren
Ewan
2
An sich impliziert das Strategiemuster keine dynamische Suche einer Strategie in einer Liste oder einem Wörterbuch. Dh beide weapon.do("attack")und der Typ-Safe weapon.attack.perform()können Beispiele für das Strategiemuster sein. Das Nachschlagen von Strategien nach Namen ist nur erforderlich, wenn das Objekt aus einer Konfigurationsdatei konfiguriert wird. Die Verwendung von Reflection wäre jedoch ebenso typsicher.
Amon
Das wird in dieser Situation nicht funktionieren, da es zwei separate Aktionen gibt, die angreifen und neu laden, die Sie an einige Benutzereingaben binden müssen
Ewan