arraysrubyobjectcomparisonruby-2.6

Is there any way to specify how to compare of array of objects for .difference function in Ruby 2.6.0?


I am trying to compare an array of externally defined Objects. I was hoping I would be able to do a simple .difference, a function that was introduced in Ruby 2.6.0 but after looking at it: https://ruby-doc.org/core-2.6/Array.html#method-i-difference I'm not sure I can specify a custom comparison.

Okay assuming we have a simple Object Num

# Pretend we don't have access to this, just for reference
class Num
  def initialize(val)
    @val = val
  end

  def val
    @val
  end
end

And I have two arrays, one is a subset of the other. I want to find what the subset is missing. In this following example I want the difference to be the Object with value 3, since it doesn't exist in the subset.

all = [Num.new(1), Num.new(2), Num.new(3)]
subset = [Num.new(1), Num.new(2)]

The default .difference function compares using .eql? between the two objects so the difference does not give the expected result:

all.difference(subset)
=> [#<Num:0x00007fcae19e9540 @val=1>, #<Num:0x00007fcae19e9518 @val=2>, #<Num:0x00007fcae19e94f0 @val=3>]

I was able to create my own custom hacky solution to properly give me the values I want:

def custom_difference(all, subset)
  diff = all.reject { |all_curr|
    subset.find{ |subset_curr|
      subset_curr.val == all_curr.val
    } != nil
  }
end

custom_difference(all, subset)
=> [#<Num:0x00007fcae19e94f0 @val=3>]

But I want to know if there's anyway to utilize the existing .difference function, I was trying to use like this as well in order to override the way the two objects are compared:

all.difference(subset) { |a, b|
  a.val <=> b.val
}
=> [#<Num:0x00007fcae19e9540 @val=1>, #<Num:0x00007fcae19e9518 @val=2>, #<Num:0x00007fcae19e94f0 @val=3>]

But this doesn't do anything to adjust the way the comparison occurs (AFAIK) Am I doing something wrong? Is this just not possible? :'(


Solution

  • If you don't want to add eql? to the class as described by Aleksei Matiushkin (e.g. if you want to use multiple criteria for different things), there's no way to reuse #difference. Doing what you were doing is pretty much what you need to do, though with Array#include? is O(N^2), so I like sticking Set in there:

    Set.new(subset.map(&:val)).then { |s| all.reject { |x| s === x.val } }
    # => [#<Num:0x00007febd32330e0 @val=3>]
    

    or, as a new method:

    module ArrayWithDifferenceBy
      refine Array do
        def difference_by(other)
          other_set = Set.new(other.map { |x| yield x })
          self.reject { |x| other_set.include?(yield x) }
        end
      end
    end
    
    module TestThis
      using ArrayWithDifferenceBy
      all = [Num.new(1), Num.new(2), Num.new(3)]
      subset = [Num.new(1), Num.new(2)]
      all.difference_by(subset, &:val)
    end
    # => [#<Num:0x00007febd32330e0 @val=3>]