Module Progress
0% Complete

In a previous episode, we looked at how the #fetch method on Hash 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 `
'

But what if the KeyError 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 #fetch 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 
': Invalid auth data (missing email).See https://github.com/intridea/omniauth/wiki/Auth-Hash-Schema (RuntimeError) # ~> from -:8:in `fetch' # ~> from -:8:in `
'

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'){ '[email protected]' }
email_address # => "[email protected]"

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
# => #,
#     @formatter=nil,
#     @level=0,
#     @logdev=
#      #>,
#       @filename=nil,
#       @mutex=
#        #,
#         @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
# => #,
#     @formatter=nil,
#     @level=0,
#     @logdev=
#      #>,
#       @filename=nil,
#       @mutex=
#        #,
#         @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
# => #

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 #fetch to provide the default value.

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

Responses