Search code examples
ruby

Enumerable#grep with a custom ruby class


I'm on Ruby 3.2 and I have a custom class Foo that (for demonstration purposes) stores an array of values.

I have another code part that is not under my control, that uses Enumerable#grep and Enumerable#grep_v to search for string patterns in an array.

I want to make the Foo class work with Array#grep. I looked into https://docs.ruby-lang.org/en/3.2/Enumerable.html#method-i-grep and it states that

With no block given, returns an array containing each element for which pattern === element is true:

I tried implementing a #=== method like this

class Foo
  def initialize(values)
    @values = values
  end

  def ===(pattern)
    @values.any? { |v| v.match?(pattern) }
  end
end

But this doesn't seem to work:

[Foo.new(["foo", "bar"]), Foo.new(["zoo"])].grep(/oo/)
=> []

I can also see, when I put a debugger or puts statement inside def ===(pattern) that it does not seem to be called by the grep command.

Any idea on how this could work?


Solution

  • Look closely at the documentation of Enumerable#grep [bold italic emphasis mine]:

    grep(pattern)array

    grep(pattern) {|element| ... }array

    Returns an array of objects based elements of self that match the given pattern.

    With no block given, returns an array containing each element for which pattern === element is true […]

    The pattern is on the left-hand side of the === triple-equals case subsumption binary infix operator, which means the message === is sent to the pattern, not to the element. The === triple-equals case subsumption binary infix operator is not commutative.

    If you want your object to be matchable by a Regexp, then you need to represent it as something that can be matched by a Regexp, i.e., a String:

    class Foo
      def initialize(values)
        @values = values
      end
    
      def to_str = @values.join
    end
    
    [Foo.new(%w[foo bar]), Foo.new(['zoo'])].grep(/oo/)
    #=> [#<Foo:0xdeadbeef @values=["foo", "bar"]>, #<Foo:0xdeadbeef @values=["zoo"]>]
    

    But, please, only do this if your object actually IS-A string, i.e., if it can be considered to be a subtype / specialization of a string.

    The "three letter" conversion methods (to_str, to_int, to_ary, to_float, to_hash, etc., and yes, I know they're not all three letters) should only be implemented for objects which genuinely are subtypes. I.e., to_str should only be implemented by objects which are subtypes of String (the object IS-A string), but for technical reasons cannot be subclasses of String.

    If you look at which objects implement to_str in the Ruby core library, you will find that there are only three, String and two others, and I would argue the two others are wrong and shouldn't implement it. Same goes for to_int or to_float. These should be very judiciously used.

    They are used for implicit conversions, with all the dangers that entails. If you implement Foo#to_str, you should be very sure that you really want Foo to be silently converted to String everywhere where a String is expected, and not, say, raise an Exception instead.

    All this is to say: trying to match something with a Regexp that is not a String is weird. Are you sure that is what you want, or is there maybe something in your design that isn't quite right?