When you use something as much as Ruby developers use Hashes, it's easy to think you've seen it all.
But I'm here to tell you that the humble ruby Hash has a few tricks up its sleeve. Far from being a dumb key-value system, the Hash object gives you the power to do some very interesting and sophisticated things.
Any object can be a hash key
Before we go any farther I'd like to point out one thing that may not be obvious. While we tend to use strings and symbols as hash keys, that doesn't mean we can't use other kinds of objects as well. In fact, you can use almost anything as a hash key.
# Numbers can be hash keys
{1 => "one"}[1] # "one"
# So can the Ruby kernel
{Kernel => 1}[Kernel] # 1
# You can store values for specific classes
{Kernel => 1, String => 2}["hello world".class] # 2
# You can store values for booleans
{true => "verdad"}[1==1] # "verdad"
# You can even use complex arrays and even other hashes as hash keys
{[[1,0],[0,1]] => "identity matrix"}[[[1,0], [0,1]]] # "identity matrix"
Some of these options are more useful than others, but they're all available to you.
You have control over the default value.
Suppose you have a hash h={ a: 1 }
. If you try to access a value that doesn't exist - for example h[:x]
- you get nil. That's because nil is the default value of every hash, unless you specify otherwise.
You can set the default value for a new hash by passing an argument into its constructor.
h = Hash.new("This attribute intentionally left blank")
h[:a] = 1
h[:a] # 1
h[:x] # "This attribute intentionally left blank"
Dynamic default values
Pay attention, because this is trick is the foundation of everything that follows.
If you pass a block into the constructor, you can generate default values programmatically. In the example below I've added a timestamp to the default value, so you can see that it's being dynamically generated.
h = Hash.new { |hash, key| "#{key}: #{ Time.now.to_i }" }
h[:a] # "a: 1435682937"
h[:a] # "a: 1435682941"
h[:b] # "b: 1435682943"
This is important because the "default value" block can do things other than return a default value.
Raising an exception if a hash key isn't present
One of the main problems with hashes is that they fail silently. You accidentally type in user[:phnoe]
instead of user[:phone]
, and instead of raising an exception, the hash returns nil. But you can change this behavior.
h = Hash.new { |hash, key| raise ArgumentError.new("No hash key: #{ key }") }
h[:a]=1
h[:a] # 1
h[:x] # raises ArgumentError: No hash key: x
This technique can be useful for debugging and refactoring because it applies to a specific hash. It's a much less intrusive way to add this behavior than something like monkey-patching the Hash class would be.
Note: I'm not suggesting that anyone use this in place of Hash.fetch in new code. It's just an interesting trick to have up your sleeve for debugging and refactoring.
Lazily-generated lookup tables
This technique useful for caching the results of a computation. Imagine that you need to calculate a lot of square roots. You could create a lazily-populated lookup table like the example below.
sqrt_lookup = Hash.new { |hash, key| hash[key] = Math.sqrt(key) }
sqrt_lookup[9] # 3.0
sqrt_lookup[7] # 2.6457513110645907
sqrt_lookup # {9=>3.0, 7=>2.6457513110645907}
Recursive lazy lookup tables
Suppose you have a recursive function and you want to cache the result of each recursion. Let's take a factorial calculation as an example. "Four factorial", aka "4!" is just another way of saying "4x3x2x1." You could implement this recursively using a hash. The example below, which I've taken from this blog post demonstrates it nicely:
factorial = Hash.new do |h,k|
if k > 1
h[k] = h[k-1] * k
else
h[k] = 1
end
end
factorial[4] # 24
factorial # {1=>1, 2=>2, 3=>6, 4=>24}
Modifying defaults after initialization
You can also control the default value after a hash has been created. To do this use the default
and default_proc
setters.
h={}
h[:a] # nil
h.default = "new default"
h[:a] # "new default"
h.default_proc = Proc.new { Time.now.to_i }
h[:a] # 1435684014
Find the Ruby: A game of lazily infinite nested hashes
Just for fun, let's wrap all of these useful techniques into one extremely useless example. Remember that old text-based game Adventure? Let's build the stupidest version of it ever.
Imagine you're in a cave. You can go north, south, east or west. Three of these choices take you to a new "room" of the cave where you keep exploring. But one choice leads you to a "ruby." Hence the name of the game: "find the ruby."
Each room in the cave corresponds to a hash. The hash only has one entry. One of ["n", "s", "e", "w"] chosen at random, has the value "You found the ruby." If you choose incorrectly a new hash is created and added to the tree.
generator = Proc.new do |hash, key|
hash[key] = Hash.new(&generator).merge(["n", "s", "e", "w"][rand(4)] => "You found the ruby!")
end
dungeon = Hash.new(&generator)
dungeon["n"] # <Hash ...
dungeon["n"]["s"] # <Hash ...
dungeon["n"]["s"]["w"] # "You found the ruby!"