Hash#fetch
Just like Hash#[]
, Hash#fetch
returns the value
corresponding to a given key. What sets these methods apart is how they handle
keys that can’t be found in the hash: while []
returns the hash’s default
value (nil
by default), fetch
will raise a KeyError
.
This might seem harsh but it can be extremely helpful when trying to figure out why something you expect to be there isn’t.
For example, imagine you’re fetching data from a remote weather API. When parsed into a hash the data looks something like this:
response = {
temp: -3.72,
humidity: 68,
weather: {
id: 80,
description: 'broken clouds'
}
}
Your application might use this data to construct a Weather
object like so:
weather = Weather.new
weather.temperature = response[:temp]
weather.description = response[:weather][:description]
Now, let’s say that the API responses are sometimes missing the weather description. This wouldn’t break the code above but in another part of your application, where the weather information is logged, an exception is raised.
logger.info "It is #{weather.temperature}°C and "\
"#{weather.description.capitalize} in Stockholm"
#=> NoMethodError: undefined method `capitalize' for nil:NilClass
The code above assumes that the weather
object has a description
and it’s
not clear from the error message why it’s missing.
If the code responsible for constructing the weather
object had been using
fetch
, the problem would’ve been detected where it first arose:
weather.description = response.fetch(:weather).fetch(:description)
#=> KeyError: key not found: :description
It’s now clear from the error message that the response is missing the weather
description. This doesn’t solve the problem but it turns out we can use
fetch
for that too.
When passed a block in addition to a key that can’t be found, instead of
raising a KeyError
, fetch
will call the block and return whatever it
returns:
weather.description = response.fetch(:weather).fetch(:description) { 'unknown' }
weather.description #=> "unknown"
This behavior is similar to using []
together with the ||
operator but it
isn’t quite the same:
options = {
false: false,
nil: nil
}
options[:missing] || 'default' #=> "default"
options[:false] || 'default' #=> "default"
options[:nil] || 'default' #=> "default"
options.fetch(:missing) { 'default' } #=> "default"
options.fetch(:false) { 'default' } #=> false
options.fetch(:nil) { 'default' } #=> nil
fetch
only returns the default value if the key can’t be found, not when the
corresponding value is falsey. That might not seem so different but it has some
interesting consequences. For example, it enables you to write code like this:
def inform(name, message, options = {})
greeting = options.fetch(:greeting) { 'Hey' }
print "#{greeting} " if greeting
puts "#{name}, #{message}"
end
inform 'Alice', 'your pancakes are ready!'
#=> Hey Alice, your pancakes are ready!
inform 'Alice', 'your pancakes are ready!', greeting: 'Howdy'
#=> Howdy Alice, your pancakes are ready!
inform 'Alice', 'your pancakes are ready!', greeting: false
#=> Alice, your pancakes are ready!
Another interesting feature of fetch
is that when a key can’t be found and it
calls the given block, it will pass the missing key to the block.
{}.fetch(:missing) { |key| "The key '#{key}' is missing!" }
#=> "The key 'missing' is missing!"
This feature combined with the fact that lambdas and procs can be converted to
blocks using the &
operator makes it possible to reuse the default behavior
for multiple calls to fetch
:
cache = { name: 'Bob' }
fallback = ->(key) { cache[key] = SlowStorage.get(key) }
name = cache.fetch(:name, &fallback)
email = cache.fetch(:email, &fallback)
phone = cache.fetch(:phone, &fallback)
Instead of passing a block to fetch
in order to specify a default value, one
can also pass a second parameter like so:
{}.fetch(:missing, 'default') #=> "default"
This seems to work exactly the same but there is a catch. Let me illustrate this with an example:
require 'benchmark'
def expensive_calculation
sleep 1
end
hash = { present: 'present' }
Benchmark.measure {
hash.fetch(:missing, expensive_calculation)
}.real #=> 1.0053069996647537
Benchmark.measure {
hash.fetch(:present, expensive_calculation)
}.real #=> 1.0013770000077784
Benchmark.measure {
hash.fetch(:missing) { expensive_calculation }
}.real #=> 1.0051070000045002
Benchmark.measure {
hash.fetch(:present) { expensive_calculation }
}.real #=> 0.0000109998509287
Can you spot the difference? When specifying the default value using the second parameter the expensive calculation is done even when the key is present in the hash!
This happens because Ruby evaluates a method’s arguments before calling the method but doesn’t evaluate blocks until they are yielded to.
For this reason, I default to specifying default values using a block when
calling fetch
.
To summarize, Hash#fetch
is exceptionally useful and can make it easier to
discover data inconsistency issues and allows for great flexibility in handling
missing data and providing default values.