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
dd do |card|
card.value % 2 != 0
end
get :black, :black
get
dd
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 & para expor o bloco. O significado de & é 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 & 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 & 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 & 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
dd do |card|
card.value % 2 != 0
end
get :black, :black
get
dd
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.
