Saturday, April 29, 2006
Twisting and Shaping DSLs using Ruby Metaprogramming
[..] 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 = {}
and finally the code to print out the recipe:class Recipe
…
private
def register_with_global_repository
$RECIPES_REPOSITORY[ @name] = self
end
end
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:
Our new DSL now allows us to write:def recipe? recipe_name
$RECIPES_REPOSITORY[recipe_name].report
end
and to obtain:"italian breakfast".consists_of.caffe 1, :macchiato
recipe? "italian breakfast"
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
The trick here is that after having defined an ingredients we still have the original Recipe object ready to accept new ingredients.def method_missing method, *args
@ingredients [method.to_s] = args
allow_method_chaining
end
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!
(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.
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?
"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
i.e.
Recipe("italian breakfast").caffe 1, :macchiato
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.
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.
<< Home