Contourner les règles des sorciers et des guerriers

9

Dans cette série d'articles de blog , Eric Lippert décrit un problème de conception orientée objet utilisant des assistants et des guerriers comme exemples, où:

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

puis ajoute quelques règles:

  • Un guerrier ne peut utiliser qu'une épée.
  • Un assistant ne peut utiliser qu'un bâton.

Il continue ensuite à démontrer les problèmes que vous rencontrez si vous essayez d'appliquer ces règles en utilisant le système de type C # (par exemple, rendre la Wizardclasse responsable de s'assurer qu'un assistant ne peut utiliser qu'un bâton). Vous violez le principe de substitution Liskov, vous risquez des exceptions d'exécution ou vous vous retrouvez avec du code difficile à étendre.

La solution qu'il propose est qu'aucune validation n'est effectuée par la classe Player. Il est uniquement utilisé pour suivre l'état. Ensuite, au lieu de donner une arme à un joueur:

player.Weapon = new Sword();

l'état est modifié par Commands et selon Rules:

... nous créons un Commandobjet appelé Wieldqui prend deux objets d'état de jeu, a Playeret a Weapon. Lorsque l'utilisateur envoie une commande au système «cet assistant doit manier cette épée», cette commande est alors évaluée dans le contexte d'un ensemble de Rules, qui produit une séquence de Effects. Nous en avons un Rulequi dit que lorsqu'un joueur tente de manier une arme, l'effet est que l'arme existante, s'il y en a une, est lâchée et que la nouvelle arme devient l'arme du joueur. Nous avons une autre règle qui renforce la première règle, qui dit que les effets de la première règle ne s'appliquent pas lorsqu'un sorcier essaie de brandir une épée.

J'aime cette idée en principe, mais je me demande comment elle pourrait être utilisée dans la pratique.

Rien ne semble empêcher un développeur de contourner le Commandset Rules en plaçant simplement le Weaponsur a Player. La Weaponpropriété doit être accessible par la Wieldcommande, elle ne peut donc pas être créée private set.

Alors, qu'est - ce qui empêche un développeur de faire cela? Doivent-ils simplement se rappeler de ne pas le faire?

Ben L
la source
2
Je ne pense pas que cette question soit spécifique au langage (C #) car c'est vraiment une question sur la conception OOP. Veuillez envisager de supprimer la balise C #.
Maybe_Factor
1
@maybe_factor La balise c # est correcte car le code publié est c #.
CodingYoshi
Pourquoi ne demandez-vous pas directement à @EricLippert? Il semble apparaître ici sur ce site de temps en temps.
Doc Brown
@ Maybe_Factor - J'ai hésité sur la balise C #, mais j'ai décidé de la conserver au cas où il y aurait une solution spécifique au langage.
Ben L
1
@DocBrown - J'ai posté cette question sur son blog (il y a seulement quelques jours, certes, je n'ai pas attendu si longtemps pour une réponse). Existe-t-il un moyen de porter ma question ici à son attention?
Ben L

Réponses:

9

L'argument entier auquel mène une série de billets de blog se trouve dans la cinquième partie :

Nous n'avons aucune raison de croire que le système de type C # a été conçu pour avoir une généralité suffisante pour encoder les règles de Dungeons & Dragons, alors pourquoi essayons-nous même?

Nous avons résolu le problème «où va le code qui exprime les règles du système?» Il va dans les objets qui représentent les règles du système, pas dans les objets qui représentent l'état du jeu; le souci des objets d'état est de maintenir leur état cohérent, pas d'évaluer les règles du jeu.

Les armes, personnages, monstres et autres objets du jeu ne sont pas responsables de vérifier ce qu'ils peuvent ou ne peuvent pas faire. Le système de règles en est responsable. L' Commandobjet ne fait rien non plus avec les objets du jeu. Cela représente simplement la tentative de faire quelque chose avec eux. Le système de règles vérifie ensuite si la commande est possible et lorsqu'elle l'est, le système de règles exécute la commande en appelant les méthodes appropriées sur les objets du jeu.

Si un développeur veut créer un deuxième système de règles qui fait des choses avec des personnages et des armes que le premier système de règles ne permettrait pas, il peut le faire parce qu'en C # vous ne pouvez pas (sans hacks de réflexion désagréables) savoir d'où vient un appel de méthode de.

