Configuration Blocks
Many times in Ruby, it is useful to allow the creator of an object to configure it using a block. This is especially true when writing a domain-specific language (DSL).
At work, I’ve built a Ruby DSL on top of Rake for building our C++ applications, and I use the configuration block idiom to allow a particular project to be customized:
project "myProject" do |p|
p.use_pthreads
p.add_compile_flags %w{-DMMAP_SUPPORT -march=i686}
p.link_with "m"
p.link_with %w{myLib1 myLib2}
end
To support this API, I have code something like:
def project(name, &block)
Project.new(name, &block)
end
class Project
def initialize(name)
@name = name
# ...
yield self if block_given?
# ...
end
def use_pthreads
#...
end
def add_compile_flags(flags)
#...
end
def link_with(libraries)
#...
end
end
This works fine, but I find the configuration block to be a bit more verbose than I’d like. There is a way to simplify the block:
project "myProject" do
use_pthreads
add_compile_flags %w{-DMMAP_SUPPORT -march=i686}
link_with "m"
link_with %w{myLib1 myLib2}
end
In this version, I no longer need to specify the p
argument to the
block or prefix all of the configuration method calls with p.
.
The code to make this work reaches deeper into the bag of Ruby tricks:
class Project
def initialize(name, &block)
@name = name
#...
instance_eval(&block) if block_given?
#...
end
# Rest as before...
end
This version uses instance_eval
to evaluate the block in the context
of the Project
instance being configured. Any implicit references
to self within the block are referring to that instance.
This makes the configuration block cleaner and more readable, especially in the context of a DSL. But there are (at least) two potential issues with it:
-
The configuration block has access to any method, public or private, defined on
Project
. In this case I’m OK with that, but it might be an issue in other circumstances. If I was worried about it, I would introduce a separate configuration object that has only the desired API and useinstance_eval
on that object instead. -
The configuration block cannot use any methods local to the calling context. For example:
project "myProject" do
# Doesn't work!!
link_with libraries_to_link_with
end
def libraries_to_link_with
# Figure out which libraries to link with
end
This doesn’t work, because the block is evaluated such that self
is
the Project
instance, and so can’t see the local
libraries_to_link_with
method. In this case, the client really
needs to use the original block syntax with the explicit argument:
project "myProject" do |p|
p.link_with libraries_to_link_with
end
def libraries_to_link_with
# Figure out which libraries to link with
end
Since we changed our implementation, though, this option is no longer available. The solution is to make the configuration block handling code more flexible:
class Project
def initialize(name, &block)
@name = name
if block_given?
if block.arity == 1
yield self
else
instance_eval(&block)
end
end
end
# Rest as before...
end
Now, we look at the block to see if it needs an argument or not. If
it does, we use the original yield self
approach; otherwise, we use
the instance_eval
approach.
This gives the client code the option of which form to use. In the normal case, no block argument is required and the client can use the cleaner form of configuration block. However, if the client needs to do something more complex, the original form is still available as well.
I learned about both the instance_eval
approach and the flexible
approach from
this excellent post by Michael Bleigh.