Esoteric Ruby in MemoWise
This post was co-written by Jacob Evelyn.
We recently released a memoization gem, MemoWise! We’ve written about its origin story and performance. In this post, we’ll discuss some esoteric corners of Ruby we encountered while writing MemoWise.
Memoizing frozen objects with prepend
One of the features we needed to support when creating this gem was memoization of frozen, or immutable, objects. Specifically, we use the Values gem which creates immutable instances. Once an object is frozen, we can’t assign any of its instance variables:
class Example
attr_writer :value
end
Example.new.freeze.value = true # FrozenError (can't modify frozen Example)
How is this relevant to memoization? Most memoization gems work by creating a hash to store memoized values. This hash is usually an instance variable on the object itself. (We call ours @_memo_wise
.) So if the object is immutable, this instance variable can’t be assigned after the object is frozen. (It can, however, be mutated.)
This is why we prepend
the MemoWise module to enable memoization. prepend
is less well known than include
or extend
and works slightly differently than both of them.
Every Ruby class has a list of ancestors. This list contains all included or preprended modules, ordered by inheritance. We can look at Array
as an example:
Array.ancestors
=> [Array, Enumerable, Object, Kernel, BasicObject]
When a method is called on an object, Ruby will look through each ancestor sequentially to see if the method is defined on any of the ancestors of that object. When we include
a module, that module is inserted after the class:
module Example; end
class Array
include Example
end
Array.ancestors
=> [Array, Example, Enumerable, Object, Kernel, BasicObject]
When we extend
a module, the module’s methods are imported as methods on the class, and the module is not inserted into the ancestors chain.
class Array
extend Example
end
Array.ancestors
=> [Array, Enumerable, Object, Kernel, BasicObject]
When we prepend
a module, the module is inserted before the class prepend
ing it in the ancestors chain. The module’s methods will take precedence over the class’ methods.
class Array
prepend Example
end
Array.ancestors
=> [Example, Array, Enumerable, Object, Kernel, BasicObject]
This, in turn, allows us to override the initialize
method, and create the memoization hash before the object is frozen. This is how MemoWise allows memoization of frozen objects.
Determining method visibility
Another important feature in MemoWise is preserving method visibility. If someone using our gem memoizes a private method, we want to guarantee that the memoized method will still be private.
There is actually not a built-in Ruby method to get a method’s visibility. However, it is possible to determine visibility by combining various built-in methods:
if private_method_defined?(method_name)
:private
elsif protected_method_defined?(method_name)
:protected
elsif public_method_defined?(method_name)
:public
else
raise NoMethodError
end
Using this :private
, :protected
or :public
symbol, we can then dynamically set the visibility of our new method to match the original one.
Supporting objects created with allocate as well as new
In testing an early version of this gem, we encountered errors memoizing ActiveRecord
classes. The errors indicated that our @_memo_wise
instance variable wasn’t set, which surprised us because, as mentioned earlier, we set it in initialize
.
After a lot of debugging we learned that there is a little-known way in Ruby to initialize an object without executing its initialize
method! It’s called allocate
, and it’s used by Rails’ ActiveRecord
.
When we call new
on a class, what’s happening under the hood looks something like this:
class Example
def self.new(...)
allocate.tap { |instance| instance.initialize(...) }
end
end
By using allocate
instead of new
, Rails was bypassing our initialization of @_memo_wise
. To fix this we had to overwrite allocate
to also perform this initialization.
Looking Ahead
These are a few of the fun things we’ve learned about Ruby while developing MemoWise, and we plan to write about others in the future! In the meantime, please try it out or read the code on GitHub, and we’re happy to accept contributions.