Une solution de contournement qui pourrait fonctionner dans certaines situations consiste à placer les objets de jeu (ou leurs interfaces) dans un seul assemblage avec le moteur de règles et à marquer les méthodes de mutation comme internal. Tous les systèmes qui ont besoin d'un accès en lecture seule aux objets du jeu se trouveraient dans un assemblage différent, ce qui signifie qu'ils ne pourraient accéder qu'aux publicméthodes. Cela laisse toujours la faille des objets de jeu appelant les méthodes internes des autres. Mais cela serait une odeur de code évidente, car vous avez convenu que les classes d'objets de jeu sont censées être des détenteurs d'état stupides.

Philipp
la source
4

Le problème évident du code d'origine est qu'il fait de la modélisation de données au lieu de la modélisation d' objet . Veuillez noter qu'il n'y a absolument aucune mention des exigences commerciales réelles dans l'article lié!

Je commencerais par essayer d'obtenir les exigences fonctionnelles réelles. Par exemple: "N'importe quel joueur peut attaquer n'importe quel autre joueur, ...". Ici:

interface Player {
    void Attack(Player enemy);
}

"Les joueurs peuvent manier une arme qui est utilisée dans l'attaque, les sorciers peuvent manier un bâton, les guerriers une épée":

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

"Chaque arme inflige des dégâts à l'ennemi attaqué". Ok, maintenant nous devons avoir une interface commune pour Weapon:

interface Weapon {
    void dealDamageTo(Player enemy);
}

Et ainsi de suite ... Pourquoi n'y en a-t-il pas Wield()dans le Player? Parce qu'il n'y avait aucune exigence selon laquelle tout joueur peut utiliser n'importe quelle arme.

Je peux imaginer, qu'il y aurait une exigence qui dit: "N'importe qui Playerpeut essayer d'en utiliser Weapon." Ce serait cependant une chose complètement différente. Je le modéliserais peut-être de cette façon:

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

Résumé: modélisez les exigences et uniquement les exigences. Ne faites pas de modélisation de données, ce n'est pas de la modélisation oo.

