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/
 
mortgage refinancing
refinance
home mortgage refinance
refinance loan
auto refinance
refinance mortgage loan
bad credit refinance
refinance rate
home mortgage refinance loan
refinance mortgage rate
california refinance
bad credit mortgage refinance
texas mortgage refinance
refinance second mortgage
florida refinance mortgage
mortgage refinance california
home refinance rate
refinance mortgage interest rate
in refinance
florida refinance
refinance home mortgage rate
business refinance
home loan mortgage refinance loan
auto loan refinance
interest only refinance
refinance interest rate
no closing cost refinance
michigan mortgage refinance
lowest refinance rate
california mortgage refinance loan
refinance house
florida home refinance
refinance home mortgage interest rate
refinance student loan
california refinance home mortgage
bad credit refinance loan
refinance home mortgage home equity loan
refinance san diego
va refinance
refinance 2nd mortgage
mortgage refinance online
refinance comparison
va loan refinance
best refinance mortgage rate
california refinance loan
low refinance rate
poor credit refinance
florida refinance mortgage rate
debt consolidation refinance
cash out refinance
refinance loan rate
home equity mortgage refinance loan
ohio mortgage refinance
refinance after bankruptcy
best refinance mortgage
california mortgage rate refinance
home refinance bad credit
life refinance suv
refinance bankruptcy
houston refinance
mortgage loan refinance florida
california home loan refinance
refinance home equity
bad credit home loan refinance
refinance mortgage new jersey
mortgage refinance lowest rate
refinance mobile home
no credit refinance small business loan
refinance mortgage application
refinance jacksonville
current mortgage refinance rate
miami refinance
mortgage refinance calculator
loan mortgage rate refinance
va home loan refinance
michigan refinance
no cost refinance
mortgage refinance company
mortgage loan refinance and debt consolidation
auto lease refinance
home refinance california
refinance mortgage quote
refinance quote
refinance orlando
small business refinance
refinance company
michigan refinance mortgage loan
norfolk refinance
foreclosure refinance
refinance lender
new york refinance
home loan refinance rate
refinance honolulu
best refinance
business debt refinance
countrywide home loan refinance
refinance dallas
best refinance rate
free quote on refinance
mortgage refinance information
 
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

Links to this post:

Create a Link



<< Home

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