Un Lenguaje de Dominio Específico para Describir Recetas de Cocina

Supongamos que queremos crear una Clase Recipe cuyo constructor entienda un Lenguaje de Dominio Específico (Domain Specific Language, DSL) para describir recetas de cocina. Queremos que sea posible especificar con facilidad los ingredientes y los pasos de la forma mas simple posible. La API debería ser parecida a esta:

puts Recipe.new("Noodles and Cheese") {
  ingredient "Water",   amount => "2 cups"
  ingredient "Noodles", amount => "1 cup"
  ingredient "Cheese",  amount => "1/2 cup"

  step "Heat water to boiling.",        during => "5 minutes"
  step "Add noodles to boiling water.", during => "6 minutes"
  step "Drain water."
  step "Mix cheese in with noodles."
}

y el resultado de ejecutar el código anterior debería ser similar a este:

~/Chapter8ReflectionandMetaprogramming$ ruby recipes.rb 
Noodles and Cheese
==================

Ingredients: Water (2 cups), Noodles (1 cup), Cheese (1/2 cup)

1) Heat water to boiling. (5 minutes)
2) Add noodles to boiling water. (6 minutes)
3) Drain water.
4) Mix cheese in with noodles.

Como se ve en el ejemplo de uso, el constructor de la clase recibe un bloque que describe la receta. Una solución es usar yield para llamar al bloque:

~/Chapter8ReflectionandMetaprogramming$ cat -n recipes2.rb 
     1  class Recipe2
     2    attr_accessor :name, :ingredients, :instructions
     3  
     4    def initialize(name)
     5      self.name = name
     6      self.ingredients = []
     7      self.instructions = []
     8  
     9      yield self
    10    end
    11  
    12    def to_s
    13      output = name
    14      output << "\n#{'=' * name.size}\n\n"
    15      output << "Ingredients: #{ingredients.join(', ')}\n\n"
    16  
    17      instructions.each_with_index do |instruction, index|
    18        output << "#{index + 1}) #{instruction}\n"
    19      end
    20  
    21      output
    22    end
    23  
    24    def ingredient(name, options = {})
    25      ingredient = name
    26      ingredient << " (#{options[:amount]})" if options[:amount]
    27  
    28      ingredients << ingredient
    29    end
    30  
    31    def step(text, options = {})
    32      instruction = text
    33      instruction << " (#{options[:during]})" if options[:during]
    34  
    35      instructions << instruction
    36    end
    37  end
Los métodos ingredient y step nos ayudan en el proceso de construcción de la cadena de presentación.

Sin embargo, esta solución no es satisfactoria, ya que debemos pasar al bloque de definición el objeto receta que esta siendo construido:

    39  noodles_and_cheese = Recipe2.new("Noodles and Cheese") do |r|
    40    r.ingredient "Water",   :amount => "2 cups"
    41    r.ingredient "Noodles", :amount => "1 cup"
    42    r.ingredient "Cheese",  :amount => "1/2 cup"
    43  
    44    r.step "Heat water to boiling.",        :during => "5 minutes"
    45    r.step "Add noodles to boiling water.", :during => "6 minutes"
    46    r.step "Drain water."
    47    r.step "Mix cheese in with noodles."
    48  end
    49  
    50  puts noodles_and_cheese
Peor aún, debemos repetir constantemente el objeto r.ingredient y r.step. Sabemos que instance_eval evalúa el bloque que se le pasa en el contexto del objeto en el que es llamado (self en la línea 9 abajo):

~/Chapter8ReflectionandMetaprogramming$ cat -n recipes.rb 
     1  class Recipe
     2    attr_accessor :name, :ingredients, :instructions
     3  
     4    def initialize(name, &block)
     5      self.name = name
     6      self.ingredients = []
     7      self.instructions = []
     8  
     9      instance_eval &block
    10    end
    11  
    12    def to_s
    13      output = <<"EORECIPE"
    14  #{name}
    15  #{'=' * name.size}
    16  
    17  Ingredients: #{ingredients.join(', ')}
    18  
    19  #{
    20      out = ""
    21      instructions.each_with_index do |instruction, index|
    22        out << "#{index + 1}) #{instruction}\n"
    23      end
    24      out
    25  }
    26  EORECIPE
    27    end
    28  
    29    def ingredient(name, options = {})
    30      ingredient = name
    31      ingredient << " (#{options[:amount]})" if options[:amount]
    32  
    33      ingredients << ingredient
    34    end
    35  
    36    def step(text, options = {})
    37      instruction = text
    38      instruction << " (#{options[:for]})" if options[:for]
    39  
    40      instructions << instruction
    41    end
    42  
    43    def amount 
    44     :amount 
    45    end
    46  
    47    def during 
    48     :for
    49    end
    50  end
    51  
    52  puts Recipe.new("Noodles and Cheese") {
    53    ingredient "Water",   amount => "2 cups"
    54    ingredient "Noodles", amount => "1 cup"
    55    ingredient "Cheese",  amount => "1/2 cup"
    56  
    57    step "Heat water to boiling.",        during => "5 minutes"
    58    step "Add noodles to boiling water.", during => "6 minutes"
    59    step "Drain water."
    60    step "Mix cheese in with noodles."
    61  }

Casiano Rodriguez León 2015-01-07