Ruby DSLs: instance_eval with delegation

I saw the warning issued on Ola's blog: don't overuse instance_eval. JEG II blogged about a compromise, and why had an idea. But I like variation of Jim Weirich's MethodDirector.

how instance_eval works

If you're not familiar with instance_eval, it evaluates a block of code with self set to the object that's receiving the instance_eval call. Here's an example.

add_two = Proc.new { self + 2 }

puts 1.instance_eval(&add_two)
#=> 3

puts 2.instance_eval(&add_two)
#=> 4

Here's a second example using an implicit method receiver.

call_reverse = Proc.new { reverse }

p "abc".instance_eval(&call_reverse)
#=> "cba"

p ["x", "y", "z"].instance_eval(&call_reverse)
#=> ["z", "y", "x"]

the problem with instance_eval

Ola described it really well, so I'm just going to quote him and then show an example.

So what's the problem with it? Well, the problem is that blocks are generally closures. And you expect them to actually be full closures. And it's not obvious from the point where you write the block that that block might not be a full closure. That's what happens when you use instance_eval: you reset the self of that block into something else - this means that the block is still a closure over all local variables outside the block, but NOT for method calls. I don't even know if constant lookup is changed or not.

Using instance_eval changes the rules for the language in a way that is not obvious when reading a block. You need to think an extra step to figure out exactly why a method call that you can lexically see around the block can actually not be called from inside of the block.

Here's an example. Let's take a look at a quasi migration using create_table and _not_ using instance_eval.

class MyMigration < MigrationExample
  def self.up
    create_table "people" do |t|
      t.string "first_name", "last_name"
    end
  end
end

To implement this, we could need t to reference a TableDefinition class that would be used to build up columns. But we could get rid of t if we were to use instance_eval. Because instance_eval could change self to reference the TableDefinition, we could use the implicit method receiver and change the migration to look something like this.

class MyMigration < MigrationExample
  def self.up
    create_table "people" do
      string "first_name", "last_name"
    end
  end
end

But let's take a look at the consequences of this approach. Here's a little fake migration class that just prints output.

class MigrationExample
  def self.create_table(table_name, &block)
    table = TableDefinition.new table_name
    table.evaluate &block
    table.create
  end
end

class TableDefinition
  def initialize(table_name, &block)
    @table_name = table_name
    @columns = []
  end
  
  def evaluate(&block)
    instance_eval &block
  end
  
  def string(*columns)
    @columns << columns
  end
  
  def create
    puts "creating the '#{@table_name}' table with columns: #{@columns.join(", ")}"
  end
end

So here we're using instance_eval to evaluate the block passed to create_table. And if we run the migration, we'll see that the table gets created.

MyMigration.up
#=> creating the 'people' table with columns: first_name, last_name

But what happens if we want to use a little helper method in our migration? Let's try it by doing something completely trivial and extracting a name method.

class MyMigration &lt; MigrationExample
  def self.up
    create_table "people" do
      string name("first"), name("last")
    end
  end
  
  def self.name(which)
    "#{which}_name"
  end
end

And now if we run the migration...

MyMigration.up
# NoMethodError: undefined method 'name' for #<TableDefinition:0x89e18 @columns=[], @table_name="people">

Ouch. Because self is set to the TableDefinition class while instance_evaling the block, our method call to name was sent to the table definition object instead of our migration class.

Here's one approach to solve this problem.

instance_eval + delegation

So the problem is that instance_eval hijacks self, making our method call to name fail. But what if we can get the name call sent back to the object that it would have gone to if we didn't change self?

We can accomplish this by capturing what self is before we change it with instance_eval. Then if our class doesn't respond to a method, we'll assume it was meant for the original receiver.

class TableDefinition
  def evaluate(&block)
    @self_before_instance_eval = eval "self", block.binding
    instance_eval &block
  end
  
  def method_missing(method, *args, &block)
    @self_before_instance_eval.send method, *args, &block
  end
end

Looking at the create_table block again...

create_table "people" do
  string name("first"), name("last")
end  

The call to string will be called on TableDefinition. But the name method call will hit method missing and be delegated back to the migration. Here's the output if we run the migration again.

MyMigration.up
# creating the 'people' table with columns: first_name, last_name

We've removed the need for the block local variable t, but also preserved the capability to use local helper methods.

summary

This approach may be useful in some internal DSLs, but it still involves Ruby magic that may be confusing. Especially if you're trying to use instance variables. But if all you want is the ability to use helper methods and/or attribute readers, instance_eval + delegation back to the original self will work.

Hi, I'm Dan Manges, a software developer best known for being the founding CTO of Braintree. Send me an email.
blog comments powered by Disqus