Domain Specific Languages: Uma implementação simples

January 9th, 2008 § 0 comments

Este é o terceiro artigo em uma série sobre DSL, ou Linguagens Específicas de Domínio. O primeiro artigo introduziu o assunto explicando o que é uma DSL, como elas podem melhorar e simplificar o código de uma aplicação e, finalmente, mostrando alguns exemplos usados pela maioria dos desenvolvedores. O segundo artigo mostrou o processo de transformação de um problema usual em um problema resolvido por uma DSL e a simplificação resultante da mudança.

Neste terceiro artigo, mostraremos como implementar uma DSL simples usando Ruby e quais são os passos necessários para que a mesma represente o intento do que estamos querendo fazer. Como esse é um exemplo inicial, somente os passos mais básicos da criação de um DSL serão mostrados. Em artigos posteriores, mostraremos outras técnicas mais avançadas.

A DSL que criaremos será baseada na descrição de regras do meu jogo de cartas preferido, Eleusis. Eleusis é um jogo que não depende de qualquer tipo de sorte e procura simular o processo indutivo de descobertas científicas. Funciona bem para grupos de três a oito pessoas embora o grupo ideal esteja entre quatro e cinco jogadores.

Em linhas gerais as regras são as seguintes:

  • A cada rodada, um jogadores diferente é o carteador. O jogo termina quando todos jogadores tiverem passado pelo papel de carteador;
  • O carteador inventa uma regra secreta que os outros jogadores devem descobrir;
  • Cada jogador que não o carteador recebe quatorze cartas iniciais;
  • O carteador coloca a primeira carta do jogo, que deve atender à primeira carta de sua regra;
  • Os outros jogadores se alteram colocando uma carta que supõem atender a regra;
  • Se acertarem, a carta fica após as outros cartas já jogadas;
  • Se errarem, são penalizados com duas cartas extras, e a carta errada é colocada perpendicularmente à ultima carta jogada;
  • A rodada termina quando um dos jogadores fica sem cartas (supostamente porque descobriu a regra e conseguiu descartar todas as castas que tinha em mãos);
  • O jogo termina quando todos jogadores tiverem ocupado a posição de carteador.

As regras inventadas podem variar de simples a complexas. Uma regra simples seria, por exemplo, jogar uma carta de copas seguida por uma de ouros seguida por uma de paus seguida por uma de espadas e assim por diante. Uma regra complexa seria jogar uma carta vermelha se a última foi par ou preta se a última foi ímpar. Uma bom carteador sabe equilibrar a complexidade de suas regras.

Mas, chega de explicar o jogo. A DSL que criaremos será uma linguagem para descrever as regras que podem ser criadas no jogo, descrevendo o intento da mesma através de construções simples. A regra também terá um método que, ao receber um grupo de cartas, dirá se esse grupo se conforma à regra.

Uma regra de exemplo poderia ser algo da forma abaixo:

r1 = rule "Rule 1" do
  
  group :black, :is => [:clubs, :spades]
  group :red, :is => [:diamonds, :hearts]
  
  group :even do |card|
    card.value % 2 == 0
  end
  
  group :odd do |card|
    card.value % 2 != 0
  end

  get :black, :black
  get :odd
  get :red, :red, :red
  get :even
  
end

Para fins desse exercício não usaremos nenhum grupo pré-definido ou suportaremos regras com recombinações, ou seja, regras que dependem de valores anteriores de cartas jogadas.

Depois que a regra está definida, ela pode ser usada para verificar um grupo qualquer de cartas. Por exemplo, poderíamos usar algo assim:

r1.run("2 of clubs", "3 of spades", 
       "ace of clubs", "2 of hearts")

Ao rodar esse teste, seremos informados se as cartas atendem à regra ou qual a primeira delas a falhar e o por quê.

Como você pode perceber ao ler o trecho que código que representa a regra, a mesma soa quase como uma descrição textual do que está acontecendo. Obviamente, como a sintaxe de uma linguagem qualquer não pode ser completamente flexível, algumas partes da definição não serão tão bem especificadas, mas o ganho em legibilidade é óbvio.

