Sunday, November 19, 2006

Ruby Domain Specific Languages - The Basics (Part 3)

This is another installment in a series about creating domain specific languages with Ruby. In part 1 and part 2 of this series, I created a simple Ruby DSL to describe the relationship between Pets and Persons. Now I will extend the DSL, and use some cool Ruby metaprogramming tricks to demonstrate the power and benefits of using Ruby to create an internal DSL.
First, I want to simplify the syntax for declaring things in our DSL. Getting rid of the class definition stuff can make it a lot easier for domain experts to read. A simple, cool declarative style like Rake is what we want to end up with, something like this:

person "Dorothy" do
temperament :nice
food :sunflower_seeds, :carrot_juice
end

Furthermore, I want to extend our DSL to put all of the descriptions of Pets and Persons together into a PetShop. Here is our PetShop DSL:

shop = PetShop.create do
pet "Toto" do
friend_test do |person|
true unless person.temperament == :mean
end
end

pet "Tweety" do
friend_test do |person|
person.has_food?(:sunflower_seeds)
end
end

pet "Slugworth" do
friend_test do |person|
true # I like anyone
end
end

person "Dorothy" do
temperament :nice
food :sunflower_seeds, :carrot_juice
end

person "Witch" do
temperament :mean
food :cheetos, :soda
end
end

One interesting technique used is declaring the "pet" within the "do-end" block for the "petshop" object:

shop = PetShop.create do
pet "Toto" do
friend_test do |person|
true unless person.temperament == :mean
end
end

...
end

The little bit of DSL niceness is achieved using the class_eval method. The block of code within the "do" block is called in the context of the newly created object. Here is the Ruby code that achieves this for a new Pet:

def self.pet(name, &blk)
@pets = Hash.new
p = Pet.new(name)
p.class.class_eval(&blk) if block_given?
@pets[name] = p
p.copyvars
end


Now that we have declared all of the Pets and Persons in to the context of a PetShop, we can test the relationships between Pets and Persons is a more general purpose way then in my previous post. The older, more fragile code was this:

dog = Toto.new
bird = Tweety.new
snail = Slugworth.new

person = Dorothy.new
puts "#{dog.class.name} is a friend of #{person.class.name}: #{dog.is_friend?(person)}"
puts "#{bird.class.name} is a friend of #{person.class.name}: #{bird.is_friend?(person)}"
puts "#{snail.class.name} is a friend of #{person.class.name}: #{snail.is_friend?(person)}"
puts

person = Witch.new
puts "#{dog.class.name} is a friend of #{person.class.name}: #{dog.is_friend?(person)}"
puts "#{bird.class.name} is a friend of #{person.class.name}: #{bird.is_friend?(person)}"
puts

The new, cleaner code is like this:

shop.people.each_value do person
shop.pets.each_value do pet
puts "Is #{pet.name} a friend of #{person.name}? #{pet.is_friend?(person)}"
end
end

And here is the output when we run the program:

Is Toto a friend of Witch? false
Is Slugworth a friend of Witch? true
Is Tweety a friend of Witch? false
Is Toto a friend of Dorothy? true
Is Slugworth a friend of Dorothy? true
Is Tweety a friend of Dorothy? true

We have simplified the syntax of our domain specific language, and added some additional functionality. We have also been able to reduce the amount of Ruby code required at the same time.

Here is the final version of the code:

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

class PetShop < DSLThing
attr_accessor :pets, :people

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

def self.pet(name, &blk)
@pets ||= Hash.new
p = Pet.new(name)
p.class.class_eval(&blk) if block_given?
@pets[name] = p
p.copyvars
end

def self.person(name, &blk)
@people ||= Hash.new
p = Person.new(name)
p.class.class_eval(&blk)
@people[name] = p
p.copyvars
end
end

class Animal < DSLThing
attr_accessor :name

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

class Person < Animal
attr_accessor :temperament

def initialize(name=nil)
super
end

def self.temperament(type)
@temperament = type
end

def self.food(*types_of_food)
@food = []
types_of_food.each do |food|
@food << food
end
end

def has_food?(type_of_food)
@food.include?(type_of_food)
end
end

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

def self.friend_test(&test)
@friend_test = test
end

def is_friend?(person)
@friend_test.call(person) == true
end
end

shop = PetShop.create do
pet "Toto" do
friend_test do |person|
true unless person.temperament == :mean
end
end

pet "Tweety" do
friend_test do |person|
person.has_food?(:sunflower_seeds)
end
end

pet "Slugworth" do
friend_test do |person|
true # I like anyone
end
end

person "Dorothy" do
temperament :nice
food :sunflower_seeds, :carrot_juice
end

person "Witch" do
temperament :mean
food :cheetos, :soda
end
end

shop.people.each_value do |person|
shop.pets.each_value do |pet|
puts "Is #{pet.name} a friend of #{person.name}? #{pet.is_friend?(person)}"
end
end

No comments: