DSL: Rolando dados com o Groovy

Dados de RPGProvavelmente, muitos dos que acompanham este blog também acompanharam o evento Rails for Kids, que rolou no último sábado. Eu particularmente achei o evento muito bacana, principalmente agora que já terminei de ler o livro Agile Web Development with Rails e estou começando a levar o Rails mais a sério como opção de framework para criação de aplicações web. Mas não é sobre isso que falarei hoje.

Apesar de já ter lido um punhado de artigos e posts sobre DSLs, a palestra que o Ronaldo Ferraz ministrou nesse evento foi o incentivo de que precisava para tomar vergonha na cara e tentar implementar minha primeira DSL. Eu peguei um dos primeiros exemplos que ele citou em sua palestra — um rolador de dados — e tentei implementar para ver como ficava. O resultado dessa experiência será detalhado ao longo deste post.

Sobre a DSL

A idéia era criar uma DSL em Groovy que permitisse especificar rolagens de dados de RPG numa “linguagem” semelhante à forma que os próprios jogadores costumam usar. Claro que eu adicionei algumas firulas, mas os exemplos abaixo ilustram bem essa “linguagem”:

  1. 2.d10 + 5.d20
  2. 5.d - 2.d12
  3. 2.d20 - 5
  4. 4.d * 4
  5. (5.d6 + 5) * 4
  6. 5.d6 + 5 * 4

Cada uma das linhas acima pode ser traduzida para as formas textuais correspondentes:

  1. Role 2 dados de 10 faces e 5 dados de 20 faces, e junte os resultados;
  2. Role 5 dados comuns (de 6 faces) e tire dos resultados todos os números sorteados ao rolar 2 dados de 12 faces;
  3. Role 2 dados de 20 faces e adicione um “-5″ nos resultados;
  4. Role 4 dados de 6 faces e multiplique cada número sorteado por “+4″;
  5. Role 5 dados de 6 faces, adicione um “+5″ nos resultados e multiplique cada número por “+4″;
  6. Role 5 dados de 6 faces e adicione nos resultados o retorno da multiplicação de 5 por 4.

E não parei por aí; além de permitir a simples rolagem de dados, a DSL também devia fornecer algumas formas de se obter informações sobre um determinado conjunto de resultados. Por exemplo:

  1. 10.d20.highest
  2. 5.d10.lowest.d6
  3. (6.d12 - 2).sum
  4. 5.d20.average
  5. (2.d10 + 6.d).best(3)
  6. (2.d10 + 6.d).worst(3)

Novamente, cada uma das linhas acima podem ser traduzidas para:

  1. Role 10 dados de 20 faces e retorne o maior número que foi sorteado;
  2. Role 5 dados de 10 faces, pegue o menor número que foi sorteado e role dados de 6 faces por esse número de vezes;
  3. Role 6 dados de 12 faces, adicione um “-2″ e retorne a soma dos resultados;
  4. Role 5 dados de 20 faces e retorne a média dos números sorteados;
  5. Role 2 dados de 10 faces e 6 dados de 6 faces, e retorne os 3 melhores números sorteados;
  6. Role 2 dados de 10 faces e 6 dados de 6 faces, e retorne os 3 piores números sorteados.

Uma das características mais marcantes em DSLs bem projetadas é que tais linguagens podem ser facilmente entendidas por especialistas do domínio, mesmo se tais especialistas não tiverem nenhum conhecimento em programação.

Para ilustrar essa afirmação, podemos dizer sem medo de errar que uma instrução Groovy new DiceRollingSpec(sides:4).roll(5) pode não significar absolutamente nada para a maioria dos jogadores de RPG. Entretanto, se você já jogou RPG alguma vez na vida, sabe que uma instrução 5.d4 é facilmente entendida por qualquer jogador.

Preparado para o código? Então vamos nessa. :)

Primeiro: resolvendo o problema… a qualquer custo

Antes de enveredarmos na criação da DSL, precisamos criar código que nos forneça a funcionalidade necessária, que é rolar dados e colher informações sobre os mesmos.

Como o código é extremamente simples, não precisarei ficar explicando. Basta conhecer o básico da linguagem Groovy para entender. Veja só:

  1. class DiceRollingSpec {
  2.     int sides = 6
  3.     def result = []
  4.    
  5.     def roll(n=1) {
  6.         n.times {
  7.             result << (int) (Math.random() * sides) + 1
  8.         }
  9.         this
  10.     }
  11.    
  12.     def getSum() {
  13.         result.sum()
  14.     }
  15.    
  16.     def getHighest() {
  17.         result.max()
  18.     }
  19.    
  20.     def getLowest() {
  21.         result.min()
  22.     }
  23.    
  24.     def getAverage() {
  25.         (int) (result.sum() / result.size)
  26.     }
  27.    
  28.     def best(n=1) {
  29.         n = n > 0 ? n : 1
  30.         result.sort().reverse()[n == 1 ? 0 : (0..(n-1))]
  31.     }
  32.    
  33.     def worst(n=1) {
  34.         n = n > 0 ? n : 1
  35.         result.sort()[n == 1 ? 0 : (0..(n-1))]
  36.     }
  37.    
  38.     def plus(value) {
  39.         if (value instanceof Integer) {
  40.             return new DiceRollingSpec(sides:sides,
  41.                 result:result.collect{it} + value)
  42.         }
  43.         else if (value instanceof DiceRollingSpec) {
  44.             return new DiceRollingSpec(sides:sides > value.sides ? sides : value.sides,
  45.                 result:result + value.result)
  46.         }
  47.         throw new IllegalArgumentException("Invalid argument: $value")
  48.     }
  49.    
  50.     def minus(value) {
  51.         if (value instanceof Integer) {
  52.             return plus(-value)
  53.         }
  54.         else if (value instanceof DiceRollingSpec) {
  55.             return new DiceRollingSpec(sides:sides, result:result - value.result)
  56.         }
  57.         throw new IllegalArgumentException("Invalid argument: $value")
  58.     }
  59.    
  60.     def multiply(value) {
  61.         if (value instanceof Integer) {
  62.             return new DiceRollingSpec(sides:sides,
  63.                 result:result.collect{it * value})
  64.         }
  65.         throw new IllegalArgumentException("Invalid argument: $value")
  66.     }
  67. }

