Umgehen der Regeln in Zauberern und Kriegern

9

In dieser Reihe von Blog-Posts beschreibt Eric Lippert ein Problem im objektorientierten Design am Beispiel von Zauberern und Kriegern.

abstract class Weapon { }
sealed class Staff : Weapon { }
sealed class Sword : Weapon { }

abstract class Player 
{ 
  public Weapon Weapon { get; set; }
}
sealed class Wizard : Player { }
sealed class Warrior : Player { }

und fügt dann ein paar Regeln hinzu:

  • Ein Krieger kann nur ein Schwert benutzen.
  • Ein Assistent kann nur einen Stab verwenden.

Anschließend demonstriert er die Probleme, auf die Sie stoßen, wenn Sie versuchen, diese Regeln mithilfe des C # -Systems durchzusetzen (z. B. indem Sie die WizardKlasse dafür verantwortlich machen, dass ein Assistent nur einen Stab verwenden kann). Sie verstoßen gegen das Liskov-Substitutionsprinzip, riskieren Laufzeitausnahmen oder erhalten Code, der schwer zu erweitern ist.

Die Lösung, die er findet, ist, dass die Player-Klasse keine Validierung durchführt. Es wird nur zum Verfolgen des Status verwendet. Dann, anstatt einem Spieler eine Waffe zu geben durch:

player.Weapon = new Sword();

Zustand wird durch Commands und gemäß Rules geändert :

... wir machen ein CommandObjekt namens Wield, das zwei Spielstatusobjekte nimmt, a Playerund a Weapon. Wenn der Benutzer einen Befehl an das System ausgibt, "dieser Assistent sollte dieses Schwert führen", wird dieser Befehl im Kontext einer Menge von Rules ausgewertet , die eine Folge von Effects erzeugt. Wir haben eine Rule, die besagt, dass, wenn ein Spieler versucht, eine Waffe einzusetzen, die vorhandene Waffe, falls vorhanden, fallen gelassen wird und die neue Waffe zur Waffe des Spielers wird. Wir haben eine andere Regel, die die erste Regel stärkt, die besagt, dass die Auswirkungen der ersten Regel nicht gelten, wenn ein Zauberer versucht, ein Schwert zu führen.

Ich mag diese Idee im Prinzip, habe aber Bedenken, wie sie in der Praxis angewendet werden könnte.

Nichts scheint einen Entwickler daran zu hindern, das Commandsund Rules zu umgehen, indem er einfach das Weaponauf a setzt Player. Die WeaponEigenschaft muss über den WieldBefehl zugänglich sein , damit sie nicht erstellt werden kann private set.

Was hindert einen Entwickler daran? Müssen sie sich nur daran erinnern, es nicht zu tun?

