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:
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.
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
# ‘|| 0’ is needed for ‘+=’ 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.
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
...
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!
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
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
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.
Comments are loading...