Friday, January 12, 2007

Ruby Domain Specific Languages - The Basics (Part 4)

In the previous three entries in this series of postings, I have been exploring the basics of creating domain specific languages using Ruby. Since I cannot disclose details of my big financial service client's DSL, I am using this made up example to illustrate the techniques that are useful when building your own Ruby DSL. So far we have created a "PetShop" domain that allows us to interact with the Pets. Defining the behavior of new Pets is the purpose of this DSL.

For example, let's say we now want each Pet to be able to do some trick, when called on to do so. When you are defining behaviors, it is very useful to define a recipe-like syntax in a DSL.

Here is an example:

pet "Toto" do
when_performing_tricks do
sit
speak
end
end


This is just a description or recipe of what the animal is supposed to do when asked to perform it's trick. When we really want our Pet to perform, we simply say:

pet.perform


This produces the following output:

Toto will now perform...
Toto is sitting...
Toto says 'Woof!'
Let's hear some applause for Toto!
Slugworth will now perform...
Slugworth is sitting...
Let's hear some applause for Slugworth!
Tweety will now perform...
Tweety is sitting...
Tweety says 'I thought I saw a putty tat!
Tweety is flying...
Let's hear some applause for Tweety!


The Pet will do whatever tricks it knows, using this little simple bit of Ruby magic:

def self.when_performing_tricks(&routine)
@routine = routine
end

def perform
puts "#{name} will now perform..."
@routine.call
puts "Let's hear some applause for #{name}!"
end


The "when_performing_tricks" method simply stores the block, and executes it only when the time comes to "perform".

So how does the Pet know what is entailed in each trick? I have put the "sit" trick into the base Pet class (every Pet knows how to sit):

def self.sit
puts "#{name} is sitting..."
end


We can also more importantly define custom tricks per type of Pet. A simple bit of Ruby metaprogramming goodness helps us out:

def self.define_trick(name, &trick_definition)
singleton_class.class_eval do
define_method name, &trick_definition
end
end



The "class_eval" methos lets us evaluate the inside expression in the context of the class object, not just a particular instance of the object. Another bit worth noting is the helper method that I have added to the DSLThing base class called "singleton_class" that looks like this:

def self.singleton_class
class << self; self; end
end

This is just a short cut to get to the class instance object, known better to Rubyists as the "singleton_class".

Lastly, the "define_method" method then lets us define a new method for the trick. When we want to define a new trick for a particular Pet, we can simply define it like this:

define_trick "speak" do
puts "Toto says 'Woof!'"
end


Putting it all together, here are our new definitions of the tricks our Pets can do:

pet "Toto" do
when_performing_tricks do
sit
speak
end

define_trick "speak" do
puts "Toto says 'Woof!'"
end
end

pet "Tweety" do
when_performing_tricks do
sit
speak
fly
end

define_trick "speak" do
puts "Tweety says 'I thought I saw a putty tat!"
end

define_trick "fly" do
puts "Tweety is flying..."
end
end

pet "Slugworth" do
when_performing_tricks do
sit
end
end


One other interesting technique of note is that we are actually defining a new type of Pet by dynamically declaring a new class based on the Pet class. Otherwise, our custom tricks for one type of Pet might interfer with the custom tricks of another. The solution to this is using the "Object.const_set" and "Object.const_get". Here is the code I used:

def self.pet(name, &blk)
@pets ||= Hash.new
klass = Class.new(Pet)
Object.const_set(name, klass) if not Object.const_defined?(name)
p = Object.const_get(name).new
p.name = name
p.class.class_eval(&blk) if block_given?
p.copyvars
@pets[name] = p
end


In this posting I have used the DSL recipe technique, and created dynamic methods and classes. Combining these techniques can allow for a very powerful and yet concise syntax when creating your own Ruby domain specific languages.

Here is the complete listing of the code from this post:

class DSLThing
def copyvars
self.class.instance_variables.each do |var|
instance_variable_set(var, self.class.instance_variable_get(var))
end
end

def self.singleton_class
class << self; self; end
end
end

class PetShop < DSLThing
attr_accessor :pets, :people

def self.create(&block)
f = PetShop.new
f.class.instance_eval(&block) if block_given?
f.copyvars
return f
end

def self.pet(name, &blk)
@pets ||= Hash.new
klass = Class.new(Pet)
Object.const_set(name, klass) if not Object.const_defined?(name)
p = Object.const_get(name).new
p.name = name
p.class.class_eval(&blk) if block_given?
p.copyvars
@pets[name] = p
end
end

class Animal < DSLThing
attr_accessor :name

def initialize(name=nil)
@name = name
end
end

class Pet < Animal
def initialize(name=nil)
@name = name
super
end

def self.when_performing_tricks(&routine)
@routine = routine
end

def self.define_trick(name, &trick_definition)
singleton_class.class_eval do
define_method name, &trick_definition
end
end

def perform
puts "#{name} will now perform..."
@routine.call
puts "Let's hear some applause for #{name}!"
end

def self.sit
puts "#{name} is sitting..."
end
end

shop = PetShop.create do
pet "Toto" do
when_performing_tricks do
sit
speak
end

define_trick "speak" do
puts "Toto says 'Woof!'"
end
end

pet "Tweety" do
when_performing_tricks do
sit
speak
fly
end

define_trick "speak" do
puts "Tweety says 'I thought I saw a putty tat!"
end

define_trick "fly" do
puts "Tweety is flying..."
end
end

pet "Slugworth" do
when_performing_tricks do
sit
end
end
end

shop.pets.each_value do |pet|
pet.perform
end

No comments: