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 Hash#fetch
.
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 #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 `<main>'
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 <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 #fetch
to provide the default value.
OK, that’s all for today. Happy hacking!
Top comments (0)