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
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.