Ruby assignment operators return the right-hand side value


Memoizing in ruby is pretty straight forward, but sometimes ruby puts limitations. This limitation is apparent when you override behavior of parent class, and the solution, let alone your mistake, is not so obvious.

The Problem

Ran into an interesting ruby problem the other day. Basically, we were trying to memoize a hash like so:

require 'active_support/all'

@store = ActiveSupport::HashWithIndifferentAccess.new

def my_hash
  @store[:foo] ||= {bar: 'BAR'}
end

my_hash[:waldo] = 'WALDO'
my_hash[:baz] = 'BAZ'
my_hash[:qux] = 'QUX'

puts my_hash

Source

You are probably expecting my_hash to have 4 values, right? Wrong.

This is what we got, and Waldo is missing!

$ ruby where_is_waldo.rb
{"bar"=>"BAR", "baz"=>"BAZ", "qux"=>"QUX"}

Why is this happening?

Ruby specs tell you that, when using assignment operations in ruby, the right side must be returned. This allows chained assignment of variables like so:

a = b = c = 42

When we assign variables in this matter, we expect variable a to be assigned 42 and not be modified somewhere in that assignment process. Suppose we overrode the definition of = on c, and returned a modified value (outcome of c=), we will not have consistent assignment, and a will not be 42. For this reason, ruby does not return the result of the assignment, but rather the value we are assigning.

Unfortunately, this leads to confusion in some cases. In our case, it’s use of ActiveSupport::HashWithIndifferentAccess, which inherits from ruby’s native Hash and overrides the []= assignment operator.

The problem with our code is in this line:

@store[:foo] ||= {bar: 'BAR'}

When initial assignment of hash happens, we are passing a regular ruby hash into HashWithIndifferentAccess. It modifies a regular ruby hash into a HashWithIndifferentAccess object during the assignment. Since ruby returns the right side of assignemnt, my_hash method will return our regular ruby hash, while memoizing the modified value. The stored object and the returned object will be different. So, when we first access the hash, we actually get the wrong object, and when we assign WALDO to it; we are essentially assigning it to a ruby hash, and not our memoized hash.

The Solution / Workaround

There are two ways of solving this problem:

1. Memoize with HashWithIndifferentAccess

Call hash_with_indifferent_access on your hash to memoize a non-ruby hash, and make sure the right hand side and the left side are same objects when returned.

require 'active_support/all'

@store = ActiveSupport::HashWithIndifferentAccess.new

def my_hash
  @store[:foo] ||= {bar: 'BAR'}.hash_with_indifferent_access
end

my_hash
my_hash[:waldo] = 'WALDO'
my_hash[:baz] = 'BAZ'
my_hash[:qux] = 'QUX'

puts my_hash

2. Call my_hash once before assignment

Access the hash getter method to run the memoization before you do any assignments. This will make sure that HashWithIndifferentAccess hash is returned when you do the assignment itself, and you will be accessing the same object.

require 'active_support/all'

@store = ActiveSupport::HashWithIndifferentAccess.new

def my_hash
  @store[:foo] ||= {bar: 'BAR'}
end

my_hash
my_hash[:waldo] = 'WALDO'
my_hash[:baz] = 'BAZ'

my_hash[:qux] = 'QUX'

puts my_hash

You can read a smarter answer by Matt on Rails Core mailinglist, and refer to this answer on Stackoverflow.