Robert Bräutigam
la source
1
Avez-vous lu la série? Peut-être voulez-vous dire à l'auteur de cette série de ne pas modéliser les données mais les exigences. Les rquirements que vous avez dans votre réponse sont vos confectionnés exigences pas aux exigences de l'auteur avait lors de la construction du compilateur C #.
CodingYoshi
2
Eric Lippert détaille un problème technique dans cette série, ce qui est bien. Cette question concerne le problème réel, mais pas les fonctionnalités C #. Mon point est que, dans les projets réels, nous sommes censés suivre les exigences commerciales (pour lesquelles j'ai donné des exemples inventés, oui), et non assumer des relations et des propriétés. C'est ainsi que vous obtenez un modèle qui vous convient. Quelle était la quesetion.
Robert Bräutigam
C'est la première chose que j'ai pensé en lisant cette série. L'auteur vient de proposer quelques abstractions, ne les évaluant jamais plus, se contentant de les respecter. Essayer de résoudre mécaniquement le problème, encore et encore. Au lieu de penser à un domaine et à des abstractions utiles, cela devrait apparemment passer en premier. Mon vote positif.
Vadim Samokhin
Ceci est la bonne réponse. L'article exprime des exigences contradictoires (une exigence dit qu'un joueur peut manier une [n'importe quelle] arme, tandis que d'autres exigences disent que ce n'est pas le cas.) Et détaille ensuite à quel point il est difficile pour le système d'exprimer correctement le conflit. La seule bonne réponse est de supprimer le conflit. Dans ce cas, cela signifie supprimer l'exigence selon laquelle un joueur peut utiliser n'importe quelle arme.
Daniel T.
2

Une façon serait de passer la Wieldcommande au Player. Le joueur exécute ensuite la Wieldcommande, qui vérifie les règles appropriées et renvoie la Weapon, avec laquelle Playeril définit ensuite son propre champ d'arme. De cette façon, le champ Weapon peut avoir un setter privé et ne peut être réglé qu'en passant une Wieldcommande au joueur.

Peut-être_Facteur
la source
En fait, cela ne résout pas le problème. Le développeur qui crée l'objet de commande peut passer n'importe quelle arme et le joueur la réglera. Allez lire la série car le problème est plus difficile que vous ne le pensez. En fait, il a fait cette série parce qu'il a rencontré ce problème de conception lors du développement du compilateur Roslyn C #.
CodingYoshi
2

Rien n'empêche le développeur de le faire. En fait, Eric Lippert a essayé de nombreuses techniques différentes mais elles avaient toutes des faiblesses. C'était tout l'intérêt de cette série, empêcher le développeur de le faire n'est pas facile et tout ce qu'il a essayé avait des inconvénients. Enfin, il a décidé que l'utilisation d'un Commandobjet avec des règles était la voie à suivre.

Avec les règles, vous pouvez définir la Weaponpropriété de a Wizardpour être un, Swordmais lorsque vous demandez Wizardà l'arme (épée) et l'attaque, elle n'aura aucun effet et ne changera donc aucun état. Comme il le dit ci-dessous:

Nous avons une autre règle qui renforce la première règle, qui dit que les effets de la première règle ne s'appliquent pas lorsqu'un sorcier essaie de brandir une épée. Les effets de cette situation sont «faire un triste trombone, l'utilisateur perd son action pour ce tour, aucun état de jeu n'est muté

En d'autres termes, nous ne pouvons pas appliquer une telle règle par le biais de typerelations qu'il a essayées de différentes manières mais qui ne l'ont pas aimé ou qui n'ont pas fonctionné. Ainsi, la seule chose qu'il a dit que nous pouvons faire est de faire quelque chose à ce sujet lors de l'exécution. Lancer une exception n'était pas bon car il ne la considère pas comme une exception.

Il a finalement choisi d'aller avec la solution ci-dessus. Cette solution dit essentiellement que vous pouvez définir n'importe quelle arme, mais si vous la cédez, si ce n'est la bonne arme, elle serait essentiellement inutile. Mais aucune exception ne serait levée.

Je pense que c'est une bonne solution. Bien que dans certains cas, j'irais aussi avec le modèle try-set.

CodageYoshi
la source
This solution basically says you can set any weapon but when you yield it, if not the right weapon, it would be essentially useless.Je ne l'ai pas trouvé dans cette série, pourriez-vous s'il vous plaît me montrer où cette solution est proposée?
Vadim Samokhin
@zapadlo Il le dit indirectement. J'ai copié cette partie dans ma réponse et je l'ai citée. Le voici de nouveau. Dans la citation, il dit: quand un sorcier essaie de manier une épée. Comment un sorcier peut-il manier une épée si une épée n'a pas été placée? Il doit avoir été réglé. Ensuite, si un sorcier brandit une épée Les effets de cette situation sont «faire un triste trombone, l'utilisateur perd son action pour ce tour
CodingYoshi
Hmm, je pense que brandir une épée signifie essentiellement qu'elle devrait être réglée, non? En lisant ce paragraphe, j'interprète que l'effet de la première règle est that the existing weapon, if there is one, is dropped and the new weapon becomes the player’s weapon. Bien que la deuxième règle soit that strengthens the first rule, that says that the first rule’s effects do not apply when a wizard tries to wield a sword.donc je pense qu'il existe une règle vérifiant si l'arme est une épée, elle ne peut donc pas être maniée par un sorcier, elle n'est donc pas définie. Au lieu de cela, un trombone triste sonne.
Vadim Samokhin
À mon avis, il serait étrange qu'une règle de commandement soit violée. C'est comme brouiller un problème. Pourquoi gérer ce problème après qu'une règle a déjà été violée et définir un assistant dans un état non valide? Le sorcier ne peut pas avoir d'épée, mais c'est le cas! Pourquoi ne pas laisser cela se produire?
Vadim Samokhin
Je suis d'accord avec @Zapadlo sur la façon d'interpréter Wieldici. Je pense que c'est un nom un peu trompeur pour la commande. Quelque chose comme ChangeWeaponserait plus précis. Je suppose que vous pourriez avoir un modèle différent où vous pouvez définir n'importe quelle arme, mais lorsque vous la céderez, sinon la bonne arme, ce serait essentiellement inutile . Cela semble intéressant, mais je ne pense pas que ce soit ce que décrit Eric Lippert.
Ben L
2

La première solution rejetée par l'auteur a été de représenter les règles par le système de type. Le système de type est évalué au moment de la compilation. Si vous détachez les règles du système de type, elles ne sont plus vérifiées par le compilateur, donc rien n'empêche un développeur de faire une erreur en soi.

Mais ce problème est rencontré par chaque élément de logique / modélisation qui n'est pas vérifié par le compilateur et la réponse générale à cela est le test (unitaire). Par conséquent, la solution proposée par l'auteur a besoin d'un faisceau de test solide pour contourner les erreurs des développeurs. Pour souligner ce point d'avoir besoin d'un harnais de test solide pour les erreurs qui ne sont détectées qu'au moment de l'exécution, consultez cet article de Bruce Eckel, qui fait valoir que vous devez échanger le typage fort pour des tests plus forts dans les langages dynamiques.

En conclusion, la seule chose qui peut empêcher les développeurs de faire des erreurs est d'avoir un ensemble de tests (unitaires) vérifiant que toutes les règles sont respectées.

larsbe
la source
Vous devez utiliser une API et l'API est censée vous assurer de faire des tests unitaires ou de faire cette hypothèse? Tout le défi consiste à modéliser afin que le modèle ne casse pas même si le développeur qui utilise le modèle est imprudent.
CodingYoshi
1
Le point que j'essayais de faire est qu'il n'y a rien qui empêche le développeur de faire des erreurs. Dans la solution proposée, les règles sont détachées des données, donc si vous ne créez pas vos propres contrôles, rien ne vous empêche d'utiliser les objets de données sans appliquer les règles.
larsbe
1

J'ai peut-être manqué une subtilité ici, mais je ne suis pas sûr que le problème soit avec le système de type. C'est peut-être avec la convention en C #.

Par exemple, vous pouvez rendre ce type complètement sûr en Weaponprotégeant le setter dans Player. Ensuite, ajoutez setSword(Sword)et setStaff(Staff)à Warrioret Wizardrespectivement qui appellent le setter protégé.

De cette façon, la relation Player/ Weaponest vérifiée statiquement et le code qui ne se soucie pas peut simplement utiliser un Playerpour obtenir un Weapon.

Alex
la source
Eric Lippert n'a pas voulu lever d'exceptions. Avez-vous lu la série? La solution doit répondre aux exigences et ces exigences sont clairement énoncées dans la série.
CodingYoshi
@CodingYoshi Pourquoi cela lèverait-il une exception? Il est de type sécurisé, c'est-à-dire vérifiable au moment de la compilation.
Alex
Désolé, je n'ai pas pu changer mon commentaire une fois que j'ai réalisé que vous ne jetez pas d'exception. Cependant, vous avez rompu l'héritage en faisant cela. Voir le problème que l'auteur tentait de résoudre est que vous ne pouvez pas simplement ajouter une méthode comme vous l'avez fait car maintenant les types ne peuvent pas être traités de manière polymorphe.
CodingYoshi
@CodingYoshi La condition polymorphe est qu'un joueur possède une arme. Et dans ce schéma, le joueur a en effet une arme. Aucun héritage n'est rompu. Cette solution ne se compilera que si vous obtenez les bonnes règles.
Alex
@CodingYoshi Maintenant, cela ne signifie pas que vous ne pouvez pas écrire de code qui nécessiterait une vérification de l'exécution, par exemple si vous essayez d'ajouter un Weaponà un Player. Mais il n'y a pas de système de types où vous ne connaissez pas les types de béton au moment de la compilation qui peuvent agir sur ces types de béton au moment de la compilation. Par définition. Ce schéma signifie que seul ce cas doit être traité au moment de l'exécution, car en tant que tel, il est en fait meilleur que n'importe lequel des schémas d'Eric.
Alex
0

Alors, qu'est-ce qui empêche un développeur de faire cela? Doivent-ils juste se rappeler de ne pas le faire?

Cette question est en fait la même avec un sujet assez saint-guerre-ish appelé " où mettre la validation " (notant probablement aussi ddd).

Donc, avant de répondre à cette question, il faut se demander: quelle est la nature des règles que vous souhaitez suivre? Sont-ils gravés dans la pierre et définissent l'entité? La violation de ces règles fait-elle en sorte qu'une entité cesse d'être ce qu'elle est? Si oui, en plus de conserver ces règles dans la validation de commande , placez-les également dans une entité. Donc, si un développeur oublie de valider la commande, vos entités ne seront pas dans un état invalide.

Si ce n'est pas le cas, cela implique intrinsèquement que ces règles sont spécifiques à une commande et ne doivent pas résider dans des entités de domaine. La violation de ces règles entraîne donc des actions qui n'auraient pas dû être autorisées, mais pas dans un état de modèle non valide.

Vadim Samokhin
la source