Saturday, April 29, 2006

Twisting and Shaping DSLs using Ruby Metaprogramming

I write this in response to Federico Feroldi's comment to my previous post on defining simple DSLs.

[..] while reading your article I was thinking about using the recipe name string as an object to route the messages coming from the ingredients and then catching them by implementing missing_method in the string object...
So, for:

recipe "italian breakfast" do
   caffe = 1, :macchiato
end

is there a way to translate it into:

"italian breakfast".caffe(1, :macchiato) ?
[..]

Let's see:

"italian breakfast".caffe 1, :macchiato

First of all, I removed the brackets because I don't believe in brackets :-)

What Federico proposes can be done, but I am not sure I would do it this way, unless I am working  only within the context of a DSL.  If you want your recipe-string to accept the caffe method, then you have to override the method_missing method of the String class, which can be a dangerous thing to do if you have loaded other meta-code that overrides this method on a String or Object level.

I would rather go for a Concept Wrap Up:

Recipe("italian breakfast").caffe  1, :macchiato

where I make explicit the meaning of 'italian breakfast' by wrapping it into a Recipe object.  Note that I did not use Recipe. new.  I am launching a campaign against the use of new in application code, which I consider at the same level of badly used hungarian notation (because there is such a thing as good hungarian notation).

Another way I would do it is by using what I call a Conjunction:

"italian breakfast".consists_of.caffe  1, :macchiato

You will have to inject a consists_of method in the String class, which will act as a conjunction between the String and the method call.  The conjunction will take the String and wrap it up in a Recipe object.  The cafe method is then invoked on the Recipe object.
   
In both cases we have moved the handling of the method_missing from String to a custom made Recipe class.

The conjunction will be:

class String

  #Recipe conjuction

  def consists_of

    Recipe .new self

  end

end


The Recipe will start as:

class Recipe

  def initialize recipe_name

    @name = recipe_name

    @ingredients = {}

    register_with_global_repository

  end

  def method_missing method, *args

    @ingredients[method .to_s] = args

  end

    ...

The method_missing is catching the ingredients and it stacks them nicely on an hashtable.  Why do we need the register_with_global_repository call, though?

Since we are working with a DSL and we are not explicitly storing a reference to a created recipes in a variable, then we get the Recipe to self-register itself with a global centralized repository.  I could have made it a @@all_recipes class attribute, but I think that in this simple example it looks clearer as an external separate entity:

$RECIPES_REPOSITORY = {}

class Recipe

 

  private

  def register_with_global_repository

    $RECIPES_REPOSITORY[ @name] = self

  end

end

and finally the code to print out the recipe:

class Recipe

 

  def report

    puts "to make #{@name} you should buy:" 

    @ingredients.each_pair do

      |ingredient,description|

      puts " * #{Array(description).join ' '} #{ingredient}"

    end

  end

   

end


What is still missing is a method to print out the recipe directly from the DSL.  We would like our call to look like this:

recipe? "italian breakfast"


This snippet fully satisfies our language choice:

def recipe? recipe_name

  $RECIPES_REPOSITORY[recipe_name].report

end

Our new DSL now allows us to write:

"italian breakfast".consists_of.caffe  1, :macchiato

recipe? "italian breakfast"    

and to obtain:

to make italian breakfast you should buy:
 * 1 macchiato caffe

The one big limitation is that at the moment you can define recipes with only one ingredient.

We get around this limitation by making the method_missing a Follow Up Method Chain:

def allow_method_chaining() self end

def method_missing method, *args

    @ingredients [method.to_s] = args

    allow_method_chaining

  end

The trick here is that after having defined an ingredients we still have the original Recipe object ready to accept new ingredients.

You can now do:

"italian breakfast".consists_of.

                    caffe(1 , :macchiato ).

                    cornetto(1, :cioccolato)

 

recipe? "italian breakfast"   


and get:

to make italian breakfast you should buy:
 * 1 cioccolato cornetto
 * 1 macchiato caffe


That was long! I think I will now go for a 1 cioccolato cornetto!


Comments:
I was thinking of taking yet another route:
(brackets, fur to non-meaningfulness of do..end in this case)

shopping_list_for "BLT sandwiches" {
three of sliced_bread
two of lettuce
six of tomatoes
four of bacon
}

This requires you to turn down $VERBOSE or you'll get warnings.
Obviously you can use add #of to Fixnum to sidestep the issue, or you can remove it completely.

Eliminating semicolons is easy once you evaluate the block in a context where method_missing is just returning the called symbol.
 
Brilliant! Your insightful explanation is extremely clear, I've just realized that I still have a lot to learn about the great Ruby! :)
 
Very inventive Gabriele!

so.. three, two and so on are implicit methods that get caught by method_missing. 'of' is a nil variable that doen't get used.. or did you miss some "." when copying the code here?

or is it supposed to be three(of(sliced_bread()) with a cascade of method_missings being called?

Can you give us a sample implementation?
 
I'm afraid that the parentheses and the trailing dot in:

"italian breakfast".consists_of.
caffe (1 , :macchiato ).
cornetto (1, :cioccolato)

...spoil most most of the good work before it. I'd probably go for
the usual instance_eval trick with something like:

"italian breakfast".consists_of {
caffe 1, :macchiato
cornetto 1, :cioccolato
}

recipe? "italian breakfast"

(Sample mess, I mean, implementation at the bottom.)

Also when playing with method missing you might want to start with a blank slate, someone might always decide to #freeze a cornetto or do
something we wouldn't expect with a #hash. :-)


require 'singleton'
class RecipeCollection
include Singleton

def self.<<(recipe)
instance << recipe
end

def self.lookup(title)
instance.lookup(title)
end

def <<(recipe)
@recipes ||= []
@recipes << recipe
end

def lookup(title)
@recipes.find { |r| r.title == title }
end
end

class Recipe
def initialize(title, &ingredients_desc)
@title, @ingredients = title, {}
instance_eval(&ingredients_desc)
RecipeCollection << self
end
attr_reader :title

def method_missing(id, *args)
@ingredients[id.to_s] = args
end

def to_s
@title + ":\n" +
@ingredients.collect do |name, description|
description.join(' ') + ' ' + name
end.join("\n")
end
end

class String
def consists_of(&ingredients_desc)
Recipe.new(self, &ingredients_desc)
end
end

def recipe?(name)
puts RecipeCollection.lookup(name)
end
 
How do you get rid of 'new', as mentioned at the top?

i.e.
Recipe("italian breakfast").caffe 1, :macchiato
 
How do you get rid of 'new', as mentioned at the top?

i.e.
Recipe("italian breakfast").caffe 1, :macchiato
 
###############
Killing the New
###############

def Recipe *args
Recipe.new *args
end

now you can just do Recipe(..) and you get a recipe. I call this a Concept Declarator.

In a future post I'll show how to do it semi-autmatically with metaprogramming.
 
Hi Hyperstruct, you are right. I pushed it too far and the trailing dot is not very DSL like.. I should have stopped just a little bit earlier.

BlankSlate is cool, it removes most methods from a class to give free rein to method_missing users. You are right, it is appropriate to use that too.
 
I'll provide a sample impl ASAP, sray tuned :)
 
Gabriele kicked my ass at: http://riffraff.blogsome.com/2006/05/02/metaprogramming-breakfast/
 
Although I understand most the "register_with_global_repository" self registry confuses me a little, and could you use this type of programme under any naming syntax.
 
I must admit the registry confuses me too, how and why would it self register, does it need to do this.
 
Post a Comment



<< Home

This page is powered by Blogger. Isn't yours?