HashWithIndifferentAccess + Ruby's Assignment Behavior
January 17, 2010I recently ran into a weird bug related to HashWithIndifferentAccess that led to my discovery of an interesting Ruby behavior.
The relevant code:
1 require 'rubygems'; require 'activesupport' 2 class House 3 def initialize 4 @rooms = {}.with_indifferent_access 5 end 6 7 def room_details_for(room_name) 8 @rooms[room_name] ||= {} 9 end 10 end 11 12 house = House.new 13 kitchen = house.room_details_for(:kitchen) 14 kitchen[:size] = "5x10" 15 16 puts kitchen.inspect #=> {:size=>"5x10"} 17 puts house.room_details_for(:kitchen).inspect #=> {}
After a little debugging and digging through HashWithIndifferentAccess's code, it turns out the problem is a consequence of the way Ruby implements the assignment operator. The return value of an assignment expression is the return value of the right hand side, regardless of what the return value of the assignment method that ends up being called:
1 obj = Object.new 2 def obj.foo=(value) 3 print "foo is called with #{value} but is returning 2, puts receives: " 4 2 5 end 6 7 puts (obj.foo = 1) #=> foo is called with 1 but is returning 2, puts receives: 1
That, in conjunction with the fact that HashWithIndifferentAccess converts all hashes set as values into HashWithIndifferentAccess, makes the above bug possible.
1 hash = {}.with_indifferent_access 2 hash[:key] = {'inner_key' => 2} 3 puts hash[:key][:inner_key] #=> 2
Note that ActiveSupport creates a new HashWithIndifferentAccess even if it is already one:
1 hash = {}.with_indifferent_access 2 inner_hash = (hash[:key] = {}.with_indifferent_access) 3 hash[:key][:inner_key] = 2 4 puts inner_hash.inspect #=> {} 5 puts hash[:key].inspect #=> {"inner_key"=>2}
There are a couple ways to get around this bug:
- Always discard the return value of a HWIA assignment statement unless the right hand side is not a Hash or an Array.
- Patch ActiveSupport so that calling with_indifferent_access on a HWIA is a no-op and always use HWIA in favor of Hash when it's a value of another HWIA.
- Patch ActiveSupport to not convert values assigned to it. This would break params in Rails unless it was explicitly recursively converted to HWIA.
Oh and by the way, using send doesn't have the same behavior, but is ugly:
1 obj = Object.new 2 def obj.foo=(value) 3 print "foo is called with #{value} but is returning 2, puts receives: " 4 2 5 end 6 7 puts (obj.send(:foo=, 1)) #=> foo is called with 1 but is returning 2, puts receives: 2
I hope this helps at least one person while Googling for this issue.

