Maintaining order of iteration on a Hash Ruby 1.8.7?

1.1k views Asked by At

This is a simple homework assignment....

Given a hash that looks like this:

cost_of_groceries = {
"milk" => 3.50,
"egg" => 1.50,
"broccolli" => 0.75
}

I want to print out which groceries are less than $2 and which groceries are more than $2. This would be a sample output:

milk is more than $2
eggs is less than $2
broccolli is less than $2

Not a problem, but this is not printing in the correct order using Ruby 1.8.7.

My code:

cost_of_groceries.each do |x,y|
  if y > 2
    puts "#{x} is more than $2"
  else
    puts "#{x} is less than $2"
  end
end

This is what I get:

broccolli is less than $2
egg is less than $2
milk is more than $2
=> {"broccolli"=>0.75, "egg"=>1.5, "milk"=>3.5}

I realize pre-1.9 Ruby does not maintain the order of iteration on a Hash, and I know I can just use different version to solve this, but I was hoping to dig into this and learn an alternate method for pre-1.9.3. I never know when it might come in handy.

This is a similar post: "Ruby maintain Hash insertion order"

4

There are 4 answers

2
the Tin Man On

Ruby prior to 1.9 didn't maintain the "insertion" order of hashes. Here's a way to force a known order, without relying on sorting:

BASE_COST = 2.0

COST_OF_GROCERIES = {
  "milk"      => 3.50,
  "egg"       => 1.50,
  "broccolli" => 0.75
}

DESIRED_ORDER = %w[milk egg broccolli]
COST_OF_GROCERIES.values_at(*DESIRED_ORDER) # => [3.5, 1.5, 0.75]

That returns just the values, in their desired order.

Here's how to process the hash in that same order:

DESIRED_ORDER.each do |k|
  lt_gt = COST_OF_GROCERIES[k] > BASE_COST ? 'more' : 'less'
  puts '%s is %s than %0.2f' % [k, lt_gt, BASE_COST]
end
# >> milk is more than 2.00
# >> egg is less than 2.00
# >> broccolli is less than 2.00

Here's another way to look at it...

Enumerable's zip lets us join two arrays' elements, interweaving them:

DESIRED_ORDER.zip(COST_OF_GROCERIES.values_at(*DESIRED_ORDER)) # => [["milk", 3.5], ["egg", 1.5], ["broccolli", 0.75]]

We can pass the output from zip to map to add in whether the prices is "more" or "less":

groceries = DESIRED_ORDER.zip(COST_OF_GROCERIES.values_at(*DESIRED_ORDER)).map{ |grocery, price| 
  [
    grocery, 
    price, 
    price > BASE_COST ? 'more' : 'less'
  ] 
} 
groceries # => [["milk", 3.5, "more"], ["egg", 1.5, "less"], ["broccolli", 0.75, "less"]]

Look at the contents of groceries: An array-of-arrays is exactly the sort of data you'd want to pass to a view if you were rendering a web-page using ERB or Haml.

Then we can generate some output strings and print them:

puts groceries.map{ |ary|
  '%s, at $%.2f is %s than $%0.2f' % [*ary, BASE_COST]
}
# >> milk, at $3.50 is more than $2.00
# >> egg, at $1.50 is less than $2.00
# >> broccolli, at $0.75 is less than $2.00

Using a format-string is akin to an ERB or Haml template. This is just a couple steps away from how ERB/Haml got their start.

I broke the above into smaller steps, but the actual process could be written:

puts DESIRED_ORDER.zip(COST_OF_GROCERIES.values_at(*DESIRED_ORDER)).map{ |grocery, price| 
  [
    grocery, 
    price, 
    price > BASE_COST ? 'more' : 'less'
  ] 
}.map{ |ary|
  '%s, at $%.2f is %s than $%0.2f' % [*ary, BASE_COST]
}
# >> milk, at $3.50 is more than $2.00
# >> egg, at $1.50 is less than $2.00
# >> broccolli, at $0.75 is less than $2.00
0
Chuck On

Basically, to iterate a hash with an arbitrary ordering, the best approach is usually to specify the order with an array of keys and loop over that. So you'd have an array like ['eggs', 'milk', 'broccoli'], and you'd access the hash object for the appropriate key.

0
Cary Swoveland On

I thought it might be an interesting exercise to maintain order by subclassing Hash. []=(other) can be used to add or change elements, and delete can be used to remove them, but I have not attempted to override other hash methods that add or delete elements:

class MyHash < Hash
  def initialize(*vals)
    pairs =  vals.each_slice(2).to_a
    @keys = pairs.map(&:first)
    pairs.each_with_object(super()) {|(k,v), h| h[k] = v}
  end

  def []=(key,val)
    @keys << key unless @keys.include?(key)
    super
  end

  def delete(key)
    @keys.delete(key)
    super
  end

  def print_prices(cutoff)
    @keys.each do |k|
      if self[k] < cutoff
         puts "#{k} is less than %0.2f" % [cutoff]
      else
         puts "#{k} is at least %0.2f" % [cutoff]      
      end    
    end
  end  
end

h = MyHash.new("milk", 3.50, "egg", 1.50, "broccolli", 0.75)
  # => {"milk"=>3.5, "egg"=>1.5, "broccolli"=>0.75}

h.print_prices(2.00)         
milk is at least 2.00
egg is less than 2.00
broccolli is less than 2.00

h["fish"] = 2.00
h["egg"] = 2.10
h.delete("milk")

h.print_prices(2.00)         
egg is at least 2.00
broccolli is less than 2.00
fish is at least 2.00
0
imagineux On

this is what I have been able to mashup: it works but violates the context of homework question:

hash={}

hash.instance_eval do
  def []=(key,val)
    ordered_keys << key
    super(key,val)
  end

  def ordered_keys
    @ordered_keys ||= []
  end

  def each_in_order(&block)
    ordered_keys.each do |key|
      yield(key, self[key])
    end
  end
end


hash['milk'] = 3.50
hash['egg'] = 1.50
hash['broccolli'] = 0.75



hash.each_in_order do |key, val|
  if val > 2
    puts "#{key} is more Than $2"
  else
    puts "#{key} is less than $2"
  end    
end