Eludiendo las reglas en magos y guerreros

9

En esta serie de publicaciones de blog , Eric Lippert describe un problema en el diseño orientado a objetos utilizando magos y guerreros como ejemplos, donde:

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

y luego agrega un par de reglas:

  • Un guerrero solo puede usar una espada.
  • Un asistente solo puede usar un bastón.

Luego continúa demostrando los problemas con los que se encuentra si intenta hacer cumplir estas reglas utilizando el sistema de tipo C # (por ejemplo, hacer que la Wizardclase sea responsable de asegurarse de que un asistente solo pueda usar un bastón). Viola el Principio de sustitución de Liskov, arriesga excepciones de tiempo de ejecución o termina con un código que es difícil de extender.

La solución que se le ocurre es que la clase Player no realiza ninguna validación. Solo se usa para rastrear el estado. Luego, en lugar de darle un arma a un jugador:

player.Weapon = new Sword();

el estado se modifica por Commandsy según Rules:

... hacemos un Commandobjeto llamado Wieldque toma dos objetos de estado del juego, a Playery a Weapon. Cuando el usuario emite un comando para el sistema "este asistente debería empuñar esa espada", entonces ese comando se evalúa en el contexto de un conjunto de Rules, que produce una secuencia de Effects. Tenemos uno Ruleque dice que cuando un jugador intenta empuñar un arma, el efecto es que el arma existente, si la hay, se cae y la nueva arma se convierte en el arma del jugador. Tenemos otra regla que fortalece la primera regla, que dice que los efectos de la primera regla no se aplican cuando un mago intenta empuñar una espada.

En principio, me gusta esta idea, pero me preocupa cómo podría usarse en la práctica.

Nada parece impedir que un desarrollador se sustraigan a la Commandsy Rulees simplemente fijando el Weaponsobre una Player. El comando Weapondebe poder acceder a la propiedad Wield, por lo que no se puede hacer private set.

Por lo tanto, lo que hace evitar que un desarrollador de hacer esto? ¿Solo tienen que recordar no hacerlo?

