Wednesday, November 08, 2006

Ruby Domain Specific Languages - The Basics (Part 2)

Previously, I was exploring the basics of DSL creation using Ruby. This post continues sharing my lessons learned while developing a prototype of a domain specific language in the mortgage industry. Since I cannot share the actual code itself belonging to my client, I will continue to extract the useful concepts into these simple examples.

Last time we created a simple Animal DSL class. The important part of that class is this bit that makes sure our DSL like syntax works for the declarative methods:

class Animal
attr_accessor :number_of_legs

def self.number_of_legs(number_of_legs)
@number_of_legs = number_of_legs
end

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

Now we will create a Person class that can interact with the Animals. Each Person will have a temperament (either mean or nice), and will be carrying some kind of food in their pocket to feed their pet. We will define people using our DSL as follows:


class Dorothy < Person
temperament :nice
food :sunflower_seeds, :carrot_juice
end

class Witch < Person
temperament :mean
food :cheetos, :soda
end


The implementation is very similar to the basic Animal class

class Person < Animal
attr_accessor :temperament

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


Since we are working with an array, the self.food method has a variable number of parameters, represented using the asterisk in front of the parameters list. The cool little idiom @food ||= [] returns with the current @food variable, or if it is nil, returns an empty array. There is also a has_food? method to tell us if a person is carrying a certain type of food.

Now let us introduce the Pet. We will use the power of Ruby blocks to tell each pet the rules about when it likes a person, or not. Here is the DSL we want to use for the Pets:

class Toto < Pet
friend_test do |person|
true unless person.temperament == :mean # I like anyone who is not mean
end

end

class Tweety < Pet
friend_test do |person|
true if person.has_food?(:sunflower_seeds) # I like anyone who has sunflower seeds
end
end

class Slugworth < Pet
friend_test do |person|
true # I like anyone
end
end


And now the implementation of the Pet class:

class Pet < Animal
def self.friend_test(&test)
@friend_test = test
end

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


The friend_test method stores a block containing the test for that Pet, and the is_friend? method tests to see if a Pet will be friendly toward a particular Person.
Last, we put it all together with some simple code to display the interactions between the People and the Pets:

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 "#{snail.class.name} is a friend of #{person.class.name}: #{snail.is_friend?(person)}"


The result of running this code is:

Toto is a friend of Dorothy: true
Tweety is a friend of Dorothy: true
Slugworth is a friend of Dorothy: true

Toto is a friend of Witch: false
Tweety is a friend of Witch: false
Slugworth is a friend of Witch: true

Using this simple Ruby DSL approach, it is very easy to add a new Person or Pet, just by entering the rules that define its behavior. As the objects in a system become more complex, one of the best ways to manage that complexity is to create an abstraction that hides the ugly bits.

Here is the full code for this example:

class Animal
attr_accessor :number_of_legs

def self.number_of_legs(number_of_legs)
@number_of_legs = number_of_legs
end

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

class Person < Animal
attr_accessor :temperament

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 self.friend_test(&test)
@friend_test = test
end

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


class Toto < Pet
friend_test do |person|
true unless person.temperament == :mean
end

end

class Tweety < Pet
friend_test do |person|
person.has_food?(:sunflower_seeds)
end
end

class Slugworth < Pet
friend_test do |person|
true
end
end

class Dorothy < Person
temperament :nice
food :sunflower_seeds, :carrot_juice
end

class Witch < Person
temperament :mean
food :cheetos, :soda
end


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 "#{snail.class.name} is a friend of #{person.class.name}: #{snail.is_friend?(person)}"

No comments: