Contornando as regras em magos e guerreiros

9

Em esta série de posts , Eric Lippert descreve um problema no design orientado a objetos usando assistentes e guerreiros como exemplos, em que:

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

e depois adiciona algumas regras:

  • Um guerreiro só pode usar uma espada.
  • Um assistente pode usar apenas uma equipe.

Ele então demonstra os problemas com os quais você se depara se tentar aplicar essas regras usando o sistema do tipo C # (por exemplo, responsabilizando a Wizardclasse por garantir que um assistente possa usar apenas uma equipe). Você viola o princípio de substituição de Liskov, corre o risco de exceções em tempo de execução ou acaba com um código difícil de estender.

A solução que ele apresenta é que nenhuma validação é feita pela classe Player. É usado apenas para rastrear o estado. Então, em vez de dar uma arma a um jogador:

player.Weapon = new Sword();

o estado é modificado por se de Commandacordo com Rules:

... criamos um Commandobjeto chamado Wieldque leva dois objetos de estado do jogo, a Playere a Weapon. Quando o usuário emite um comando para o sistema “esse assistente deve usar essa espada”, esse comando é avaliado no contexto de um conjunto de Rules, que produz uma sequência de Effects. Temos uma Ruleque diz que quando um jogador tenta empunhar uma arma, o efeito é que a arma existente, se houver, é descartada e a nova arma se torna a arma do jogador. Temos outra regra que fortalece a primeira regra, que diz que os efeitos da primeira regra não se aplicam quando um mago tenta empunhar uma espada.

Eu gosto dessa idéia em princípio, mas tenho uma preocupação sobre como ela pode ser usada na prática.

Nada parece impedir que um desenvolvedor de iludirem o Commandse Ruleé simplesmente definindo o Weaponem um Player. A Weaponpropriedade precisa estar acessível pelo Wieldcomando, portanto, não pode ser feita private set.

Então, o que impede um desenvolvedor de fazer isso? Eles apenas precisam se lembrar de não?

Ben L
fonte
2
Eu não acho que essa pergunta seja específica da linguagem (C #), pois é realmente uma pergunta sobre o design do OOP. Por favor, considere remover a tag C #.
Maybe_Factor
11
A tag @maybe_factor c # é boa porque o código publicado é c #.
CodingYoshi
Por que você não pergunta diretamente para @EricLippert? Ele parece aparecer aqui neste site de tempos em tempos.
Doc Brown
@ Maybe_Factor - Eu vacilei sobre a tag C #, mas decidi mantê-la caso haja uma solução específica de idioma.
Ben L
11
@DocBrown - Eu postei essa pergunta em seu blog (apenas alguns dias atrás, é certo; eu não esperei muito tempo por uma resposta). Existe uma maneira de trazer minha pergunta aqui para sua atenção?
Ben L

Respostas:

9

Todo o argumento que uma série de postagens do blog leva até a Parte Cinco :

Não temos motivos para acreditar que o sistema do tipo C # foi projetado para ter generalidade suficiente para codificar as regras de Dungeons & Dragons, então por que estamos tentando?

Resolvemos o problema de “para onde vai o código que expressa as regras do sistema?” Ele entra em objetos que representam as regras do sistema, não nos objetos que representam o estado do jogo; a preocupação dos objetos estatais é manter seu estado consistente, não na avaliação das regras do jogo.

Armas, personagens, monstros e outros objetos do jogo não são responsáveis ​​por verificar o que podem ou não fazer. O sistema de regras é responsável por isso. O Commandobjeto também não está fazendo nada com os objetos do jogo. Representa apenas a tentativa de fazer algo com eles. O sistema de regras verifica se o comando é possível e quando é o sistema de regras executa o comando chamando os métodos apropriados nos objetos do jogo.

Se um desenvolvedor quiser criar um segundo sistema de regras que faça coisas com personagens e armas que o primeiro sistema de regras não permitiria, ele poderá fazer isso porque em C # você não pode (sem hacks de reflexão desagradáveis) descobrir de onde vem uma chamada de método de.

Uma solução alternativa que pode funcionar em algumas situações é colocar os objetos do jogo (ou suas interfaces) em um assembly com o mecanismo de regras e marcar como mutador qualquer método internal. Todos os sistemas que precisam de acesso somente leitura aos objetos do jogo estariam em uma montagem diferente, o que significa que eles só poderiam acessar os publicmétodos. Isso ainda deixa a brecha dos objetos do jogo que chamam os métodos internos um do outro. Mas fazer isso seria um cheiro óbvio para o código, porque você concordou que as classes de objetos do jogo deveriam ser detentoras de estado.

Philipp
fonte
4

O problema óbvio do código original é que ele está fazendo Modelagem de Dados em vez de Modelagem de Objetos . Observe que não há absolutamente nenhuma menção aos requisitos comerciais reais no artigo vinculado!

Eu começaria tentando obter requisitos funcionais reais. Por exemplo: "Qualquer jogador pode atacar outros jogadores, ...". Aqui:

interface Player {
    void Attack(Player enemy);
}

"Os jogadores podem empunhar uma arma usada no ataque, os Magos podem empunhar um Cajado, os Guerreiros uma Espada":

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

"Cada arma causa dano ao inimigo atacado". Ok, agora temos que ter uma interface comum para a Arma:

interface Weapon {
    void dealDamageTo(Player enemy);
}

E assim por diante ... Por que não há Wield()no Player? Porque não havia exigência de que qualquer jogador pudesse usar qualquer arma.

Posso imaginar que haveria um requisito que diz: "Qualquer um Playerpode tentar exercer algum Weapon". Isso seria uma coisa completamente diferente no entanto. Eu modelaria talvez dessa maneira:

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

Resumo: Modele os requisitos e apenas os requisitos. Não faça modelagem de dados, isso não é modelagem oo.

Robert Bräutigam
fonte
11
Você leu a série? Talvez você queira dizer ao autor dessa série que não modele dados, mas requisitos. As exigências que você tem em sua resposta são seus requisitos de composição , NÃO os requisitos que o autor tinha ao criar o compilador C #.
CodingYoshi
2
Eric Lippert está detalhando um problema técnico nessa série, o que é bom. Esta pergunta é sobre o problema real, no entanto, não recursos do C #. Meu argumento é que, em projetos reais, devemos seguir os requisitos de negócios (para os quais dei exemplos inventados, sim), não assumir relações e propriedades. É assim que você obtém um modelo adequado. Qual foi a pergunta.
Robert Bräutigam
Essa foi a primeira coisa que pensei ao ler essa série. O autor acabou de apresentar algumas abstrações, nunca as avaliando mais, apenas mantendo-as. Tentando resolver mecanicamente o problema, repetidamente. Em vez de pensar em um domínio e abstrações que sejam úteis, isso aparentemente deveria acontecer primeiro. Meu voto positivo.
Vadim Samokhin
Essa é a resposta correta. O artigo expressa requisitos conflitantes (um requisito diz que um jogador pode usar uma arma [qualquer], enquanto outros requisitos dizem que esse não é o caso.) E, em seguida, detalha quão difícil é para o sistema expressar corretamente o conflito. A única resposta correta é remover o conflito. Nesse caso, isso significa remover o requisito de que um jogador possa usar qualquer arma.
26517 Daniel T.
2

Uma maneira seria passar o Wieldcomando para o Player. O jogador então executa o Wieldcomando, que verifica as regras apropriadas e retorna o Weapon, com o qual Playerele define seu próprio campo de arma. Dessa forma, o campo Arma pode ter um setter privado e só pode ser definido através da passagem de um Wieldcomando para o jogador.

Maybe_Factor
fonte
Na verdade, isso não resolve o problema. O desenvolvedor que está criando o objeto de comando pode passar qualquer arma e o jogador a definirá. Vá ler a série porque o problema é mais difícil do que você pensa. Na verdade, ele fez essa série porque se deparou com esse problema de design enquanto desenvolvia o compilador Roslyn C #.
CodingYoshi
2

Nada impede que o desenvolvedor faça isso. Na verdade, Eric Lippert tentou muitas técnicas diferentes, mas todas tinham fraquezas. Esse era o ponto principal da série que impedir o desenvolvedor de fazer isso não é fácil e tudo o que ele tentou teve desvantagens. Finalmente, ele decidiu que usar um Commandobjeto com regras é o caminho a seguir.

Com as regras, você pode definir a Weaponpropriedade de Wizarda como a, Swordmas quando você pede Wizardpara usar a arma (espada) e atacar, ela não terá nenhum efeito e, portanto, não mudará nenhum estado. Como ele diz abaixo:

Temos outra regra que fortalece a primeira regra, que diz que os efeitos da primeira regra não se aplicam quando um mago tenta empunhar uma espada. Os efeitos para essa situação são "fazer um som trombone triste, o usuário perde sua ação neste turno, nenhum estado do jogo é alterado.

Em outras palavras, não podemos impor essa regra por meio de typerelacionamentos que ele tentou de muitas maneiras diferentes, mas que não gostaram ou não deram certo. Portanto, a única coisa que ele disse que podemos fazer é fazer algo sobre isso em tempo de execução. Lançar uma exceção não era bom porque ele não a considera uma exceção.

Ele finalmente escolheu a solução acima. Essa solução basicamente diz que você pode definir qualquer arma, mas quando você a produz, se não a arma certa, seria essencialmente inútil. Mas nenhuma exceção seria lançada.

Eu acho que é uma boa solução. Embora, em alguns casos, eu também siga o padrão de teste.

CodificaçãoYoshi
fonte
This solution basically says you can set any weapon but when you yield it, if not the right weapon, it would be essentially useless.Não consegui encontrá-lo nessa série. Você poderia me indicar onde esta solução é proposta?
Vadim Samokhin
@ zapadlo Ele diz isso indiretamente. Copiei essa parte da minha resposta e a citei. Aqui está de novo. Na citação, ele diz: quando um mago tenta empunhar uma espada. Como um mago pode empunhar uma espada se ela não foi colocada? Deve ter sido definido. Então, se um assistente empunha uma espada Os efeitos para essa situação são “fazer um som trombone triste, o usuário perde a sua acção para este turno
CodingYoshi
Hmm, acho que empunhar uma espada significa basicamente que ela deve estar ajustada, não? Ao ler esse parágrafo, interpreto que o efeito da primeira regra é that the existing weapon, if there is one, is dropped and the new weapon becomes the player’s weapon. Enquanto a segunda regra que that strengthens the first rule, that says that the first rule’s effects do not apply when a wizard tries to wield a sword.Então, eu acho que existe uma regra verificando se a arma é espada, por isso não pode ser manejada por um wizzard, por isso não está definida. Em vez disso, soa um triste trombone.
Vadim Samokhin 24/11
Para mim, seria estranho que alguma regra de comando fosse violada. É como desfocar um problema. Por que lidar com esse problema depois que uma regra já foi violada e configurar um assistente em um estado inválido? O mago não pode ter uma espada, mas tem! Por que não deixar isso acontecer?
Vadim Samokhin 24/11
Concordo com o @Zapadlo sobre como interpretar Wieldaqui. Eu acho que é um nome um pouco enganador para o comando. Algo como ChangeWeaponseria mais preciso. Eu acho que você poderia ter um modelo diferente onde você pode definir qualquer arma, mas quando você a produz, se não a arma certa, seria essencialmente inútil . Isso parece interessante, mas não acho que seja o que Eric Lippert descreve.
Ben L
2

A primeira solução descartada do autor foi representar as regras pelo sistema de tipos. O sistema de tipos é avaliado em tempo de compilação. Se você desanexar as regras do sistema de tipos, elas não serão mais verificadas pelo compilador; portanto, não há nada que impeça um desenvolvedor de cometer um erro per se.

Mas esse problema é enfrentado por toda peça de lógica / modelagem que não é verificada pelo compilador e a resposta geral para isso é o teste (de unidade). Portanto, a solução proposta pelo autor precisa de um forte recurso de teste para contornar os erros dos desenvolvedores. Para enfatizar esse ponto de precisar de um forte recurso de teste para erros que só são detectados em tempo de execução, consulte este artigo de Bruce Eckel, que argumenta que você precisa trocar a digitação forte por testes mais fortes em linguagens dinâmicas.

Em conclusão, a única coisa que pode impedir os desenvolvedores de cometer erros é ter um conjunto de testes (unitários), verificando se todas as regras são respeitadas.

Larsbe
fonte
Você deve usar uma API e a API deve garantir que você faça testes de unidade ou faça essa suposição? Todo o desafio é sobre modelagem, para que o modelo não seja quebrado, mesmo que o desenvolvedor que o utiliza seja descuidado.
CodingYoshi
11
O ponto que eu estava tentando enfatizar era que não há nada que impeça o desenvolvedor de cometer erros. Na solução proposta, as regras são desanexadas dos dados; portanto, se você não criar suas próprias verificações, não haverá nada impedindo o uso dos objetos de dados sem aplicar as regras.
Larsbe
1

Talvez eu tenha perdido uma sutileza aqui, mas não tenho certeza se o problema está no sistema de tipos. Talvez seja com convenção em c #.

Por exemplo, você pode tornar esse tipo completamente seguro, protegendo o Weaponsetter Player. Em seguida, adicione setSword(Sword)e setStaff(Staff)para Warriore, Wizardrespectivamente, que chamam o setter protegido.

Dessa forma, o relacionamento Player/ Weaponé estaticamente verificado e o código que não se importa pode usar apenas a Playerpara obter a Weapon.

Alex
fonte
Eric Lippert não queria lançar exceções. Você leu a série? A solução precisa atender aos requisitos e esses requisitos estão claramente definidos na série.
CodingYoshi
@CodingYoshi Por que isso lançaria uma exceção? É do tipo seguro, ou seja, pode ser verificado no momento da compilação.
23417 Alex
Desculpe, não foi possível alterar o meu comentário depois que percebi que você não está lançando uma exceção. No entanto, você quebrou a herança fazendo isso. Veja o problema que o autor estava tentando resolver: você não pode simplesmente adicionar um método como o fez, porque agora os tipos não podem ser tratados polimorficamente.
CodingYoshi
@CodingYoshi O requisito polimórfico é que um jogador tenha uma arma. E neste esquema, o jogador realmente tem uma arma. Nenhuma herança é quebrada. Esta solução será compilada apenas se você acertar as regras.
23417 Alex
@CodingYoshi Agora, isso não significa que você não pode escrever código que exigiria uma verificação de tempo de execução, por exemplo, se você tentar adicionar um Weapona a Player. Mas não existe um sistema de tipos em que você não conheça os tipos concretos em tempo de compilação que possam atuar nesses tipos concretos em tempo de compilação. Por definição. Esse esquema significa que é apenas o caso que precisa ser tratado em tempo de execução. Como tal, é realmente melhor do que qualquer um dos esquemas de Eric.
23417 Alex
0

Então, o que impede um desenvolvedor de fazer isso? Eles apenas precisam se lembrar de não?

Essa questão é efetivamente a mesma com o tópico bastante conhecido como " onde colocar a validação " (provavelmente observando também o ddd).

Portanto, antes de responder a essa pergunta, deve-se perguntar: qual é a natureza das regras que você deseja seguir? Eles são esculpidos em pedra e definem a entidade? A quebra dessas regras resulta em uma entidade que deixa de ser o que é? Se sim, além de manter essas regras na validação de comandos , coloque-as em uma entidade também. Portanto, se um desenvolvedor esquecer de validar o comando, suas entidades não estarão em um estado inválido.

Se não - bem, isso implica inerentemente que essas regras são específicas de comandos e não devem residir em entidades de domínio. Portanto, violar essas regras resulta em ações que não deveriam ter sido permitidas, mas não no estado de modelo inválido.

Vadim Samokhin
fonte