Com exceção dos três últimos métodos, o resto é extremamente simples de entender. Na verdade, os três últimos métodos sobrescrevem os operadores +, - e * para objetos DiceRollingSpec. Isso permite que objetos dessa classe sejam usados em alguns tipos de expressões como as citadas anteriormente. Um exemplo de expressão válida:

  1. new DiceRollingSpec().roll(4) + new DiceRollingSpec(sides:10).roll(7) // 4.d + 7.d10

No entanto, esse código não possui a expressividade que gostaríamos de ver numa API de rolagem de dados.

Segundo: adicionando expressividade à solução

Como vimos, o ponto de partida para se especificar rolagens de dados se parece com o seguinte:

  1. I.dX

I é um objeto Integer qualquer e indica o número de vezes que o dado deve ser lançado; dX indica o tipo de dado, que é codificado como sendo um método getter getDX(), onde X é o número de faces do dado.

Agora, qual a razão de eu ter escolhido usar um método getter a um método comum? Veja só o exemplo abaixo:

  1. 5.d12()

Infelizmente, o Groovy exige a digitação do “abre-e-fecha-parênteses” ao invocar métodos sem parâmetros. Como a idéia era representar o mais fielmente possível a forma com que jogadores de RPG especificam rolagens de dados, esse “abre-e-fecha-parênteses” acaba sujando o código e prejudicando sua expressividade; eu particularmente nunca vi alguém dizer “beleza, agora eu vou rolar cinco dê-doze abre-e-fecha-parênteses… esse monstro vai ver só uma coisa!”. :P

Mas beleza… agora que sabemos como deverá funcionar, precisamos arranjar um jeito de retornar objetos DiceRollingSpec sempre que getters getDX() forem invocados em objetos Integer. Felizmente, o Groovy fornece uma forma bem fácil de se fazer isso:

  1. Integer.metaClass.propertyMissing = { name ->
  2.  
  3.     if (name == ‘d’) {
  4.         name = ‘d6′ // 6-sided dice is the default dice type
  5.     }
  6.    
  7.     /* intercept calls to ‘dX’ properties */
  8.     if (name =~ /^d\d+$/) {
  9.         return new DiceRollingSpec(sides:Integer.parseInt(name.substring(1)))
  10.             .roll(value)
  11.     }
  12.    
  13.     throw new MissingPropertyException(
  14.         "No such property: $name for class: ${value.class}")
  15. }

O Groovy fornece diversas soluções para modificar objetos e classes em runtime; a que parece mais adequada ao nosso problema é interceptar chamadas a getters getDX() e retornar objetos DiceRollingSpec correspondentes.

Outra solução — não muito boa — seria adicionar diversos métodos getters, um para cada tipo de dado. Embora tal solução seja bem mais “leve” que a solução escolhida, ela possui limitações no que diz respeito aos tipos de dados suportados; a primeira solução permite rolar qualquer tipo de dado enquanto que a segunda só suportaria determinados tipos de dados.

Falta alguma coisa?

Não! Basta executar o trecho de código mostrado anteriormente para que objetos Integer possam responder a chamadas getDX(). Como as operações +, - e * foram sobrescritas para objetos DiceRollingSpec, expressões como as mostradas no início deste post funcionam perfeitamente!

Agora que já conhecemos o código, vamos ver como funciona a execução de uma rolagem de dados qualquer, passo-a-passo:

  1. (2.d10 + 6.d).best(3)
  1. O método getD10() é chamado no objeto Integer 2. Um novo objeto DiceRollingSpec(sides:10) é criado e seu método roll() é invocado com o parâmetro 2. Por exemplo, o resultado foi [8,3]. Tal objeto é retornado;
  2. O método getD() é chamado no objeto Integer 6. Um novo objeto DiceRollingSpec(sides:6) é criado e seu método roll() é invocado com o parâmetro 6. Por exemplo, o resultado foi [5,1,3,6,5,2]. Tal objeto é retornado;
  3. Os objetos retornados nos passos 1 e 2 são somados num novo objeto DiceRollingSpec, resultando em [8,3,5,1,3,6,5,2];
  4. O método best(3) é chamado no objeto DiceRollingSpec criado no passo anterior, retornando o array [8,6,5].

Legal, não?! Fique tranqüilo se algum ponto ficou meio difícil de entender. O ideal seria ver a coisa rodando na sua frente e, por esse motivo, eu disponibilizei o código-fonte para download na página de aplicações aqui do blog. Sinta-se livre para baixar e brincar com o código, que, além de muitas linhas de comentários, possui um caso de testes que mostra detalhadamente o funcionamento da DSL.

Have fun!

UPDATE: Este post levou a criação de um projeto de software livre cujos detalhes podem ser conferidos aqui.

Tags: , , , , , , , , ,