There’s a common pattern I’ve seen for developing DSL in Ruby. It’s used in RSpec, the Statemachine Gem, and Unclebob’s Clean Code talk at RailsConf 2007. I haven’t seen a name for this pattern so I’ll call it the DSL Block Pattern.
RSpec
1 describe "Bowling Game" do
2 it "should score 0 on a gutter game" do
3 game = Game.new
4 20.times { game.roll(0) }
5 game.score.should eql(0)
6 end
7 end
Statemachine
1 sm = Statemachine.build do
2 trans :locked, :coin, :unlocked
3 trans :locked, :pass, :locked
4 trans :unlocked, :pass, :locked
5 trans :unlocked, :coin, :unlocked
6 end
Parser
1 parser = Args.expect do
2 boolean "l"
3 number "p"
4 string "d"
5 end
Here’s the problem. You’ve got to write code for specific domain such as writing specifications (RSpec), defining a Statemachine, or defining command line arguments (Unclebob’s Clean Code talk).
These domains have a contained and well defined terminology set. Often the cleanest, most elegant way to express this code is to create a DSL.
Before diving into the example, let me say that I like coffee as much as the next guy. But I feel lost when ever I go to a Starbucks. As you know, Starbucks has a it’s own language, DSL if you will, for ordering coffee. What follows is a DSL Block for ordering Starbucks coffee.
The general grammar for ordering coffee is: Size, Adjective (optional), Type of Coffee. This is by no means comprehensive but it’s sufficient for the example. So if you wanted to order a large coffee, for example, you would say, Grande Coffee.
A small espresso: Short Americano. An extra large mixture of regular and decaffeinated coffee with some half and half: Venti Breve Half Caff.
Given the task to code these coffee orders, I’d like to code it like this:
1 Starbucks.order do
2 grande.coffee
3 short.americano
4 venti.breve.half_caff
5 end
Ok that looks good, but as you look closely, you’ll start to wonder
about those methods, grande, short, and
venti “Do they have to be defined on the Kernel?” you may
ask.
Defining them on the Kernel is a scary prospect. And that may convince you to clutter the syntax by passing an object into the block like this:
1
2 Starbucks.order do |order|
3 order.grande.coffee
4 order.short.americano
5 order.venti.breve.half_caff
6 end
This would allow you to define the grande,
short, and venti methods on the object passed
into the block. Although you do need an object where
grande, short, and venti will be
defined, you don’t need to add an argument to the block.
You’ll find code out there, such as migrations, that uses this less optimal route. It’s not necessary. The trick to get rid of the argument is below:
1
2 module Starbucks
3
4 def self.order(&block)
5 order = Order.new
6 order.instance_eval(&block)
7 return order.drinks
8 end
9
10 class Order
11
12 attr_reader :drinks
13
14 def initialize
15 @drinks = []
16 end
17
18 def short
19 @size = "small"
20 return self
21 end
22
23 def grande
24 @size = "large"
25 return self
26 end
27
28 def venti
29 @size = "extra large"
30 return self
31 end
32
33 def coffee
34 @drink = "coffee"
35 build_drink
36 end
37
38 def half_caff
39 @drink = "regular and decaffeinated coffee mixed together"
40 build_drink
41 end
42
43 def americano
44 @drink = "espresso"
45 build_drink
46 end
47
48 def breve
49 @adjective = "with half and half"
50 return self
51 end
52
53 private
54
55 def build_drink
56 drink = "#{@size} cup of #{@drink}"
57 drink << " #{@adjective}" if @adjective
58 @drinks << drink
59
60 @size = @drink = @adjective = nil
61 end
62 end
63
64 end
You can see that the Order object is doing all the work. It’s got the
responsibility of interpreting the DSL, so let’s call it
the Interpreter Object. The Module::order method simply
creates an instance of Order and calls
istance_eval on it.
This causes the block to execute using the binding of the
Order instance. All of the methods on Order
will be accessible to the block.
The Interpreter Object can do any number of things as it interprets the DSL. In this case it simply generates a translation for Starbucks newbies. But, the sky’s the limit really.