Книга: Metaprogramming Ruby 2
Назад: Writing a Domain-Specific Language
Дальше: Wrap-Up

Quiz: A Better DSL

Where you’re unexpectedly left alone to develop a new version of the RedFlag DSL.

Your boss wants you to add a setup instruction to the RedFlag DSL, as shown in the following code.

 
setup ​do
 
puts ​"Setting up sky"
 
@sky_height = 100
 
end
 
 
setup ​do
 
puts ​"Setting up mountains"
 
@mountains_height = 200
 
end
 
 
event ​"the sky is falling"​ ​do
 
@sky_height < 300
 
end
 
 
event ​"it's getting closer"​ ​do
 
@sky_height < @mountains_height
 
end
 
 
event ​"whoops... too late"​ ​do
 
@sky_height < 0
 
end

In this new version of the DSL, you’re free to mix events and setup blocks (setups for short). The DSL still checks events, and it also executes all the setups before each event. If you run redflag.rb on the previous test file, you expect this output:

<= 
Setting up sky
 
Setting up mountains
 
ALERT: the sky is falling
 
Setting up sky
 
Setting up mountains
 
ALERT: it's getting closer
 
Setting up sky
 
Setting up mountains

RedFlag executes all the setups before each of the three events. The first two events generate an alert; the third doesn’t.

A setup can set variables by using variable names that begin with an @ sign, such as @sky_height and @mountains_height. Events can then read these variables. Your boss thinks that this feature will encourage programmers to write clean code: all shared variables are initialized together in a setup and then used in events, so it’s easy to keep track of variables.

Still impressed by your boss’ technical prowess, you and Bill get down to business.

Runaway Bill

You and Bill compare the current RedFlag DSL to the new version your boss has suggested. The current RedFlag executes blocks immediately. The new RedFlag should execute the setups and the events in a specific order. You start by rewriting the event method:

 
def​ event(description, &block)
 
@events << {:description => description, :condition => block}
 
end
 
 
@events = []
 
load ​'events.rb'

The new event method converts the event condition from a block to a Proc. Then it wraps the event’s description and the Proc-ified condition in a hash and stores the hash in an array of events. The array is a top-level instance variable (like the ones you read about in ), so it can be initialized outside the event method. Finally, the last line loads the file that defines the events. Your plan is to write a setup method similar to the event method, and then write the code that executes events and setups in the correct sequence.

As you ponder your next step, Bill slaps his forehead, mutters something about his wife’s birthday party, and runs out the door. Now it’s up to you alone. Can you complete the new RedFlag DSL and get the expected output from the test file?

Quiz Solution

You can find many different solutions to this quiz. Here is one:

 
def​ setup(&block)
 
@setups << block
 
end
 
 
def​ event(description, &block)
 
@events << {:description => description, :condition => block}
 
end
 
 
@setups = []
 
@events = []
 
load ​'events.rb'
 
 
@events.each ​do​ |event|
 
@setups.each ​do​ |setup|
 
setup.call
 
end
 
puts ​"ALERT: ​#{event[:description]}​"​ ​if​ event[:condition].call
 
end

Both setup and event convert the block to a proc and store away the proc, in @setups and @events, respectively. These two top-level instance variables are shared by setup, event, and the main code.

The main code initializes @setups and @events, then it loads events.rb. The code in the events file calls back into setup and event, adding elements to @setups and @events.

With all the events and setups loaded, your program iterates through the events. For each event, it calls all the setup blocks, and then it calls the event.

You can almost hear the voice of Bill in your head, sounding a bit like Obi-Wan Kenobi: “Those top-level instance variables, @events and @setups, are like global variables in disguise. Why don’t you get rid of them?”

Removing the “Global” Variables

To get rid of the global variables (and Bill’s voice in your head), you can use a Shared Scope ():

 
lambda {
 
setups = []
 
events = []
 
 
Kernel.send :define_method, :setup ​do​ |&block|
 
setups << block
 
end
 
 
Kernel.send :define_method, :event ​do​ |description, &block|
 
events << {:description => description, :condition => block}
 
end
 
 
Kernel.send :define_method, :each_setup ​do​ |&block|
 
setups.each ​do​ |setup|
 
block.call setup
 
end
 
end
 
 
Kernel.send :define_method, :each_event ​do​ |&block|
 
events.each ​do​ |event|
 
block.call event
 
end
 
end
 
}.call
 
 
load ​'events.rb'
 
 
each_event ​do​ |event|
 
each_setup ​do​ |setup|
 
setup.call
 
end
 
puts ​"ALERT: ​#{event[:description]}​"​ ​if​ event[:condition].call
 
end

The Shared Scope is contained in a lambda that is called immediately. The code in the lambda defines the RedFlag methods as Kernel Methods () that share two variables: setups and events. Nobody else can see these two variables, because they’re local to the lambda. (Indeed, the only reason why we have a lambda here is that we want to make these variables invisible to anyone except the four Kernel Methods.) And yes, each call to Kernel.send is passing a block as an argument to another block.

Now those ugly global variables are gone, but the RedFlag code is not as pleasantly simple as it used to be. It’s up to you to decide whether this change is an improvement or just an unwelcome obfuscation. While you decide that, there is one last change that is worth considering.

Adding a Clean Room

In the current version of RedFlag, events can change each other’s shared top-level instance variables:

 
event ​"define a shared variable"​ ​do
 
@x = 1
 
end
 
event ​"change the variable"​ ​do
 
@x = @x + 1
 
end

You want events to share variables with setups, but you don’t necessarily want events to share variables with each other. Once again, it’s up to you to decide whether this is a feature or a potential bug. If you decide that events should be as independent from each other as possible (like tests in a test suite), then you might want to execute events in a Clean Room ():

 
each_event ​do​ |event|
 
env = Object.new
 
each_setup ​do​ |setup|
 
env.instance_eval &setup
 
end
 
puts ​"ALERT: ​#{event[:description]}​"​ ​if​ env.instance_eval &(event[:condition])
 
end

Now an event and its setups are evaluated in the context of an Object that acts as a Clean Room. The instance variables in the setups and events are instance variables of the Clean Room rather than top-level instance variables. Because each event runs in its own Clean Room, events cannot share instance variables.

You might think of using a BasicObject instead of an Object for your Clean Room. However, remember that BasicObject is also a Blank Slate (), and as such it lacks some common methods, such as puts. So you should only use a BasicObject if you know that the code in the RedFlag events isn’t going to call puts or other Object methods. You grin and add a comment to the code, leaving this difficult decision to Bill.

Назад: Writing a Domain-Specific Language
Дальше: Wrap-Up