Why and how to use Ruby’s Hash#fetch for default values

Here’s another classic from the early days of RubyTapas. Originally published as Episode #11 in October 2012, it’s a complement to the episode on using fetch as an assertion. This episode digs into the difference between using the || operator for defaults vs. using <a href="https://ruby-doc.org/core-2.7.2/Hash.html#method-i-fetch">Hash#fetch</a>.

Director’s commentary: I can see some tiny advancements in quality here. At this point I’d realized that my original comment color was nearly unreadable on the dark background, and brightened it up a bit.

But my voiceover still sounds kind of bored. And was still content to have long sections of silence while I put new code on the screen. These days, I try to always accompany coding with simultaneous explanation.

Read on for the original script and code…


In a previous episode, we looked at how the <a href="https://www.rubytapas.com/out/ruby--hash-fetch">#fetch</a> method on <a href="https://www.rubytapas.com/out/ruby--hash">Hash</a> can be used to assert that a given hash key is present.

auth = {
  'uid'  => 12345,
  'info' => {
  }
}

# ...

email_address = auth['info'].fetch('email')
# ~> -:11:in `fetch': key not found: "email" (KeyError)
# ~>    from -:11:in `<main>'

But what if the <a href="https://www.rubytapas.com/out/ruby--keyerror">KeyError</a> that Hash raises doesn’t provide enough context for a useful error message?

Along with the key to fetch, the #fetch method can also receive an optional block. This block is evaluated if, and only if, the key is not found.

Knowing this, we can pass a block to <a href="https://www.rubytapas.com/out/ruby--hash-fetch">#fetch</a> which raises a custom exception:

auth['uid'] # => 12345
auth['info'].fetch('email') do 
  raise "Invalid auth data (missing email)."\
        "See https://github.com/intridea/omniauth/wiki/Auth-Hash-Schema"
end
email_address = auth['info'].fetch('email')
# ~> -:10:in `block in <main>': Invalid auth data (missing email).See https://github.com/intridea/omniauth/wiki/Auth-Hash-Schema (RuntimeError)
# ~>    from -:8:in `fetch'
# ~>    from -:8:in `<main>'

Now when this code encounters an unexpectedly missing key, the raised exception will explain both the problem, and where to find more information.

The block argument to #fetch isn’t just for raising errors, however. If it doesn’t raise an exception, #fetch will return the result value of the block to the caller, meaning that #fetch is also very useful for providing default values. So, for instance, we can provide a default email address when none is specified.

email_address = auth['info'].fetch('email'){ 'anonymous@example.org' }
email_address # => "anonymous@example.org"

Now, you may be wondering: what’s the difference between using #fetch for defaults, and using the || operator for default values? While these may seem equivalent at first, they actually behave in subtly, but importantly different ways. Let’s explore the differences.

Here’s an example of using the || operator for a default. This code receives an options hash, and uses the :logger key to find a logger object. If the key isn’t specified, it creates a default logger to $stdout. If the key is nil or false, it disables logging by substituting a NullLogger object.

This works fine when we give it an empty Hash.

require 'logger'

class NullLogger
  def method_missing(*); end
end

options = {}
logger = options[:logger] || Logger.new($stdout) 
unless logger
  logger = NullLogger.new
end
logger
# => #<Logger:0x000000030545a8
#     @default_formatter=
#      #<Logger::Formatter:0x00000003054580 @datetime_format=nil>,
#     @formatter=nil,
#     @level=0,
#     @logdev=
#      #<Logger::LogDevice:0x00000003054530
#       @dev=#<IO:<STDOUT>>,
#       @filename=nil,
#       @mutex=
#        #<Logger::LogDevice::LogDeviceMutex:0x00000003054508
#         @mon_count=0,
#         @mon_mutex=#<Mutex:0x000000030544b8>,
#         @mon_owner=nil>,
#       @shift_age=nil,
#       @shift_size=nil>,
#     @progname=nil>

But when we pass false as the value of :logger, we get a surprise:

options = {logger: false}
logger = options[:logger] || Logger.new($stdout) 
unless logger
  logger = NullLogger.new
end
logger
# => #<Logger:0x000000040bb608
#     @default_formatter=
#      #<Logger::Formatter:0x000000040bb5e0 @datetime_format=nil>,
#     @formatter=nil,
#     @level=0,
#     @logdev=
#      #<Logger::LogDevice:0x000000040bb590
#       @dev=#<IO:<STDOUT>>,
#       @filename=nil,
#       @mutex=
#        #<Logger::LogDevice::LogDeviceMutex:0x000000040bb568
#         @mon_count=0,
#         @mon_mutex=#<Mutex:0x000000040bb518>,
#         @mon_owner=nil>,
#       @shift_age=nil,
#       @shift_size=nil>,
#     @progname=nil>

That was supposed to be a NullLogger, not the default logger!

So what happened here? The problem with using || with a Hash for default values is that it can’t differentiate between a missing key, versus a key whose value is nil or false. Here’s some code to demonstrate:

{}[:foo] || :default             # => :default
{foo: nil}[:foo] || :default     # => :default
{foo: false}[:foo] || :default   # => :default

In contrast, #fetch only resorts to the default when the given key is actually missing:

{}.fetch(:foo){:default}             # => :default
{foo: nil}.fetch(:foo){:default}     # => nil
{foo: false}.fetch(:foo){:default}   # => false

When we switch to using #fetch in our logger-defaulting code, it works as intended.

options = {logger: false}
logger = options.fetch(:logger){Logger.new($stdout)}
unless logger
  logger = NullLogger.new
end
logger
# => #<NullLogger:0x00000003b73858>

When you want to provide default value for a missing hash key, consider carefully whether you want an explicitly supplied nil or false to be treated the same as a missing key. If not, use <a href="https://www.rubytapas.com/out/ruby--hash-fetch">#fetch</a> to provide the default value.

OK, that’s all for today. Happy hacking!

Leave a Reply

Your email address will not be published. Required fields are marked *