Ruby Set with custom class to equal basic strings

Viewed 96

I want to be able to find a custom class in my set given just a string. Like so:

require 'set'

Rank = Struct.new(:name, keyword_init: true) {
  def hash
    name.hash
  end
  def eql?(other)
    hash == other.hash
  end
  def ==(other)
    hash == other.hash
  end
}
one = Rank.new(name: "one")
two = Rank.new(name: "two")
set = Set[one, two]

but while one == "one" and one.eql?("one") are both true, set.include?("one") is still false. what am i missing?

thanks!

2 Answers

Set is built upon Hash, and Hash considers two objects the same if:

[...] their hash value is identical and the two objects are eql? to each other.

What you are missing is that eql? isn't necessarily commutative. Making Rank#eql? recognize strings doesn't change the way String#eql? works:

one.eql?('one') #=> true
'one'.eql?(one) #=> false

Therefore it depends on which object is the hash key and which is the argument to include?:

Set['one'].include?(one) #=> true
Set[one].include?('one') #=> false

In order to make two objects a and b interchangeable hash keys, 3 conditions have to be met:

  1. a.hash == b.hash
  2. a.eql?(b) == true
  3. b.eql?(a) == true

But don't try to modify String#eql? – fiddling with Ruby's core classes isn't recommended and monkey-patching probably won't work anyway because Ruby usually calls the C methods directly for performance reasons.

In fact, making both hash and eql? mimic name doesn't seem like a good idea in the first place. It makes the object's identity ambiguous which can lead to very strange behavior and hard to find bugs:

h = { one => 1, 'one' => 1 }
#=> {#<struct Rank name="one">=>1, "one"=>1}

# vs

h = { 'one' => 1, one => 1 }
#=> {"one"=>1}

what am i missing?

What you are missing is that "one" isn't in your set. one is in your set, but "one" isn't.

Therefore, the answer Ruby is giving you is perfectly correct.

All that you have done with your implementation of Rank is that any two ranks with the same name are considered to be the same by a Hash, Set, or Array#uniq. But, a Rank is not the same as a String.

If you want to be able to have a set-like data structure where you can look up things by one of their attributes, you will have to write it yourself.

Something like (untested):

class RankSet < Set
  def [](*args)
    super(*args.map(&:name))
  end

  def each
    return enum_for(__callee__) unless block_given?

    super {|e| yield e.name }
  end
end

might get you started.

Or, instead of writing your own set, you can just use the fact that any arbitrary rank with the right name can be used for lookup:

set.include?(Rank.new(name: "one"))
#=> true
# even though it is a *different* `Rank` object
Related