Counting Block Nesting Depth in Ruby
(an Exercise in Exception-handling, Thread-safety, & Meta-programming)

30 November 2010

This question recently arose on the SFRuby users group mailing list. Given some nested block methods, Steven Harms asked how we could track the current block-nesting depth during runtime:

```ruby method1 "something" do method2 "something else" do "hiya" # < = We are now 2 block levels deep end end ```

Unfortunately, Ruby does not have any built-in runtime methods to return the current block nesting depth. So, it looks like we'll have to roll our own.

For the impatient among us, feel free to skip straight to the solution.

Using an instance variable

Our first pass at this problem was to simply use an instance variable called @block_depth attached to the object, incrementing it when a new block method was started, and decrementing it when the block method finished.

class MyObject

  def block_depth=(value)
    @block_depth = value
  end

  def block_depth
    # &lsquo;|| 0&rsquo; is needed for &lsquo;+=&rsquo; to work when @block_depth is nil
    @block_depth || 0
  end

  def method1(stuff, &block)
    self.block_depth += 1
    puts "This is #{stuff}... #{self.block_depth} level deep\n"
    yield
    self.block_depth -= 1
  end

  def method2(stuff, &block)
    self.block_depth += 1
    puts "This is #{stuff}... #{self.block_depth} levels deep\n"
    yield
    self.block_depth -= 1
  end

end

obj = MyObject.new

obj.method1 "something" do
  obj.method2 "something else" do
    puts "hiya"
  end
  puts "Back to #{obj.block_depth} level deep"
end

# => This is something... 1 level deep
# => This is something else... 2 levels deep
# => hiya
# => Back to 1 level deep

This example solves the problem at first blush, but further inspection reveals a few shortomings.

DRY it out

First of all, there's some needlessly repeated code in there. Let's abstract the depth-tracking bits into a track_block_depth method...

...
  def track_block_depth(&block)
    self.block_depth += 1
    yield
    self.block_depth -= 1
  end

  def method1(stuff, &block)
    track_block_depth do
      puts "This is #{stuff}... #{self.block_depth} level deep\n"
      yield
    end
  end

  def method2(stuff, &block)
    track_block_depth do
      puts "This is #{stuff}... #{self.block_depth} levels deep\n"
      yield
    end
  end
...

Surviving exceptions

Jacob Rothstein and Mark Wilden pointed out, on the mailing list, that @block_depth would become corrupted if an exception was thrown in any of your methods. To illustrate this, consider the following:

obj = MyObject.new

begin
  obj.method1 "something" do
    raise # Raise an exception
    puts "hiya"
  end
rescue
  puts "Exception raised"
end
puts "0 levels deep now; block_depth says #{obj.block_depth}!"

# => This is something... 1 level deep
# => Exception raised
# => 0 levels deep now; block_depth says 1!

We solve this by ensuring that the decrementing line runs even if an exception is encountered:

...
  def track_block_depth(&block)
    self.block_depth += 1
    yield
    ensure # < = This solves the above problem
      self.block_depth -= 1
  end
...

# => This is something... 1 level deep
# => Exception raised
# => 0 levels deep now; block_depth says 0!

Thread-safety

Then Joel VanderWerf pointed out that this solution isn't thread-safe. To illustrate this problem, let's try running our methods on a single object in two parallel threads...

obj = MyObject.new

t1 = Thread.new do
  obj.method1 "something" do
    obj.method2 "something else" do
      puts "hiya\n"
    end
    puts "Back to #{obj.block_depth} level deep\n"
  end
end

t2 = Thread.new do
  obj.method1 "something" do
    obj.method2 "something else" do
      puts "hiya\n"
    end
    puts "Back to #{obj.block_depth} level deep\n"
  end
end

t1.join
t2.join

# => This is something... 1 level deep
# => This is something... 2 level deep
# => This is something else... 3 levels deep
# => This is something else... 4 levels deep
# => hiya
# => hiya
# => Back to 3 level deep
# => Back to 2 level deep

Our code certainly never goes 4 block levels deep. Instead of using an instance variable, let's store the value in a thread-local variable:

...
  # use Thread.current[:block_depth] instead of @block_depth
  def block_depth=(value)
    Thread.current[:block_depth] = value
  end

  def block_depth
    Thread.current[:block_depth] || 0
  end
...

# => This is something... 1 level deep
# => This is something... 1 level deep
# => This is something else... 2 levels deep
# => This is something else... 2 levels deep
# => hiya
# => hiya
# => Back to 1 level deep
# => Back to 1 level deep

Making it meta-awesome

What if you want to track block depth in method1 ONLY some of the time? You could create an optional parameter, but that's not very fun. Let's make it a little more convenient and readable.

The following will not work in versions prior to Ruby 1.9 due to define_method's inability to handle blocks as passed arguments, which has since been fixed.

...
  def method1(stuff, &block)
    puts "This is #{stuff}... #{self.block_depth} level deep\n"
    yield
  end

  def method2(stuff, &block)
    puts "This is #{stuff}... #{self.block_depth} levels deep\n"
    yield
  end

  def method_missing(method_name,*args, &block)
    if method_name.to_s =~ /([\w\d]+)_with_block_depth/ && self.respond_to?($1)
      self.class.send :define_method, method_name do |*args, &block|
        self.track_block_depth do
          self.send($1, *args, &block)
        end
      end
      self.send(method_name, *args, &block)
    else
      super
    end
  end
...

And now, we can either call method1 and method2 on their own, with normal behavior, or we can call method1_with_block_depth and method2_with_block_depth to get our block depth tracking.

Don't forget, we'll also want to override the respond_to? method to return true for our "_with_block_depth" meta-methods, because it's just good method_missing practice.

obj.method1_with_block_depth "something" do
  obj.method2_with_block_depth "something else" do
    puts "hiya\n"
  end
  puts "Back to #{obj.block_depth} level deep\n"
end

 

Solution

Now, we can easily track block-nesting-depth (or not) with confidence, with resilience to unexpected exceptions, and in a completely thread-safe manner.

Another point I hoped to illustrate in this article is how awesome local Ruby groups can be. I encourage you to join us and discuss interesting problems like this. Two of my favorites are the Ann Arbor Ruby Brigade (a2rb) and the SFRuby users group.

About the author:

Steve Schwartz // Owner of Alfa Jango Web-based Software, creator of RateMyStudentRental & LeadNuke, engineer, hacker, rubyist, guitarist, aspiring racecar driverist.



Comments are loading...