Da mesma forma, o código acima atende aos critérios de uma DSL:

  • Ele resolve um único problema, a descrição de uma regra do jogo
  • Ele reduz o foco do problema ao descrever a regra de uma forma mais intuitiva
  • Ele esconde o código complexo por trás de métodos simples

A criação da DSL em sim pode ser agora quebrada em alguns passos simples. Antes de começar, assumiremos, porém, que a seguinte classe representa as cartas que são usadas pela regra (partes do código foram formatadas diferentemente do comum em Ruby para se encaixar melhor do texto):

SUITS = [:clubs, :diamonds, :spades, :hearts].freeze

FACES = [:ace, :two, :three, :four, :five, :six, :seven, :eight,
         :nine, :ten, :jack, :queen, :king].freeze

class InvalidCardError < StandardError; end

class Card

  include Comparable

  def initialize(face, suit)
    @value = FACES.index(face) + 13 * SUITS.index(suit)
  rescue
    raise InvalidCardError
  end

  def face
    FACES[@value % 13]
  end

  def suit
    SUITS[@value / 13]
  end
  
  def value
    FACES.index(face) + 1
  end

  def eql?(card)
    @value == card.instance_variable_get(:@value)
  end

  def <=>(card)
    @value <=> card.instance_variable_get(:@value)
  end
  
  def to_s
    "#{face} of #{suit}"
  end

  def self.all
    @@all_cards ||= SUITS.inject([]) { |m, s| 
      m + FACES.collect { |f| Card.new(f, s) } 
    }
  end
  
  def self.from_string(spec)
    spec.downcase!
    if spec.length == 2
      parts = spec.split(//)
    else
      parts = spec.split("of").collect { |p| p.strip }
    end
    if parts.first =~ /\d+/
      face = FACES[parts.first.to_i - 1]
    elsif ["a", "j", "q", "k"].include?(parts.first)
      face = FACES.detect { |face| face.to_s[0,1] == spec[0,1] }
    else
      face = parts.first.to_sym
    end
    if parts.last.length == 1
      suit = SUITS.detect { |suit| suit.to_s[0,1] == parts.last }
    else
      suit = parts.last.to_sym
    end
    Card.new(face, suit)
  end

end

Queremos representar a nossa regra com uma classe que possa ser passada adiante e cujos métodos possam ser acessados por qualquer código interessado nos mesmos.

Nosso código inicial, então, seria algo assim:

require "card"

class Rule
  
  def initialize(name)
  end
  
  def group(name, items = nil)
  end
  
  def get(*groups)
  end
  
  def run(*specs)
    puts "Not implemented"
  end
  
end

r1 = Rule.new("Rule 1")

r1.group(:black, [:clubs, :spades])
r1.group(:red, [:diamonds, :hearts])

r1.group(:even) do |card| 
  card.value % 2 == 0
end

r1.group(:odd) do |card| 
  card.value % 2 != 0
end

r1.get(:black, :black)
r1.get(:odd)
r1.get(:red, :red, :red)
r1.get(:even)

r1.run("2 of clubs", "3 of spades", 
       "ace of clubs", "2 of hearts")

Ainda não tem muito a ver com o que queremos, mas já é um começo. Podemos fazer o código funcionar implementado os métodos necessários.

O método initialize ficaria assim, inicializando as variáveis internas da classe:

class Rule
  
  def initialize(name)
    @name = name
    @groups = Hash.new([])
    @path = []    
  end

  # ...

end

O próximo método é get, igualmente simples, guardando os grupos que serão rotacionados para formar a regra:

class Rule
  
  def get(*groups)
    @path += groups
  end

  # ...

end

O método group é um pouco mais elaborado já que envolve duas interfaces diferentes:

class Rule
  
  def group(name, items = nil)
    if items.kind_of?(Array)
      @groups[name] = []
      items.each do |group|
        @groups[name] += Card.all.select { |card|
          (card.suit == group) ||
          (card.face == group) 
        }
      end
    elsif block_given?
      @groups[name] = Card.all.select { |card| yield card }
    end    
  end

  # ...

end

A primeira parte do método verifica se o parâmetros items foi passado. Se o mesmo é um Array contendo uma combinação de naipes ou faces, o código procura no grupo de todas as cartas possíveis as que possuem o naipe ou face passado. As cartas recuperadas são então guardadas como um grupo específico. Se o parâmetro item não foi passado e um bloco existe, o bloco é usado sobre o mesmo grupo de todas as cartas para escolher aquelas que atendem ao critério do bloco.

Finalmente temos o método run que define o processamento da regra em si:

class Rule
  
  def run(*specs)
    cards = specs.collect { |spec| Card.from_string(spec) }
    rules = cards.zip(@path * ((cards.length / @path.length) + 1))
    rules.each_with_index do |pair, i|
      unless @groups[pair.last].include?(pair.first)
        puts "Rule failed at item #{i + 1}: " +
        "#{pair.first} did't match group #{pair.last.to_s}"
        return
      end
    end
    puts "Rule matched all cards."
  end

  # ...

end

A implementação segue a seguinte lógica:

  • As descrições das cartas passadas são convertidas nas cartas equivalentes.
  • Essas cartas são pareadas com as posições equivalentes na regra. Como a regra pode ser menor do que o número de cartas passadas, as regras são duplicadas até que forem uma lista com o mesmo comprimento das cartas. O método zip é usado então para fazer o pareamento final.
  • Para cada par, a carta é verificada para ver se existe dentro do grupo pareado. Se a carta existe, o processamento continua. Se não, o processamento é abortado com a mensagem necessária.

Se você roda o programa agora, verá que o mesmo funciona e que você pode criar regras diferentes e verificá-las contra qualquer conjunto de cartas.

O objetivo agora é transformar o código acima em algo mais legível, que se aproxime mais de uma linguagem de definição de regras.

Esse processo é surpreendentemente simples. O primeiro passo é executar o código de definição dentro de um bloco. Por mais trivial que isso seja, a mudança dá a impressão de agrupamento ao código e o torna mais interessante. A mudança necessária é a seguinte:

class Rule
  
  def initialize(name)
    @name = name
    @groups = Hash.new([])
    @path = []    
    yield self
  end

  # ...

end

O método initialize agora requer um bloco que pode ser usado para encapsular as chamadas aos métodos internos da classe. Nesse ponto pode ser interessante mover os métodos get e group para a seção protected para evitar acesso externo aos mesmos. A chamada a yield invoca o bloco passado ao método e dá ao mesmo como parâmetro a própria instância sendo criada.

Com a mudança acima, a criação da regra pode ser mudada para:

r1 = Rule.new("Rule 1") do |r|

  r.group(:black, [:clubs, :spades])
  r.group(:red, [:diamonds, :hearts])

  r.group(:even) do |card| 
    card.value % 2 == 0
  end

  r.group(:odd) do |card| 
    card.value % 2 != 0
  end

  r.get(:black, :black)
  r.get(:odd)
  r.get(:red, :red, :red)
  r.get(:even)

  r.run("2 of clubs", "3 of spades", 
    "ace of clubs", "2 of hearts")
      
end

Já podemos ver que o código se assemelha mais a uma linguagem separada embora o uso do parâmetro não seja tão interessante.

Para conseguir eliminar o parâmetro precisamos de um pouco de mágica que consiste em invocar o código dentro do bloco como se ele fosse parte da própria classe. Embora yield também invoque o bloco, a invocação é realizada no contexto em que o bloco foi criado, ou seja, fora da classe. Se conseguirmos invocar o bloco dentro da classe, não precisaremos referenciar a instância e poderemos remover o código desnecessário.

class Rule
  
  def initialize(name, &block)
    @name = name
    @groups = Hash.new([])
    @path = []    
    instance_eval(&block)
  end

  # ...

end

Para entender a mudança, precisamos entender duas outras coisas. Primeiro, que block é uma parâmetro de bloco. Em Ruby, blocos de código são considerados variáveis passíveis de manipulação tanto como qualquer outra valor primitivo ou instâncias de classe. De fato, um bloco é nada mais que uma instância da classe Proc. Como tal, possuem não só o código associado como métodos que podem ser invocados sobre esse código. Para explicar isso, veja o seguinte código:

p = Proc.new { |a, b| a + b }
puts p.call(1, 2)

O código acima criar um bloco que recebe dois parâmetros e devolve a sua soma. Logo em seguida o bloco é invocado com dois parâmetros e imprime o resultada do invocação. Como é fácil perceber, blocos também são variáveis de primeira classe tanto quanto qualquer outro tipo. Sendo assim, eles podem ser passados como parâmetros que é exatamente o que estamos fazendo em nosso código.

Como blocos geralmente são passados como parâmetros escondidos, pelo uso das palavras-chave do...end ou {...}, o Ruby usa &amp; para expor o bloco. O significado de &amp; é o seguinte: pegue o bloco de código passado como parâmetro para o método, coloque-o na variável cujo nome está após o &amp; e não o execute até que o mesmo seja invocado implicitamente. O método yield, ao contrário, não precisa da variável e simplesmente executa o bloco quando usado.

No caso do método initialize estamos pegando esse bloco passado como parâmetro e transferindo sua execução para o método instance_eval. Esse método faz exatamente o que diz: recebe um bloco de código e o executa como se o bloco fosse local à classe. Mais uma vez precisamos de &amp; para indicar que o bloco será passado como parâmetro e não explicitamente através de do...end ou {...}.

Com essa mudança, nossa regra fica assim:

r1 = Rule.new("Rule 1") do

  group(:black, [:clubs, :spades])
  group(:red, [:diamonds, :hearts])

  group(:even) do |card| 
    card.value % 2 == 0
  end

  group(:odd) do |card| 
    card.value % 2 != 0
  end

  get(:black, :black)
  get(:odd)
  get(:red, :red, :red)
  get(:even)

  run("2 of clubs", "3 of spades", 
     "ace of clubs", "2 of hearts")
      
end

Estamos agora bem próximos do resultado final. Precisamos apenas de alguns ajustes.

Podemos, para começar esses ajustes, criar um método para envolver a chamada à classe e simplesmente retorná-la. Fazemos isso com:

def rule(name, &block)
  Rule.new(name, &block)
end

Note mais uma vez o uso de &amp; para passar o bloco adiante. Usando esse método, o bloco será passado três vezes como parâmetro até sua execução final.

Esse método deixa a regra assim:

r1 = rule("Rule 1") do

  group(:black, [:clubs, :spades])
  group(:red, [:diamonds, :hearts])

  group(:even) do |card| 
    card.value % 2 == 0
  end

  group(:odd) do |card| 
    card.value % 2 != 0
  end

  get(:black, :black)
  get(:odd)
  get(:red, :red, :red)
  get(:even)

  run("2 of clubs", "3 of spades", 
     "ace of clubs", "2 of hearts")
      
end

Esse é basicamente o resultado que queríamos. Os dois últimos ajustes farão o resto. Um ajuste no método group para usar um Hash pode deixá-lo mais livre:

class Rule
  
  def group(name, options = {})
    if options[:is].kind_of?(Array)
      @groups[name] = []
      options[:is].each do |group|
        @groups[name] += Card.all.select { |card|
          (card.suit == group) ||
          (card.face == group) 
        }
      end
    elsif block_given?
      @groups[name] = Card.all.select { |card| yield card }
    end    
  end

  # ...

end

E finalmente, podemos remover os parênteses desnecessários da regra, que fica assim:

r1 = rule "Rule 1" do

  group :black, :is => [:clubs, :spades]
  group :red, :is => [:diamonds, :hearts]

  group :even do |card| 
    card.value % 2 == 0
  end

  group :odd do |card| 
    card.value % 2 != 0
  end

  get :black, :black
  get :odd
  get :red, :red, :red
  get :even

end

r1.run("2 of clubs", "3 of spades", 
      "ace of clubs", "2 of hearts")

Finalmente temos o resultado que queríamos. Como é fácil perceber pelos passados dados, o grosso da transformação envolveu o uso de blocos para esconder as chamadas.

Basicamente qualquer DSL mais simples pode usar uma estratégia similar. De fato, a maioria das DSL encontradas como exemplo usam exatamente esse tipo de código, embora o mesmo tenha alguns problemas de integração com testes, especialmente no que tange ao uso de ferramentas como RSpec.

No próximo artigo veremos dois outros exemplos da aplicação de DSL usando algumas técnicas diferentes para criar possibilidades ainda mais interessantes.

Leave a Reply

Your email address will not be published. Required fields are marked *

What's this?

You are currently reading Domain Specific Languages: Uma implementação simples at Superfície Reflexiva.

meta