Understanding Ruby Refinements by implementing hash#with_indifferent_access
As Rails developers, most of us must have come across this handy method, Hash#with_indifferent_access, another instance of "rails-magic" that lets us access values from a ruby hash using either strings or symbols.
require 'active_support/core_ext/hash'
:0> π = {
:1* make: 'emoji',
:1* model: 'Red-car'
:1> }.with_indifferent_access
=> {"make"=>"emoji", "model"=>"red-car"}
:0> puts π[:make]
=> "emoji"
:0> puts π["make"]
=> "emoji"
In this post, I will be discussing three different ways to implement this functionality and compare and contrast each of these techniques.
Patching the core Hash class / Monkey Patch
The most straightforward approach of the three is implementing this functionality by patching the core Hash class.
class Hash
def [](key)
dig(key.to_s) || dig(key.to_sym)
end
def []=(key, value)
dig(key.to_sym) ? store(key.to_sym, value) : store(key.to_s, value)
end
end
In the above code snippet, we are opening and re-implementing a core class. A side effect of this approach is, from that point when this class is loaded, all instances of Hash implements this new behavior and thereby violates the Open/Closed Principle (OCP).
A technique to extend the behavior of Hash without violating OCP is by using inheritance. This is also how Rails provides indifferent access.
The Rails Way
If we open Rails console and check the class name of the object returned by calling #with_indifferent_access, we see that, it is no longer Hash, but it is ActiveSupport::HashWithIndifferentAccess.
[1] pry(main)> {}.class.name
=> "Hash"
[2] pry(main)> {}.with_indifferent_access.class.name
=> "ActiveSupport::HashWithIndifferentAccess"
If we dig up the source code we can see that, HashWithIndifferentAccess inherits from Hash.
module ActiveSupport
class HashWithIndifferentAccess < Hash
def initialize
# ...
end
def [](key)
dig(key.to_s) || dig(key.to_sym)
end
def []=(key, value)
dig(key.to_sym) ? store(key.to_sym, value) : store(key.to_s, value)
end
# NOTE:
# The actual HashWithIndifferentAccess implementation
# does a lot more than re-implement #[] and #[]=
end
end
class Hash
def with_indifferent_access
HashWithIndifferentAccess.new(self)
end
end
This technique doesnβt change the implementation of any of the interfaces of the core class, but has a major downside, which is one needs to install and use ActiveSupport gem.
While working on a gem, I thought it would be a bit too much to include ActiveSupport as a dependency just for the ability to access a hash value with either a string or a symbol and that is when I came across Ruby Refinements.
Let's Refine Hash!
Ruby Refinements, as the name suggests allow us to modify the functionality of a class with very minimal impact. In fact, refinements were introduced to minimize the impact of monkey-patching core classes.
module DifferentlyAccessibleHash
refine Hash do
def [](key)
dig(key.to_s) || dig(key.to_sym)
end
def []=(key, value)
dig(key.to_sym) ? store(key.to_sym, value) : store(key.to_s, value)
end
end
end
Take a note of the Module#refine keyword in line#2. Adapting Ruby documentation for our example above, we have the following.
- Module#refine keyword is used to refine an existing class Hash.
- Module#refine creates an anonymous module and this anonymous module can be "activated" by employing the using keyword.
This is a bit confusing, correct? Let's try to explain this with a code snippet.
# differently_accessible_hash.rb
module DifferentlyAccessibleHash
refine Hash do
def [](key)
dig(key.to_s) || dig(key.to_sym)
end
def []=(key, value)
dig(key.to_sym) ? store(key.to_sym, value) : store(key.to_s, value)
end
end
end
# car.rb
require './differently_accessible_hash'
class EmojiCar
using DifferentlyAccessibleHash
def initialize
@specs = {
make: 'emoji',
model: 'Red-car'
}
end
def make
@specs[:make] # access make via symbol
end
def model
@specs['model'] # access model via string
end
end
Even though Module#refine creates an anonymous module with our new functionality against Hash, this module is not activated until the usage of using keyword.
Another advantage of the using keyword is that, it is lexically scoped. From the documentation.
- You may activate refinements at top-level, and inside classes and modules.
- You may not activate refinements in method scope.
- Refinements are activated until the end of the current class or module definition, or until the end of the current file if used at the top-level.
- Refinements are only active within a scope after the call to using. Any code before the using statement will not have the refinement activated
In our example code, since we have "activated" DifferentlyAccessibleHash in the class level, any Hash objects can be accessed with either string or symbol within that class.
Additional Reading
Refine and Using Methods in Ruby by Mehdi Farsi
So whatβs the deal with Ruby refinements, anyway? by Avdi Grimm