In Progress
Unit 1, Lesson 21
In Progress

Fetch Values

Video transcript & code

Let's pretend we're working on some kind of code editor. Any good editor allows users to configure preferences such as the fonts and colors to use. This particular application takes it one step further and supports "cascading" configurations at the system, user, and individual project levels.

There are three keys for pulling preferences out of the overall configuration: system_prefs, user_prefs, and project_prefs.

These correspond to three hashes stored in the global config. In this example, there is a system-wide font and text color defined. Then the current user has overridden just the font setting. Finally, the current project has a customized error color.

keys = %i[system_prefs user_prefs project_prefs]
configuration = {
  system_prefs:       { font: "Ubuntu Mono", text_color: "wheat" },
  user_prefs:         { font: "Source Code Pro" },
  project_prefs:      { error_color: "brick" }
}

In order to compile the these different levels of preferences into a single active set of preferences, we first extract each hash using #values_at. For the keys, we splat out our list of preference keys. This returns an array of the values corresponding to the given keys.

prefs_cascade = configuration.values_at(*keys)

We've listed the keys in order of priority, from lowest to highest, and that's the order that the values are returned in.

This ordering means that we can easily collapse them into a final preferences hash using reduce.

The block we pass to #reduce receives the last result and the next hash. We merge the next hash into the last, overriding any identical keys from the earlier, lower-priority hash.

The result is a merged, final preferences set.

prefs = prefs_cascade.reduce{|result, prefs|
  result.merge(prefs)
}

The code inside the block has a shape that might ring a bell. Whenever we see a block that consists of a single message send, where the first block argument is the receiver and the rest of the block parameters are used as message arguments, we can replace the block with an ampersand argument.

prefs = prefs_cascade.reduce(&:merge)

Up til now, we've been working with a complete set of preference hashes. But what if the user preferences are not specified?

configuration = {
  system_prefs:       { font: "Ubuntu Mono", text_color: "wheat" },
  # user_prefs:         { font: "Source Code Pro" },
  project_prefs:      { error_color: "brick" }
}

Now when we create our ordered list of preference hashes, the position corresponding to #user_prefs is now occupied by nil.

prefs_cascade = configuration.values_at(*keys)

This is a problem, as we can see when we look at the result of executing the next line.

prefs = prefs_cascade.reduce(&:merge)

Trying to merge a hash with nil has caused an exception to be raised. This is one of those errors we hate to see: a mysterious type error without any history to show us where the nil came from, or what we were expecting in its place.

We could eliminate the nil by compacting the array:

prefs_cascade.compact!
prefs_cascade

But I always think of this as a tactic of last resort. Anywhere I'm removing nils is code where, when I return to it later, I'll have to figure out why the nils were there in the first place. Much better to ensure they don't crop up at all.

In Ruby 2.3, we have a new tool to help us here. Much like the Hash#fetch method we've explored in episodes #8, #12, and #15, Ruby hashes now support a #fetch_values method.

When we convert our values_at message to #fetch_values, our code fails earlier and in a more informative way:

prefs_cascade = configuration.fetch_values(*keys)

This time, the error spells out the problem: we were missing an expected :user_prefs key.

Should this really be an error? Probably not. Instead of raising an exception for missing keys, we can provide a block to supply default values when a key isn't found. Let's just fill in an empty hash for when a value isn't found.

prefs_cascade = configuration.fetch_values(*keys){ {} }

An empty hash inside a curly-braced block isn't the most readable idiom in the world. This is one situation where I think it's reasonable to switch to an explicit invocation of the Hash constructor method, just for clarity.

prefs_cascade = configuration.fetch_values(*keys){ Hash.new }

Now when we gather our preference hashes, the place of the missing user prefs is held by an empty hash.

When we merge together our preference cascade, we get no errors.

prefs = prefs_cascade.reduce(&:merge)

Being able to supply a default value for missing keys is nice, but what if we want to do more than just generate a blank hash as a default? For instance, let's say we have a set of static defaults for each level in the preference cascade. By default the project and user levels are blank, and the system level has some sensible out-of-box settings.

DEFAULTS = {
  project_prefs:      {},
  user_prefs:         {},
  system_prefs: { font: "monospace", text_color: "white", error_color: "red" }
}

Now, instead of just replacing missing values with empty hashes, we want to use this constant as the source for defaults. To accomplish this, we add a block argument to our #fetch_values block, which will receive the name of the missing key. Then we look up that key in the global defaults hash.

This doesn't cause any immediate changes in the results of our code.

prefs_cascade = configuration.fetch_values(*keys) { |key|
  DEFAULTS[key]
}

But now lets comment out the line establishing system-level preferences.

configuration = {
  # system_prefs:       { font: "Ubuntu Mono", text_color: "wheat" },
  user_prefs:         { font: "Source Code Pro" },
  project_prefs:      { error_color: "brick" }
}

Watch what happens now when we assemble our array of preference sets.

prefs_cascade = configuration.fetch_values(*keys) { |key|
  DEFAULTS[key]
}

Now, the missing system preferences have been replaced by the program defaults for that key.

There's one more bit of tidying up we can do. Back in episode #371, we learned about another new feature of hashes in Ruby 2.3: hashes are now implicitly convertible to procs.

This means that instead of explicitly doing a hash lookup inside the block, we can just pass the hash with an ampersand, as if it were a lambda.

The outcome is the same: missing keys are supplied by the hash in the DEFAULTS constant.

prefs_cascade = configuration.fetch_values(*keys, &DEFAULTS)

And that's it for today. Happy hacking!

Responses