Ben L
fuente
2
No creo que esta pregunta sea específica del lenguaje (C #) ya que realmente es una pregunta sobre el diseño de OOP. Considere eliminar la etiqueta C #.
Maybe_Factor
1
@maybe_factor La etiqueta c # está bien porque el código publicado es c #.
CodingYoshi
¿Por qué no le preguntas a @EricLippert directamente? Parece aparecer aquí en este sitio de vez en cuando.
Doc Brown
@Maybe_Factor: dudaba sobre la etiqueta C #, pero decidí mantenerla en caso de que haya una solución específica para el idioma.
Ben L
1
@DocBrown: publiqué esta pregunta en su blog (admití hace solo un par de días; no he esperado tanto tiempo por una respuesta). ¿Hay alguna manera de llamar mi atención aquí?
Ben L

Respuestas:

9

Todo el argumento al que conduce una serie de publicaciones de blog está en la Parte Cinco :

No tenemos ninguna razón para creer que el sistema de tipo C # fue diseñado para tener la generalidad suficiente para codificar las reglas de Dungeons & Dragons, entonces, ¿por qué lo estamos intentando?

Hemos resuelto el problema de "¿a dónde va el código que expresa las reglas del sistema?" Va en objetos que representan las reglas del sistema, no en los objetos que representan el estado del juego; La preocupación de los objetos de estado es mantener su estado consistente, no en evaluar las reglas del juego.

Las armas, los personajes, los monstruos y otros objetos del juego no son responsables de verificar lo que pueden o no pueden hacer. El sistema de reglas es responsable de eso. El Commandobjeto tampoco está haciendo nada con los objetos del juego. Simplemente representa el intento de hacer algo con ellos. El sistema de reglas luego verifica si el comando es posible, y cuando lo es, ejecuta el comando llamando a los métodos apropiados en los objetos del juego.

Si un desarrollador quiere crear un segundo sistema de reglas que haga cosas con personajes y armas que el primer sistema de reglas no permitiría, puede hacerlo porque en C # no puede (sin los trucos de reflexión desagradables) averiguar de dónde proviene una llamada al método desde.

Una solución alternativa que podría funcionar en algunas situaciones es colocar los objetos del juego (o sus interfaces) en un ensamblaje con el motor de reglas y marcar los métodos mutadores como internal. Cualquier sistema que necesite acceso de solo lectura a los objetos del juego estaría en un ensamblaje diferente, lo que significa que solo podrían acceder a los publicmétodos. Esto todavía deja la laguna de los objetos del juego que se llaman métodos internos de cada uno. Pero hacer eso sería un olor de código obvio, porque acordó que se supone que las clases de objetos del juego son titulares tontos.

Philipp
fuente
4

El problema obvio del código original es que está haciendo modelado de datos en lugar de modelado de objetos . ¡Tenga en cuenta que no hay absolutamente ninguna mención de los requisitos comerciales reales en el artículo vinculado!

Comenzaría tratando de obtener los requisitos funcionales reales. Por ejemplo: "Cualquier jugador puede atacar a cualquier otro jugador, ...". Aquí:

interface Player {
    void Attack(Player enemy);
}

"Los jugadores pueden empuñar un arma que se usa en el ataque, los magos pueden empuñar un bastón, los guerreros una espada":

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

"Cada arma hace daño al enemigo atacado". Ok, ahora tenemos que tener una interfaz común para Arma:

interface Weapon {
    void dealDamageTo(Player enemy);
}

Y así sucesivamente ... ¿Por qué no hay Wield()en el Player? Porque no había ningún requisito de que ningún jugador pudiera empuñar ningún arma.

Puedo imaginar que habría un requisito que diga: "Cualquiera Playerpuede tratar de manejar cualquier Weapon". Sin embargo, esto sería algo completamente diferente. Tal vez lo modelaría de esta manera:

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

Resumen: Modele los requisitos y solo los requisitos. No haga modelado de datos, eso no es demasiado modelado.

Robert Bräutigam
fuente
1
¿Leíste la serie? Quizás desee decirle al autor de esa serie que no modele datos sino requisitos. Los requisitos que tiene en su respuesta son sus requisitos inventados , NO los requisitos que tenía el autor al crear el compilador de C #.
CodingYoshi el
2
Eric Lippert detalla un problema técnico en esa serie, lo cual está bien. Sin embargo, esta pregunta es sobre el problema real, no sobre las características de C #. Mi punto es que, en proyectos reales, se supone que debemos seguir los requisitos comerciales (para los cuales di ejemplos inventados, sí), no asumir relaciones y propiedades. Así es como se obtiene un modelo que se ajusta. Cuál fue la pregunta.
Robert Bräutigam
Eso es lo primero que pensé al leer esa serie. Al autor se le ocurrieron algunas abstracciones, nunca las evaluó más, solo se quedó con ellas. Tratando de resolver mecánicamente el problema, una y otra vez. En lugar de pensar en un dominio y abstracciones que sean útiles, aparentemente debería ir primero. Mi voto a favor.
Vadim Samokhin
Esta es la respuesta correcta. El artículo expresa requisitos contradictorios (un requisito dice que un jugador puede manejar un [cualquier] arma, mientras que otros requisitos dicen que este no es el caso) y luego detalla lo difícil que es para el sistema expresar correctamente el conflicto. La única respuesta correcta es eliminar el conflicto. En este caso, eso significa eliminar el requisito de que un jugador pueda manejar cualquier arma.
Daniel T.
2

Una forma sería pasar el Wieldcomando a Player. Luego, el jugador ejecuta el Wieldcomando, que verifica las reglas apropiadas y devuelve el Weapon, que Playerluego establece su propio campo de Arma. De esta manera, el campo Arma puede tener un setter privado y solo se puede configurar pasando un Wieldcomando al jugador.

Quizás_Factor
fuente
En realidad, esto no resuelve el problema. El desarrollador que está haciendo el objeto de comando puede pasar cualquier arma y el jugador la configurará. Ve a leer la serie porque el problema es más difícil de lo que piensas. En realidad, hizo esa serie porque se encontró con este problema de diseño mientras desarrollaba el compilador Roslyn C #.
CodingYoshi el
2

Nada impide que el desarrollador haga eso. En realidad, Eric Lippert probó muchas técnicas diferentes, pero todas tenían debilidades. Ese fue el objetivo de esa serie de que impedir que el desarrollador lo haga no es fácil y todo lo que intentó tenía desventajas. Finalmente decidió que usar un Commandobjeto con reglas es el camino a seguir.

Con las reglas, puedes establecer que la Weaponpropiedad de Wizarda sea un Swordpero cuando le pides Wizardque empuñe el arma (Espada) y ataque, no tendrá ningún efecto y, por lo tanto, no cambiará ningún estado. Como él dice a continuación:

Tenemos otra regla que fortalece la primera regla, que dice que los efectos de la primera regla no se aplican cuando un mago intenta empuñar una espada. Los efectos para esa situación son "hacer un sonido de trombón triste, el usuario pierde su acción durante este turno, ningún estado del juego está mutado

En otras palabras, no podemos hacer cumplir esa regla a través de typerelaciones que él intentó de muchas maneras diferentes, pero que no le gustó o no funcionó. Por lo tanto, lo único que dijo que podemos hacer es hacer algo al respecto en tiempo de ejecución. Lanzar una excepción no fue bueno porque no lo considera una excepción.

Finalmente eligió ir con la solución anterior. Esta solución básicamente dice que puede configurar cualquier arma, pero cuando la entrega, si no es el arma correcta, sería esencialmente inútil. Pero no se lanzaría ninguna excepción.

Creo que es una buena solución. Aunque en algunos casos también elegiría el patrón try-set.

CodificaciónYoshi
fuente
This solution basically says you can set any weapon but when you yield it, if not the right weapon, it would be essentially useless.No pude encontrarlo en esa serie, ¿podría indicarme dónde se propone esta solución?
Vadim Samokhin
@zapadlo lo dice indirectamente. Copié esa parte en mi respuesta y la cité. Aquí está de nuevo. En la cita dice: cuando un mago intenta empuñar una espada. ¿Cómo puede un mago empuñar una espada si no se ha colocado una espada? Debe haber sido establecido. Entonces, si un mago ejerce una espada Los efectos de esta situación son “hacen un sonido triste trombón, el usuario pierde su recurso de este giro
CodingYoshi
Hmm, creo que empuñar una espada básicamente significa que se debe configurar, ¿no? Mientras leo ese párrafo, interpreto que el efecto de la primera regla es that the existing weapon, if there is one, is dropped and the new weapon becomes the player’s weapon. Mientras que la segunda regla es, that strengthens the first rule, that says that the first rule’s effects do not apply when a wizard tries to wield a sword.entonces, creo que hay una regla que comprueba si el arma es espada, por lo que no puede ser manejada por un hechicero, por lo que no está establecida. En cambio suena un triste trombón.
Vadim Samokhin el
A mi parecer, sería extraño que se violara alguna regla de comando. Es como difuminar un problema. ¿Por qué manejar este problema después de que una regla ya se ha violado y establecer un asistente en un estado no válido? El mago no puede tener una espada, ¡pero la tiene! ¿Por qué no dejar que suceda?
Vadim Samokhin el
Estoy de acuerdo con @Zapadlo sobre cómo interpretar Wieldaquí. Creo que es un nombre ligeramente engañoso para el comando. Algo así ChangeWeaponsería más exacto. Supongo que podría tener un modelo diferente en el que puede configurar cualquier arma, pero cuando la entrega, si no es el arma correcta, sería esencialmente inútil . Eso suena interesante, pero no creo que sea lo que describe Eric Lippert.
Ben L
2

La primera solución descartada del autor fue representar las reglas por el sistema de tipos. El sistema de tipos se evalúa en tiempo de compilación. Si separa las reglas del sistema de tipos, el compilador ya no las verifica, por lo que no hay nada que impida que un desarrollador cometa un error per se.

Pero este problema se enfrenta a cada pieza de lógica / modelado que no es verificada por el compilador y la respuesta general a esto es la prueba (unidad). Por lo tanto, la solución propuesta por el autor necesita un arnés de prueba fuerte para evitar los errores de los desarrolladores. Para subrayar este punto de necesitar un arnés de prueba fuerte para errores que solo se detectan en tiempo de ejecución, mire este artículo de Bruce Eckel, que argumenta que necesita intercambiar el tipo fuerte para pruebas más fuertes en lenguajes dinámicos.

En conclusión, lo único que puede evitar que los desarrolladores cometan errores es tener un conjunto de pruebas (unitarias) que verifiquen que se cumplan todas las reglas.

larsbe
fuente
¿Debe usar una API y se supone que la API debe garantizar que realizará pruebas unitarias o hará esa suposición? Todo el desafío se trata de modelar para que el modelo no se rompa incluso si el desarrollador que lo usa es descuidado.
CodificaciónYoshi
1
El punto que intentaba hacer era que no hay nada que impida que el desarrollador cometa errores. En la solución propuesta, las reglas se separan de los datos, por lo que si no crea sus propias comprobaciones, no hay nada que le impida utilizar los objetos de datos sin aplicar las reglas.
larsbe
1

Puede que me haya perdido una sutileza aquí, pero no estoy seguro de que el problema sea con el sistema de tipos. Tal vez sea con convención en C #.

Por ejemplo, puede hacer que este tipo sea completamente seguro haciendo que el Weaponsetter esté protegido Player. Luego agregue setSword(Sword)y setStaff(Staff)a Warriory Wizardrespectivamente que llaman al setter protegido.

De esa manera, la relación Player/ Weaponse verifica estáticamente y el código que no le importa puede usar a Playerpara obtener un Weapon.

Alex
fuente
Eric Lippert no quería lanzar excepciones. ¿Leíste la serie? La solución tiene que cumplir con los requisitos y estos requisitos se establecen claramente en la serie.
CodificaciónYoshi
@CodingYoshi ¿Por qué esto arrojaría una excepción? Es de tipo seguro, es decir, comprobable en tiempo de compilación.
Alex
Lo siento, no pude cambiar mi comentario una vez que me di cuenta de que no estás arrojando la excitación. Sin embargo, rompiste la herencia al hacer eso. Vea que el problema que el autor estaba tratando de resolver es que no puede simplemente agregar un método como lo hizo porque ahora los tipos no pueden tratarse polimórficamente.
CodingYoshi
@CodingYoshi El requisito polimórfico es que un jugador tenga un arma. Y en este esquema, el jugador tiene un arma. Ninguna herencia está rota. Esta solución solo se compilará si tiene las reglas correctas.
Alex
@CodingYoshi Ahora, eso no significa que no pueda escribir código que requiera una verificación de tiempo de ejecución, por ejemplo, si intenta agregar un Weapona Player. Pero no existe un sistema de tipos en el que no conozca los tipos concretos en tiempo de compilación que pueda actuar sobre esos tipos concretos en tiempo de compilación. Por definición. Este esquema significa que es solo ese caso el que debe tratarse en tiempo de ejecución, como tal, en realidad es mejor que cualquiera de los esquemas de Eric.
Alex
0

Entonces, ¿qué impide que un desarrollador haga esto? ¿Solo tienen que recordar no hacerlo?

Esta pregunta es efectivamente la misma con el tema de la guerra santa llamado " dónde poner la validación " (muy probablemente señalando ddd también).

Entonces, antes de responder esta pregunta, uno debe preguntarse: ¿cuál es la naturaleza de las reglas que desea seguir? ¿Están tallados en piedra y definen la entidad? ¿El incumplimiento de esas reglas hace que una entidad deje de ser lo que es? En caso afirmativo, junto con mantener estas reglas en la validación de comandos , colóquelas también en una entidad. Entonces, si un desarrollador olvida validar el comando, sus entidades no estarán en un estado no válido.

Si no, bueno, implica inherentemente que estas reglas son específicas del comando y no deberían residir en entidades de dominio. Por lo tanto, violar estas reglas da como resultado acciones que no deberían haberse permitido, pero no en un estado de modelo no válido.

Vadim Samokhin
fuente