Collecting Input
VALID_STATES = [
3.14 Use #fetch for defaults
Indications
A method takes a Hash of values as a parameter. Some hash keys are optional, and should fall back to default values when not specified.
Synopsis
Provide default values for optional hash keys using Hash#fetch.
Rationale
#fetchclearly expresses intent, and avoids bugs that stem from Hash elements which can legitimately be set to nil or false.
Example: Optionally receiving a logger
Here's a method that performs some work. Because the work takes some time, it uses a logger to log its status periodically.
require 'nokogiri' require 'net/http' require 'tmpdir' require 'logger'
def emergency_kittens(options={})
logger = options[:logger] || default_logger
uri = URI("http://api.flickr.com/services/feeds/ photos_public.gne?tags=kittens")
logger.info "Finding cuteness"
body = Net::HTTP.get_response(uri).body feed = Nokogiri::XML(body)
image_url = feed.css('link[rel=enclosure]').to_a.sample['href'] image_uri = URI(image_url)
logger.info "Downloading cuteness"
open(File.join(Dir.tmpdir, File.basename(image_uri.path)), 'w')
do |f|
data = Net::HTTP.get_response(URI(image_url)).body f.write(data)
logger.info "Cuteness written to #{f.path}"
return f.path
end end
def default_logger
l = Logger.new($stdout)
l.formatter = ->(severity, datetime, progname, msg) { "#{severity} -- #{msg}\n"
} l
end
emergency_kittens Here's the output:
INFO -- Finding cuteness INFO -- Downloading cuteness
INFO -- Cuteness written to /tmp/8790172332_01a0aab075_b.jpg
In order to be a good citizen in a larger program, this method allows the default logger object to be overridden with an optional :logger option. So for instance if the calling code wants to log all events using a custom formatter,it can pass in a logger object and override the default.
simple_logger = Logger.new($stdout)
simple_logger.formatter = ->(_, _, _, message) { "#{message}\n"
}
emergency_kittens(logger: simple_logger) Finding cuteness
Downloading cuteness
Cuteness written to /tmp/8796729502_600ac592e4_b.jpg
After using this method for a while, we find that the most common use for the :loggeroption is to suppress logging by passing some kind of "null logger" object. In order to better support this case, we decide to modify the emergency_kittens method to accept a false value for the :logger option. When :logger is set to false, no output should be printed.
logger = options[:logger] || Logger.new($stdout)
if logger == false
logger = Logger.new('/dev/null')
end
Unfortunately, this doesn't work. When we call it with logger: false… emergency_kittens(logger: false)
…we still see log output. INFO -- Finding cuteness INFO -- Downloading cuteness
INFO -- Cuteness written to /tmp/8783940371_87bbb3c7f1_b.jpg
So what went wrong? It's the same problem we saw back in "Use #fetch to assert the presence of Hash keys [page 107]". Because false is "falsey", the statement logger = options[:logger] || Logger.new($stdout)produced the default $stdout logger rather than setting logger to false. As a result the stanza intended to check for a false value and substitute a logger to /dev/null was never triggered.
We can fix this by using Hash#fetch with a block to conditionally set the default logger. Because #fetch only executes and returns the value of the given block if the specified key is missing—not just falsey—an explicit false passed as the value of the :logger option is not overridden.
logger = options.fetch(:logger) { Logger.new($stdout) }
if logger == false
logger = Logger.new('/dev/null')
end
This time, telling the method to suppress logging is effective. When we call it with logger: false…
puts "Executing emergency_kittens with logging disabled..." kitten_path = emergency_kittens(logger: false)
puts "Kitten path: #{kitten_path}" …we see no logging output:
Executing emergency_kittens with logging disabled... Kitten path: /tmp/8794519600_f47c73a223_b.jpg
Using #fetch with a block to provide a default for a missing Hash key is more precise than using an || operator, and less prone to errors when false values are involved. But I prefer it for more than that reason alone. To me, a #fetch with a block is a semantic way of saying "here is the default value".
Reusable#fetchblocks
In some libraries, certain common options may recur over and over again, with the same default value every time. In this case, we may find it convenient to set up the default as a Proc somewhere central and then re-use that common default for each #fetch.
def emergency_kittens(options={})
logger = options.fetch(:logger){ Logger.new($stderr) } # ...
end
def emergency_puppies(options={})
logger = options.fetch(:logger){ Logger.new($stderr) } # ...
end
def emergency_echidnas(options={})
logger = options.fetch(:logger){ Logger.new($stderr) } # ...
end
In this case, we can cut down on duplication by putting the common default code into a Proc assigned to a constant. Then we can use the & operator to tell each call to #fetch to use the common default Proc as its block.
DEFAULT_LOGGER = -> { Logger.new($stderr) }
def emergency_kittens(options={})
logger = options.fetch(:logger, &DEFAULT_LOGGER) # ...
end
def emergency_puppies(options={})
logger = options.fetch(:logger, &DEFAULT_LOGGER) # ...
end
def emergency_echidnas(options={})
logger = options.fetch(:logger, &DEFAULT_LOGGER) # ...
end
If we ever decide that the default logger should print to $stdout instead of $stderr, we can just change it in the one place rather than having to hunt down each instance of a logger being set.
DEFAULT_LOGGER = -> { Logger.new($stdout) } # ...
Two-argument#fetch
If you have some familiarity with the #fetch method you may be wondering why I haven't used the two-argument form in any of these examples. That is, instead of passing a block to #fetch for the default value, passing a second argument instead.
This avoids the slight overhead of executing a block, at the cost of "eagerly" evaluating the default value whether it is needed or not.
Personally, I never use the two-argument form. I prefer to always use the block form. Here's why: let's say we're writing a program and we use the two-argument form of fetch in order to avoid that block overhead. Because the default value is used in more than one place, we extract it into a method.
def default
42 # the ultimate answer
end
answers = {}
answers.fetch("How many roads must a man walk down?", default) # => 42
Later on, we decide to change the implementation of #default to a much more expensive computation. Maybe one that has to communicate with an remote service before returning.
def default
# ...some expensive computation...
end
answers = {}
answers.fetch("How many roads must a man walk down?", default) When the default is passed as an argument to #fetch, it is always evaluated whether it is needed or not. Now our expensive #default code is being executed every time we #fetch a value, even if the value is present. By our premature optimization, we've now introduced a much bigger performance regresssion
everywhere our #default method is used as an argument. If we had used the block form, the expensive computation would only have been triggered when it was
actually needed.
And it's not just about avoiding performance hits. What if we introduce code into the default method which has side-effects, for instance writing some data into a database? We might have intended for those side effects to only occur when the default value is needed, but instead they occur whenever that line of code is hit. I'd rather not have to think about whether future iterations of the defaulting code might be slow or have side effects. Instead, I just make a habit of always using the block form of #fetch, rather than the two-argument form. If nothing else, this saves me the few seconds it would take to choose which form to use every time I type a #fetch. Since I use #fetch a lot, this time savings adds up!
Conclusion
Like using #fetch to assert the presence of Hash elements, using it to provide default values for missing keys expresses intent clearly, and avoids bugs that arise in cases where false or nil is a valid value. The fact that the default is expressed with a block means that a common default can easily be shared between multiple #fetchcalls. Defaults can also be provided as a second argument to #fetch, but this form offers little advantage, and has potential pitfalls.