Ben L.
quelle
2
Ich denke nicht, dass diese Frage sprachspezifisch (C #) ist, da es sich wirklich um eine Frage zum OOP-Design handelt. Bitte entfernen Sie das C # -Tag.
Maybe_Factor
1
@maybe_factor c # -Tag ist in Ordnung, da der veröffentlichte Code c # ist.
CodingYoshi
Warum fragst du @EricLippert nicht direkt? Er scheint von Zeit zu Zeit hier auf dieser Seite zu erscheinen.
Doc Brown
@Maybe_Factor - Ich habe über das C # -Tag geschwankt, aber beschlossen, es beizubehalten, falls es eine sprachspezifische Lösung gibt.
Ben L
1
@DocBrown - Ich habe diese Frage in seinem Blog gepostet (zugegebenermaßen erst vor ein paar Tagen; ich habe nicht so lange auf eine Antwort gewartet). Gibt es eine Möglichkeit, meine Frage hier auf ihn aufmerksam zu machen?
Ben L

Antworten:

9

Das ganze Argument, zu dem eine Reihe von Blog-Posts führt, ist in Teil 5 :

Wir haben keinen Grund zu der Annahme, dass das System vom Typ C # so konzipiert wurde, dass es allgemein genug ist, um die Regeln von Dungeons & Dragons zu kodieren. Warum versuchen wir es also überhaupt?

Wir haben das Problem gelöst: "Wohin geht der Code, der die Regeln des Systems ausdrückt?" Es geht um Objekte, die die Regeln des Systems darstellen, nicht um Objekte, die den Spielstatus darstellen. Das Anliegen der Zustandsobjekte ist es, ihren Zustand konsistent zu halten und nicht die Spielregeln zu bewerten.

Waffen, Charaktere, Monster und andere Spielobjekte sind nicht dafür verantwortlich zu überprüfen, was sie können oder nicht können. Dafür ist das Regelsystem verantwortlich. Das CommandObjekt macht auch nichts mit den Spielobjekten. Es ist nur der Versuch , etwas mit ihnen zu tun. Das Regelsystem prüft dann, ob der Befehl möglich ist, und wenn es das Regelsystem ist, führt es den Befehl aus, indem es die entsprechenden Methoden für die Spielobjekte aufruft.

Wenn ein Entwickler ein zweites Regelsystem erstellen möchte, das Dinge mit Charakteren und Waffen tut, die das erste Regelsystem nicht zulässt, kann er dies tun, da Sie in C # (ohne böse Reflexionshacks) nicht herausfinden können, woher ein Methodenaufruf kommt von.

Eine Abhilfe , die möglicherweise in Arbeit einigen Situationen setzt die Spielobjekte (oder deren Schnittstellen) in einer Baugruppe mit der Regelmaschine und markieren Sie alle Mutator-Methoden internal. Alle Systeme, die schreibgeschützten Zugriff auf Spielobjekte benötigen, befinden sich in einer anderen Baugruppe, dh sie können nur auf die publicMethoden zugreifen . Dies lässt immer noch die Lücke von Spielobjekten, die sich gegenseitig die internen Methoden aufrufen. Aber das wäre ein offensichtlicher Codegeruch, denn Sie waren sich einig, dass die Spielobjektklassen dumme Staatsinhaber sein sollen.

Philipp
quelle
4

Das offensichtliche Problem des ursprünglichen Codes besteht darin, dass Datenmodellierung anstelle von Objektmodellierung durchgeführt wird . Bitte beachten Sie, dass die tatsächlichen Geschäftsanforderungen im verlinkten Artikel absolut nicht erwähnt werden!

Ich würde zunächst versuchen, die tatsächlichen funktionalen Anforderungen zu ermitteln. Zum Beispiel: "Jeder Spieler kann jeden anderen Spieler angreifen, ...". Hier:

interface Player {
    void Attack(Player enemy);
}

"Spieler können eine Waffe einsetzen, die für den Angriff verwendet wird, Zauberer können einen Stab einsetzen, Krieger ein Schwert":

public class Wizard: Player {
    ...
    public void Wield(Staff weapon) { ... }
    ...
}
public class Warrior: Player {
    ...
    public void Wield(Sword sword) { ... }
    ...
}

"Jede Waffe fügt dem angegriffenen Feind Schaden zu". Ok, jetzt müssen wir eine gemeinsame Schnittstelle für Weapon haben:

interface Weapon {
    void dealDamageTo(Player enemy);
}

Und so weiter ... Warum gibt es keine Wield()in der Player? Weil es nicht erforderlich war, dass ein Spieler eine Waffe einsetzen kann.

Ich kann mir vorstellen, dass es eine Anforderung geben würde, die besagt: "Jeder Playerkann versuchen , einen zu führen Weapon." Dies wäre jedoch eine ganz andere Sache. Ich würde es vielleicht so modellieren:

interface Player {
    void Attack(Player enemy);
    void TryWielding(Weapon weapon); // Throws UnwieldableException
}

Zusammenfassung: Modellieren Sie die Anforderungen und nur die Anforderungen. Führen Sie keine Datenmodellierung durch, dh keine Modellierung.

Robert Bräutigam
quelle
1
Hast du die Serie gelesen? Vielleicht möchten Sie dem Autor dieser Serie sagen, dass er keine Daten, sondern Anforderungen modellieren soll. Die rquirements Sie in Ihrer Antwort haben , sind Ihre hergerichtete Anforderungen nicht den Anforderungen der Autor hatte , als die C # Compiler zu bauen.
CodingYoshi
2
Eric Lippert beschreibt ein technisches Problem in dieser Serie, das in Ordnung ist. Diese Frage bezieht sich auf das eigentliche Problem, jedoch nicht auf C # -Funktionen. Mein Punkt ist, dass wir in realen Projekten den Geschäftsanforderungen folgen sollen (für die ich erfundene Beispiele gegeben habe, ja), keine Beziehungen und Eigenschaften annehmen . So erhalten Sie ein Modell, das passt. Welches war die Frage.
Robert Bräutigam
Das ist das erste, was ich dachte, als ich diese Serie las. Der Autor hat sich nur einige Abstraktionen ausgedacht, sie nie weiter bewertet, sondern sich nur an sie gehalten. Immer wieder wird versucht, das Problem mechanisch zu lösen. Anstatt über eine Domäne und nützliche Abstraktionen nachzudenken, sollte dies anscheinend an erster Stelle stehen. Meine Gegenstimme.
Vadim Samokhin
Dies ist die richtige Antwort. Der Artikel drückt widersprüchliche Anforderungen aus (eine Anforderung besagt, dass ein Spieler eine [beliebige] Waffe einsetzen kann, während andere Anforderungen besagen, dass dies nicht der Fall ist.) Und beschreibt dann, wie schwierig es für das System ist, den Konflikt korrekt auszudrücken. Die einzig richtige Antwort ist, den Konflikt zu beseitigen. In diesem Fall bedeutet dies, dass nicht mehr erforderlich ist, dass ein Spieler eine Waffe einsetzen kann.
Daniel T.
2

Eine Möglichkeit wäre, den WieldBefehl an die zu übergeben Player. Der Spieler führt dann den WieldBefehl aus, der die entsprechenden Regeln überprüft und den zurückgibt Weapon, mit dem er Playerdann sein eigenes Waffenfeld setzt. Auf diese Weise kann das Waffenfeld einen privaten Setter haben und kann nur durch Übergabe eines WieldBefehls an den Spieler eingestellt werden.

Vielleicht_Faktor
quelle
Eigentlich löst dies das Problem nicht. Der Entwickler, der das Befehlsobjekt erstellt, kann eine beliebige Waffe übergeben und der Spieler wird sie festlegen. Lesen Sie die Serie, denn das Problem ist schwieriger als Sie denken. Eigentlich hat er diese Serie gemacht, weil er bei der Entwicklung des Roslyn C # -Compilers auf dieses Designproblem gestoßen ist.
CodingYoshi
2

Nichts hindert den Entwickler daran. Eigentlich hat Eric Lippert viele verschiedene Techniken ausprobiert, aber alle hatten Schwächen. Das war der springende Punkt dieser Serie, dass es nicht einfach ist, den Entwickler daran zu hindern, und alles, was er versuchte, hatte Nachteile. Schließlich entschied er, dass die Verwendung eines CommandObjekts mit Regeln der richtige Weg ist.

Mit Regeln können Sie die WeaponEigenschaft von a Wizardauf a setzen, Swordaber wenn Sie den bitten Wizard, die Waffe (Schwert) zu führen und anzugreifen, hat dies keine Wirkung und ändert daher keinen Zustand. Wie er unten sagt:

Wir haben eine andere Regel, die die erste Regel stärkt, die besagt, dass die Auswirkungen der ersten Regel nicht gelten, wenn ein Zauberer versucht, ein Schwert zu führen. Die Auswirkungen für diese Situation sind: „Machen Sie einen traurigen Posaunenton, der Benutzer verliert seine Aktion für diesen Zug, kein Spielstatus ist mutiert

Mit anderen Worten, wir können eine solche Regel nicht durch typeBeziehungen durchsetzen, die er auf viele verschiedene Arten ausprobiert hat, aber entweder nicht mochte oder nicht funktionierte. Das Einzige, was er tun kann, ist, zur Laufzeit etwas dagegen zu unternehmen. Eine Ausnahme zu werfen war nicht gut, weil er es nicht als Ausnahme betrachtet.

Er entschied sich schließlich für die obige Lösung. Diese Lösung besagt im Grunde, dass Sie jede Waffe einstellen können, aber wenn Sie sie abgeben, wenn nicht die richtige Waffe, wäre sie im Wesentlichen nutzlos. Aber es würde keine Ausnahme geworfen.

Ich denke, es ist eine gute Lösung. In einigen Fällen würde ich mich aber auch für das Try-Set-Muster entscheiden.

Codierung von Yoshi
quelle
This solution basically says you can set any weapon but when you yield it, if not the right weapon, it would be essentially useless.Ich habe es in dieser Serie nicht gefunden. Können Sie mir bitte zeigen, wo diese Lösung vorgeschlagen wird?
Vadim Samokhin
@ Zapadlo Er sagt es indirekt. Ich habe diesen Teil in meine Antwort kopiert und zitiert. Hier ist es wieder. Im Zitat sagt er: Wenn ein Zauberer versucht, ein Schwert zu führen. Wie kann ein Zauberer ein Schwert führen, wenn kein Schwert gesetzt wurde? Es muss eingestellt worden sein. Wenn dann ein Zauberer ein Schwert schwingt Die Auswirkungen für diese Situation sind: „Machen Sie einen traurigen Posaunenton, der Benutzer verliert seine Aktion für diesen Zug
CodingYoshi
Hmm, ich denke ein Schwert zu führen bedeutet im Grunde, dass es gesetzt werden sollte, nein? Während ich diesen Absatz lese, interpretiere ich ihn so, dass die Wirkung der ersten Regel ist that the existing weapon, if there is one, is dropped and the new weapon becomes the player’s weapon. Während die zweite Regel, die that strengthens the first rule, that says that the first rule’s effects do not apply when a wizard tries to wield a sword.ich denke, dass es eine Regel gibt, die prüft, ob die Waffe ein Schwert ist, kann sie nicht von einem Zauberer geführt werden, so dass sie nicht festgelegt ist. Stattdessen ertönt eine traurige Posaune.
Vadim Samokhin
Meiner Meinung nach wäre es seltsam, wenn eine Befehlsregel verletzt wird. Es ist, als würde man ein Problem verwischen. Warum dieses Problem behandeln, nachdem eine Regel bereits verletzt wurde, und einen Assistenten in einen ungültigen Zustand versetzen? Der Zauberer kann kein Schwert haben, aber es tut! Warum nicht zulassen, dass es passiert?
Vadim Samokhin
Ich stimme @Zapadlo darin zu, wie man Wieldhier interpretiert . Ich denke, es ist ein etwas irreführender Name für den Befehl. So etwas ChangeWeaponwäre genauer. Ich denke, Sie könnten ein anderes Modell haben, in dem Sie jede Waffe einstellen können, aber wenn Sie sie abgeben, wenn sie nicht die richtige Waffe ist, wäre sie im Wesentlichen nutzlos . Das klingt interessant, aber ich glaube nicht, dass Eric Lippert es beschreibt.
Ben L
2

Die erste verworfene Lösung des Autors bestand darin, die Regeln durch das Typsystem darzustellen. Das Typsystem wird zur Kompilierungszeit ausgewertet. Wenn Sie die Regeln vom Typsystem trennen, werden sie vom Compiler nicht mehr überprüft, sodass nichts einen Entwickler daran hindert, per se einen Fehler zu machen.

Dieses Problem tritt jedoch bei jeder Logik / Modellierung auf, die nicht vom Compiler überprüft wird, und die allgemeine Antwort darauf lautet (Einheits-) Test. Daher benötigt die vom Autor vorgeschlagene Lösung ein starkes Testgeschirr, um Fehler von Entwicklern zu umgehen. Um diesen Punkt zu unterstreichen, dass ein starkes Test-Harness für Fehler erforderlich ist, die nur zur Laufzeit erkannt werden, lesen Sie diesen Artikel von Bruce Eckel, in dem argumentiert wird, dass Sie die starke Typisierung gegen stärkere Tests in dynamischen Sprachen austauschen müssen.

Zusammenfassend lässt sich sagen, dass Entwickler nur durch eine Reihe von (Einheits-) Tests verhindern können, dass alle Regeln eingehalten werden.

Larsbe
quelle
Sie müssen eine API verwenden und die API soll sicherstellen, dass Sie Unit-Tests durchführen oder diese Annahme treffen? Die ganze Herausforderung besteht in der Modellierung, damit das Modell nicht die Gewinnschwelle erreicht, wenn der Entwickler, der das Modell verwendet, nachlässig ist.
CodingYoshi
1
Der Punkt, den ich ansprechen wollte, war, dass nichts den Entwickler daran hindert, Fehler zu machen. In der vorgeschlagenen Lösung werden die Regeln von den Daten getrennt. Wenn Sie also keine eigenen Prüfungen erstellen, hindert Sie nichts daran, die Datenobjekte zu verwenden, ohne die Regeln anzuwenden.
Larsbe
1

Ich habe hier vielleicht eine Subtilität verpasst, bin mir aber nicht sicher, ob das Problem beim Typsystem liegt. Vielleicht ist es mit Konvention in C #.

Sie können diesen Typ beispielsweise vollständig sicher machen, indem Sie den WeaponSetter in schützen Player. Dann fügen setSword(Sword)und setStaff(Staff)zu Warriorund Wizardjeweils diesen Ruf der geschützten Setter.

Auf diese Weise wird die Beziehung Player/ Weaponstatisch überprüft und Code, der sich nicht darum kümmert, kann einfach a verwenden Player, um a zu erhalten Weapon.

Alex
quelle
Eric Lippert wollte keine Ausnahmen werfen. Hast du die Serie gelesen? Die Lösung muss die Anforderungen erfüllen und diese Anforderungen sind in der Serie klar angegeben.
CodingYoshi
@CodingYoshi Warum sollte dies eine Ausnahme auslösen? Es ist typsicher, dh zur Kompilierungszeit überprüfbar.
Alex
Entschuldigung, ich konnte meinen Kommentar nicht ändern, als mir klar wurde, dass Sie keine Entschuldigung werfen. Sie haben jedoch die Vererbung dadurch gebrochen. Das Problem, das der Autor zu lösen versuchte, besteht darin, dass Sie nicht einfach eine Methode hinzufügen können, wie Sie es getan haben, da die Typen jetzt nicht mehr polymorph behandelt werden können.
CodingYoshi
@CodingYoshi Die polymorphe Voraussetzung ist, dass ein Spieler eine Waffe hat. Und in diesem Schema hat der Spieler tatsächlich eine Waffe. Es ist keine Vererbung gebrochen. Diese Lösung wird nur kompiliert, wenn Sie die richtigen Regeln haben.
Alex
@CodingYoshi Das bedeutet nicht, dass Sie keinen Code schreiben können, der eine Laufzeitprüfung erfordern würde, z. B. wenn Sie versuchen, a Weaponzu a hinzuzufügen Player. Es gibt jedoch kein Typsystem, bei dem Sie die konkreten Typen zur Kompilierungszeit nicht kennen, die zur Kompilierungszeit auf diese konkreten Typen einwirken können. Per Definition. Dieses Schema bedeutet, dass nur dieser Fall zur Laufzeit behandelt werden muss. Als solches ist es tatsächlich besser als jedes von Erics Schemata.
Alex
0

Was hindert einen Entwickler daran? Müssen sie sich nur daran erinnern, es nicht zu tun?

Diese Frage ist praktisch dieselbe wie bei einem ziemlich heilig-kriegerischen Thema namens " Wo soll die Validierung erfolgen? " (Höchstwahrscheinlich auch ddd).

Bevor Sie diese Frage beantworten, sollten Sie sich fragen: Welche Art von Regeln möchten Sie befolgen? Sind sie in Stein gemeißelt und definieren die Entität? Führt der Verstoß gegen diese Regeln dazu, dass eine Entität nicht mehr das ist, was sie ist? Wenn ja, fügen Sie diese Regeln neben der Befehlsüberprüfung auch einer Entität hinzu. Wenn ein Entwickler vergisst, den Befehl zu validieren, befinden sich Ihre Entitäten nicht in einem ungültigen Zustand.

Wenn nein, bedeutet dies von Natur aus, dass diese Regeln befehlsspezifisch sind und sich nicht in Domänenentitäten befinden sollten. Ein Verstoß gegen diese Regeln führt zu Aktionen, die nicht hätten ausgeführt werden dürfen, jedoch nicht in einem ungültigen Modellstatus.

Vadim Samokhin
quelle