Domain Specific Languages: Implementando mágica

January 10th, 2008 § 0 comments

Este é o quarto 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 e como elas podem melhorar e simplificar o código de uma aplicação. O segundo artigo mostrou o processo de transformação de um problema usual em um problema resolvido por uma DSL. Finalmente, o terceiro artigo mostrava a implementação de uma DSL simples, usando as características básicas de meta-programação do Ruby.

Neste quarto artigo, mostraremos como implementar uma DSL um pouco mais complexa usando características adicionais de meta-programação do Ruby como a method_missing e proxies. Os exemplos serão necessariamente simples para não tornar o artigo muito extenso mas mostrarão o que é preciso para utilizar tais.

O problema

Para demonstrar as características acima, vamos utilizar o mesmo problema de definir regras para um jogo de Eleusis mas com o seguinte formato:

r1 = rule "Rule 1" do
  
  black means { its.suit.is_clubs | its.suit.is_spades }
  red means { its.suit.is_diamonds | its.suit.is_hearts }
  
  even means { its.value.is_even }
  odd means { its.value.is_odd }

  odd after { black & black }
  even after { red & red & red }
  
end

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

Note que agora estamos usando uma linguagem ainda mais próxima de uma definição verbal. Obviamente, vários elementos do próprio Ruby permanecem já que existem limites para o que pode ser modificado na linguagem básica.

Esse exemplo, como o anterior, não é capaz de processar todas as regras possíveis do jogo, e não possui qualquer recuperação de erro. Apesar disso, ele serve para ilustrar os conceitos que podem tornar uma DSL ainda mais efetiva na representação de seu problema.

Implementação

Como a classe Card que declaramos no arquivo anterior não sofreu qualquer mudança, não reproduziremos o seu código aqui.

Um detalhe de implementação geralmente associado com o desenho de qualquer DSL que faz uso de proxies ou interceptação de métodos é uma espécie de classe que chamamos de blank slate. Essas classes não possuem qualquer método de instância além do básico para o seu funcionamento interno dentro do Ruby.

O uso dessas classes atende dois propósitos dentro do desenho de uma DSL. Primeiro, permite identificar de uma forma mais rápida e precisa erros de interceptação de métodos, especialmente quando operadores estão sendo sobrepostos. Segundo, permite limpar quaisquer métodos adicionais que tenham sido acrescentados por outras bibliotecas reduzindo o risco de colisões.

A implementação que usaremos para a nossa class blank slate é a seguinte:

class BlankSlate
  
  KEEPER_METHODS = %w(__id__ __send__ inspect 
    class instance_eval hash)
  
  (instance_methods - KEEPER_METHODS).each do |v|
    next if v[-1] == ?? || v[-1] == ?!
    undef_method(v)
  end
  
end

Com exceção dos métodos essenciais, a classe acima não define qualquer outro método de instância. Sendo assim, qualquer classe derivada da mesma começara somente com os métodos mais básicos possíveis.

Com essa classe podemos começar a nossa implementação. O nosso arquivo básico, derivado do exemplo anterior e com as modificações necessárias da DSL, é o seguinte:

require "card"

class BlankSlate
  
  KEEPER_METHODS = %w(__id__ __send__ inspect
    class instance_eval hash)
  
  (instance_methods - KEEPER_METHODS).each do |v|
    next if v[-1] == ?? || v[-1] == ?!
    undef_method(v)
  end
  
end

class Rule < BlankSlate
  
  def initialize(name, &block)
    @name = @name
    @groups = {}
    @paths = []
    instance_eval(&block)
  end

  def run(*specs)
    cards = specs.collect { |spec| Card.from_string(spec) }
    rules = cards.zip(@paths * ((cards.length / @paths.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

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

r1 = rule "Rule 1" do
  
  black means { its.suit.is_clubs | its.suit.is_spades }
  red means { its.suit.is_diamonds | its.suit.is_hearts }
  
  even means { its.value.is_even }
  odd means { its.value.is_odd }

  odd after { black & black }
  even after { red & red & red }
  
end

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

O primeiro passo agora é implementar o método means que define as cartas que compõem uma regra. Esse método recebe um bloco com um método interno chamada its que pode ser usado para definir conjuntos de cartas através de expressões simples.

Podemos ter, como mostrado acima, black means { its.suit.is_clubs | its.suit.is_spades } para dizer “preto significa que seu naipe é copas ou espadas” e assim por diante. E, como veremos no resto da implementação, a sintaxe permite o uso de | e & para representar, respectivamente, o operador lógico ou e o operador lógico e.

A implementação do método é simples, sendo simplesmente uma delegação para a execução do próprio bloco:

class Rule < BlankSlate
  
  # ...
  
  protected

   def means(&block)
     instance_eval(&block)
   end
  
end

A execução real acontece através do método its, que é implementado assim:

class Rule < BlankSlate
  
  # ...
  
  protected

  def its
    FilterProxy.new
  end
  
end

Mais uma vez, temos uma delegação, agora para uma classe que servirá com um proxy para a invocação dos métodos necessários. Essa classe é implementada como descrito abaixo:

class FilterProxy < BlankSlate
  
  attr_reader :cards
  
  def |(proxy)
    @cards += proxy.cards
    self
  end
  
  def &(proxy)
    @cards.reject! { |card| !proxy.cards.include?(card) }
    self
  end

  protected
  
  def method_missing(name, *args)
    if [:suit, :face, :value].include?(name)
      @message = name
      self
    elsif name == :is_any_of
      @cards = Card.all.select { |card| 
        args.include?(card.send(@message)) 
      }
      self
    elsif name.to_s =~ /^is_(.*)$/
      if $1 == "odd"
        @cards = Card.all.select { |card| 
          card.send(@message) % 2 != 0 
        }
      elsif $1 == "even"
        @cards = Card.all.select { |card| 
          card.send(@message) % 2 == 0 
        }
      else
        @cards = Card.all.select { |card| 
          card.send(@message) == $1.to_sym 
        }
      end
      self
    else
      super
    end
  end
  
end

A classe FilterProxy possui duas atribuições: implementar a união e interseção de regras–através dos operadores & e |–e interceptar as mensagens emitadas na criação de critérios de grupo.

A união e interseção são simples. Elas são feitas entre duas instâncias da classe FilterProxy e retornam a primeira delas, alterada com a operação em questão. Como estamos descartando os objetos no bloco, isso não é um problema embora talvez seja mais interessante implementar a criação de uma classe pura se termos a intenção de filtrar mais extensivamente. Como estamos implementando uma seqüência simples, não vamos nos importar com esse detalhe agora.

O método method_missing é que implementa o grosso do trabalho, interceptando os métodos que são necessários para fazer os filtros funcionarem.

A primeira condição verifica se a mensagem é uma que a classe Card response. Se sim, a mensagem é salva para o próximo passo.

A segunda condição verifica se a mensagem é o método is_any_of que, com base na mensagem anteriormente salva e uma série de possíveis respostas, verifica quais cartas atendem a esse critério.

Finalmente, mensagens no formato is_<name> (como is_odd ou is_clubs) são implementadas de maneira similar, com um bloco que verifica se a resposta necessária atende a mensagem que foi passada.

Caso a mensagem não seja interceptada, o comportamento padrão é invocado, delegando a execução para a classe pai que, no caso, simplesmente faz o tratamento de erros.

A próxima implementação é o próprio método method_missing na class Rule, que lidará com os nomes dos grupos que estão sendo declarados. Essa implementação inicial fica da seguinte forma:

class Rule < BlankSlate
  
  # ...
  
  protected

  def method_missing(name, *args)
    if args.first.kind_of?(FilterProxy)
      @groups[name] = args.first.cards
    else
      super
    end
  end
  
end

Essa primeira implementação verifica se o primeiro argumento passado para uma mensagem qualquer é uma instância de FilterProxy. Caso seja, um grupo é definido com as cartas selecionadas pelo filtro. Qualquer outro desvio no processamento de mensagens gera um erro nesse momento.

Agora é hora de implementar o método after que também usa um bloco. A implementação é idêntica à do método means:

class Rule < BlankSlate
  
  # ...
  
  protected

  def after(&block)
    instance_eval(&block)
  end

end

Mais uma vez, estamos delegando o processamento à interceptação de mensagens. Para que isso funcione, precisamos alterar o método method_missing, que ficará da seguinte forma:

class Rule < BlankSlate
  
  # ...
  
  protected

  def method_missing(name, *args)
    if @groups[name]
      if args.size == 0
        GroupProxy.new(name)
      else
        @paths += args.first.sequence
        @paths << name
      end
    elsif args.first.kind_of?(FilterProxy)
      @groups[name] = args.first.cards
    else
      super
    end
  end

end

O código acima verificar se um grupo existe com aquela mensagem. Caso o grupo não exista, o processamento anterior é mantido. Se, entretanto, o grupo existir, temos duas respostas possíveis.

Primeiro, o método foi invocado sem argumentos. Isso significa que ele está sendo usado em uma definição da seção after, como pode ser visto pela regra. Nesse caso, um proxy é criado para tratar do processamento do bloco.

Segundo, o método foi invocado com argumentos. Isso significa que o mesmo está sendo usado como prefixo para uma regra after. Nesse caso, os argumentos são coletados para formar as regras que serão processadas. Como a regra define condições inversas, os grupos dentro do bloco são inseridos em primeiro lugar seguidos pelo grupo cujo nome é a própria mensagem.

A implementação da classe GroupFilter é trivial e pode ser vista abaixo:

class GroupProxy < BlankSlate
  
  attr_accessor :sequence
  
  def initialize(*sequence)
    self.sequence = sequence
  end
  
  def &(proxy)
    GroupProxy.new(*(@sequence + proxy.sequence))
  end
  
end

Temos duas situações possíveis. Primeiro, a inicialização, que simplesmente recebe uma série de nomes de grupos a ser mantida. Segundo, a união, que combina o conjunto atual com o conjunto sendo passado para formar um novo conjunto que é retornado em seguida.

Com isso nossa implementação está completa e pode ser utilizada na criação de regras razoavelmente complexas. É possível, por exemplo, definir grupos no seguinte formato:

black_and_even means { 
  (its.suit.is_clubs | its.suit.is_spades) & 
  its.value.is_even 
}

Como é fácil observar, é possível obter uma considerável expressividade mesmo com um código razoavelmente simples. Embora, obviamente, seja necessário muito mais do que isso para implementar qualquer regra Eleusis possível, o código acima já ilustra um caminho possível com uma legibilidade boa em relação ao domínio que a DSL trata.

Com isso encerramentos este exercício. No próximo artigo, algumas considerações finais sobre o que está sendo feito atualmente no campo e como resolver alguns problemas comum.

Leave a Reply

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

What's this?

You are currently reading Domain Specific Languages: Implementando mágica at Superfície Reflexiva.

meta