42

I feel like this could be improved (a common feeling in ruby). I'm trying to uniq an array of hashes based on value. In this example, I want the colors of the elements. Moss and snow are impostors.

# remove unique array of hashes based on a hash value

a = [
  { :color => "blue", :name => "water" },
  { :color => "red", :name => "fire" },
  { :color => "white", :name => "wind" },
  { :color => "green", :name => "earth" },
  { :color => "green", :name => "moss" },
  { :color => "white", :name => "snow" }
]

# remove moss and snow
uniques = []
a.each_with_index do |r, i|
  colors = uniques.collect {|e| e[:color]}

  if !colors.include? r[:color]
    uniques.push r
  else
    a[i] = nil
  end
end

a.compact!

puts a

This will print

{:color=>"blue", :name=>"water"}
{:color=>"red", :name=>"fire"}
{:color=>"white", :name=>"wind"}
{:color=>"green", :name=>"earth"}

Which is "correct" however I feel like this is excessive. My experience with .map .inject is limited and those advanced techniques elude me. If someone could re-factor this, it might help me understand another terse technique.

squarism
  • 3,242
  • 4
  • 26
  • 35

3 Answers3

102

In Ruby 1.9, try the following

a.uniq! {|e| e[:color] }
Steve Wilhelm
  • 6,200
  • 2
  • 32
  • 36
  • 1
    It's not solving the OPs question. While it filters unique colors, it doesn't reject the non-elements if the array order is different. – the Tin Man Jan 30 '11 at 05:31
  • 1
    I answered the OPs original question "to uniq an array of hashes based on value" or as the quote states "remove unique array of hashes based on a hash value." What is ambiguous is the example: it removes "moss and snow," but does not say why they were removed. I assumed they were removed because they were duplicate colors: that is why the original code removed them. @the Tin Man assumes they were removed because they are not elements due to the example's explanation. – Steve Wilhelm Jan 30 '11 at 06:49
  • "assumes they were removed because they are not elements due to the example's explanation ", No, the OP said, "In this example, I want the colors of the elements. Moss and snow are impostors.", and in the example shows that they have been removed. The elements are "fire", "air", "earth" and "water". This solution fails if the array changes; An example is at the end of my answer. – the Tin Man Jan 30 '11 at 07:17
  • I'm sorry, I should have specified that the order in my example data is significant. In my specific case, I sort them and don't mind if I lose the second value. Thanks for the more general solution Tin Man, it's certainly more reliable. – squarism Jan 30 '11 at 17:00
  • Just a note to mention that there's a nondestructive version as well: `#uniq { ... }`. – Eric Walker Dec 02 '16 at 17:56
6

I'd go with Array's reject or select methods:

require 'pp'

a = [
  { :color => "blue", :name => "water" },
  { :color => "red", :name => "fire" },
  { :color => "white", :name => "wind" },
  { :color => "green", :name => "earth" },
  { :color => "green", :name => "moss" },
  { :color => "white", :name => "snow" }
]

pp a.reject{ |h| %w[moss snow].include?( h[:name]) } 
# >> [{:color=>"blue", :name=>"water"},
# >>  {:color=>"red", :name=>"fire"},
# >>  {:color=>"white", :name=>"wind"},
# >>  {:color=>"green", :name=>"earth"}]

Alternately you can be positive about it and select the ones you want to keep:

pp a.select{ |h| %w[water fire wind earth].include?( h[:name] ) } 
# >> [{:color=>"blue", :name=>"water"},
# >>  {:color=>"red", :name=>"fire"},
# >>  {:color=>"white", :name=>"wind"},
# >>  {:color=>"green", :name=>"earth"}]

You're not really dealing with hashes, it's an array that happens to contain hashes, so don't let them confuse you. Array methods like reject and select are core methods for filtering out unwanted, or keeping wanted, elements.

In your code sample, you're losing sight of what your objective is: You want the elements, rejecting "moss" and "snow", which are non-elements. Filter out the non-elements, and you're left with the correct/real elements in the hashes. From there you can extract the correct colors.

An additional problem to watch out for with using uniq, is it is positional, in other words, it looks for the first unique value and rejects subsequent ones. This wasn't apparent in your code because your array was consistently the same order as you tested. If you shuffled the order though...:

2.times do
  pp a.shuffle.uniq{ |h| h[:color] }
end

Pass #1...

# [{:color=>"red", :name=>"fire"},
#  {:color=>"white", :name=>"wind"},
#  {:color=>"green", :name=>"moss"},
#  {:color=>"blue", :name=>"water"}]

Pass #2...

# [{:color=>"green", :name=>"earth"},
#  {:color=>"blue", :name=>"water"},
#  {:color=>"red", :name=>"fire"},
#  {:color=>"white", :name=>"snow"}]

Suddenly we see that both "moss" and "snow" are sneaking into the results even though the colors are unique. Those are subtle gotcha's that you have to watch out for.

the Tin Man
  • 158,662
  • 42
  • 215
  • 303
0

For anyone who might want an even shorter variant of the correct answer by Steve Wilhelm ,

BEWARE:

a.uniq!(&:color)

WILL NOT WORK for an array of hashes, just like

a[1].color

wouldn't work either.

For more information on the & operator, read this link, or the comments on this question which in turn have plenty of links to resources.

On the other hand, you could get the Symbol#to_proc method working using lambdas, as is explained here, though it could be just complicating things, and certainly would not be a shorter version of the correct answer. However, it is very interesting knowledge.

Thanks mukesh-kumar-gupta for the heads-up

user2553863
  • 682
  • 1
  • 8
  • 17