1

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? :'(

nanci.drew
  • 125
  • 1
  • 8
  • I don't understand your question. I suggest you insert a simple example after your first paragraph, including the desired return value. – Cary Swoveland Aug 01 '19 at 22:11
  • @CarySwoveland I tried clarifying what I'm trying to do, and what I got instead. Does it seem clearer? – nanci.drew Aug 01 '19 at 22:30
  • You said why `difference` doesn't work with your example, but not what you want returned. Is the desired return value `[#]`? If so the problem is that `Num.new(1)` in `all` is a different object than `Num.new(1)` in `subset` (same for `Num.new(2)`). Try `[Num.new(1).object_id, Num.new(1).object_id`. Then try `i1 = Num.new(1); i2 = Num.new(2); [i1, i2, Num.new](3).difference([i1, i2])`. – Cary Swoveland Aug 02 '19 at 00:29
  • @CarySwoveland: OP knows that. The question isn't "why it doesn't work", but how to make `difference_by` with a custom comparator (like `sort_by`). – Amadan Aug 02 '19 at 05:17
  • @Amadan and nanci, so `all.difference(subject)` is to return an array of instances `x` from `all` for which `x`'s instance variable `@val` is not equal to `@val` for any instance in `subject`? (btw, [The Hidden Staircase](https://www.amazon.ca/s?k=the+hidden+staircase&gclid=CjwKCAjwm4rqBRBUEiwAwaWjjADCnZk4eOAjHhePlL_RtcZEZTeUrrvxHUG8QdUjCHDIXFxVdamXsxoCFDQQAvD_BwE&hvadid=208343092520&hvdev=c&hvlocphy=9001599&hvnetw=g&hvpos=1t1&hvqmt=e&hvrand=13804893400607142478&hvtargid=aud-749198100220%3Akwd-306736873136&hydadcr=23314_9563089&tag=googcana-20&ref=pd_sl_9pwb2rmh9_e) was a real page-turner.) – Cary Swoveland Aug 02 '19 at 06:06

2 Answers2

2

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>]
Amadan
  • 191,408
  • 23
  • 240
  • 301
  • This is quite elegant! I was really hoping to find a cleaner solution than what I wrote and I think this is it :D ideally I wish that the .difference function accepted args but I guess that's not the case :'( Thank you! – nanci.drew Aug 02 '19 at 16:44
0

You want to simply override #eql? on your object.

 class Num
  def initialize(val)
    @val = val
  end

  def val
    @val
  end

  def eql?(comp)
    @val == comp.val
  end
end

Now if you try:

all = [Num.new(1), Num.new(2), Num.new(3)]
subset = [Num.new(1), Num.new(2)]
all.difference(subset) => [#<Num:0x00007fa7f7171e60 @val=3>]
Daniel Westendorf
  • 3,375
  • 18
  • 23
  • But I only defined the class for reference, in reality I don't have direct access to the object class definition so I can't just define the `eql?` method. I receive a list of arrays from a third party gem, is there a way for me to still redefine that function if it wasn't defined by me? Do I need to extend the class somehow? – nanci.drew Aug 01 '19 at 23:52
  • @nanci.drew you simply reopen the class and define the method, it’s why we all love [tag:ruby]. Do anywhere within your code `class Foreign; def eql?; ...; end; end` or, better, `Foreign.prepend(Module.new { def eql?; ...; end })`. – Aleksei Matiushkin Aug 02 '19 at 04:40
  • 1
    One thing I worry about is affecting other function calls from this third party where the `eql?` function could maybe be utilized for logic. If I override it locally, then does it not use my redefined `eql?` which could potentially introduce unexpected behaviors? – nanci.drew Aug 14 '19 at 18:12