In Progress
Unit 1, Lesson 21
In Progress

Hash Merge

Video transcript & code

Ruby never stops surprising and delighting me. Just the other day I learned about a capability of Ruby Hashes I'd never known about before. Today I want to share that discovery with you.

Here are some server-sent HTTP headers.

headers = <<END
Accept: */*
Set-Cookie: foo=42
Set-Cookie: bar=23
END

Here's some code to parse the headers into a hash of header name/value pairs. It iterates over each line of the headers using #reduce. Each line gets split into name and value on the colon, and the resulting pair is then merged into the result hash.

def parse_headers(headers)
  headers.lines.reduce({}) do |result, line|
    name, value = line.split(":")
    result.merge(name.strip => value.strip)
  end
end

Let's apply this method to our headers.

p parse_headers(headers)

This implementation has a fairly egregious bug. The HTTP spec permits headers to be repeated, and our sample headers contains an example of this kind of repetition: there are two "Set-Cookie" headers. HTTP clients are expected to collect the values of all headers with the same name. But our implementation simply throws the previous value away when it hits a new header with the same name.

A better implementation might collect value of repeated headers into an array. Let's see if we can make this happen.

What we need is a way to merge hashes with special handling for name collisions. Until recently I would have rewritten the code to use something other than the Hash#merge method. But as it turns out, Ruby has anticipated this scenario.

We can supply an optional block to the #merge method. When there's a key collision, this block will be used to determine how to resolve it. As block arguments it receives the key, the value from the Hash on the left side of the merge, and the value from the Hash on the right-hand side. The return value of the block is used as the value in the merged hash.

We'll write our merge block to combine both values into an array. We use the Array() conversion function to ensure the code will work whether the original value is an array or a scalar value.

When we run this version, we can see that it combines the multiple Set-Cookie header values into a combined array.

def parse_headers(headers)
  headers.lines.reduce({}) do |result, line|
    name, value = line.split(":")
    result.merge(name.strip => value.strip) {
      |key, left, right|
      Array(left) + Array(right)
    }
  end
end

p parse_headers(headers)

Let's look at some other uses of a block passed to #merge.

Here are some hashes containing lunch orders from separate departments in a company. The keys are menu items, and the values indicate how many of that item are desired. Let's merge these hashes into a combined order. This time, we'll resolve duplicated keys by adding order quantities together.

accounting = {
  "burger" => 3,
  "cheesesteak" => 1,
  "veggie wrap" => 2
}

engineering = {
  "burger" => 2,
  "gyro" => 3
}

marketing = {
  "burger" => 1,
  "veggie wrap" => 2,
  "gyro" => 1
}

order = [accounting, engineering, marketing].reduce({}) {
  |result, dept|
  result.merge(dept) {
    |key, left, right|
    left + right
  }
}
order
# => {"burger"=>6, "cheesesteak"=>1, "veggie wrap"=>4, "gyro"=>4}

One more example. If you use Rails, you might be familiar with the Hash#reverse_merge method added by ActiveSupport. This method acts like #merge without a block, except that instead of the right-side hash overriding keys in the left-side hash, the left side overrides the right. This is useful for adding defaults into a hash of options while still giving any explicitly-specified options precedence. Here's an example where we reverse-merge some defaults into an options hash.

require "active_support/core_ext"

options = { genre: "drum&bass" }
full_options = options.reverse_merge(genre: "trance", bpm: 140)
full_options
# => {:genre=>"drum&bass", :bpm=>140}

We can get the same effect without ActiveSupport using a block passed to #merge. In the block we just ignore the right-hand value entirely, and always use the left-hand one.

options = { genre: "drum&bass" }
full_options = options.merge(genre: "trance", bpm: 140){|_, left, _| left}
full_options
# => {:genre=>"drum&bass", :bpm=>140}

So there you go: by passing a block to #merge, we can easily achieve any Hash-merging strategy we can think up. Happy hacking!

Responses