Friday, April 28, 2006
The Way of Meta - Part IV - Hijacking Local Variables in DSLs
The Way of Meta
~
V. 0.0.4
In the article there was a point that was left somewhat open: how to allow developers using a DSL to assign values to new concepts that they define on the spot, and then get access to both these concepts and their values from within the DSL.
I realize I have been a bit vague here, so I'll give you a straightforward example. Let's say we want to define a DSL to describe the shopping list with all the ingredients needed to make a certain recipe..
Ideally we would like to write something like:
shopping_list_for "BLT sandwiches for the picnic" do
sliced_bread = 3
lettuce = 2
tomatoes = 6, :red
bacon = 4
end
The do..end block delimits a block context that gets passed directly to the shopping_list_for method.
This method should then extract the various ingredients and their quantity and description.
How can it do that? One way is to run the block within a context where those ingredients already exist as methods. This context would be a class with -for example- the sliced_bread= method overridden to accept the DSL input.
What about new ingredients that have not been specified a priori ? Your metaprogramming instincts will tell you to go for a method_missing based technique. Wrong. Unless you specify a self (or other object) in front of sliced_bread, ruby will simply assume that it is a local variable rather than a missing method. Highly annoying if you ask me, but it is necessary to avoid even worst problems and ambiguities when dealing with stuff like mixins.
You are left with these three choices:
shopping_list_for "BLT sandwiches for the picnic" do
self.sliced_bread = 3
self.lettuce = 2
self.tomatoes = 6, :red
self.bacon = 4
end
Argh!
shopping_list_for "BLT sandwiches for the picnic" do |recipe|
recipe.sliced_bread = 3
recipe. lettuce = 2
recipe. tomatoes = 6, :red
recipe. bacon = 4
end
Just a tad annoying
shopping_list_for "BLT sandwiches for the picnic" do
@sliced_bread = 3
@lettuce = 2
@tomatoes = 6, :red
@bacon = 4
end
The best so far.. you can visualize the @ as bullet points ant it is kind of ok..
Kind of.
Jim, at this point, proposes to drop the equal sign to force ruby to recognize the new keyword as a method that gets routed through method_missing.
shopping_list_for "BLT sandwiches for the picnic" do
sliced_bread 3
lettuce 2
tomatoes 6, :red
bacon 4
end
I'll admit it, this is not bad at all. Yet it annoys me to no end that I cannot just get those variables that I need from another context.
Thinking about there is a way to get variables, and that is the local_variables method, but you have to execute it within a specific context. There is also a method to pass around contexts, and that is binding. What we have to do is to get the binding of the block and to get the local variables within the context underlying the binding.
I tried to call the block and then get the binding of the block, but it didn't work. The block gets executed and the variables fall out of scope and become unavailable.
I had to get the block itself to return the binding. This can be done and it works, but the binding looks totally out of place..
shopping_list_for "BLT sandwiches for the picnic" do
sliced_bread = 3
lettuce = 2
tomatoes = 6, :red
bacon = 4
binding
end
Here it hit me that I could alias the word binding to something that would look good with end. What about the end ? I know it's tacky, but in its own tacky way it works and sounds credible:
shopping_list_for "BLT sandwiches for the picnic" do
sliced_bread = 3
lettuce = 2
tomatoes = 6, :red
bacon = 4
the end
This is the code that allows the DSL to work and to print out the shopping list:
def report recipe, ingredients
puts "to make #{recipe} you should buy:"
ingredients.each_pair do
|ingredient,description|
puts " * #{Array(description).join ' '} #{ingredient}"
end
puts
end
def shopping_list recipe
shopping_binding = yield
ingredients = {}
eval("local_variables" , shopping_binding).
each do |var|
ingredients[var] = eval "#{var}" , shopping_binding
end
report recipe, ingredients
end
alias the binding
shopping_list "english breakfast" do
tomatoes = 2, :green
sausages = 3
eggs = 2, :big
bacon = 4
the end
shopping_list "banana milkshake" do
milk = 1
bananas = 2
the end
When you run it you get:
to make english breakfast you should buy:
* 2 green tomatoes
* 3 sausages
* 4 bacon
* 2 big eggs
to make banana milkshake you should buy:
* 2 bananas
* 1 milk
I'm not a Ruby hacker, but 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) ?
maybe you still have the issue that by using the equal sign you'll get variables and not function calls?
<< Home