5

Refinements was an experimental addition to v2.0, then modified and made permanent in v2.1. It provides a way to avoid "monkey-patching" by providing "a way to extend a class locally".

I attempted to apply Refinements to this recent question which I will simplify thus:

a = [[1, "a"],
     [2, "b"],
     [3, "c"],
     [4, "d"]]

b = [[1, "AA"],
     [2, "B"],
     [3, "C"],
     [5, "D"]]

The element at offset i in a matches the element at offset i in b if:

a[i].first == b[i].first

and

a[i].last.downcase == b[i].last.downcase

In other words, the matching of the strings is independent of case.

The problem is to determine the number of elements of a that match the corresponding element of b. We see that the answer is two, the elements at offsets 1 and 2.

One way to do this is to monkey-patch String#==:

class String
  alias :dbl_eql :==
  def ==(other)
    downcase.dbl_eql(other.downcase)
  end
end

a.zip(b).count { |ae,be| ae.zip(be).all? { |aee,bee| aee==bee } }
  #=> 2

or instead use Refinements:

module M
  refine String do
    alias :dbl_eql :==
    def ==(other)
      downcase.dbl_eql(other.downcase)
    end
  end
end

'a' == 'A'
  #=> false (as expected)
a.zip(b).count { |ae,be| ae.zip(be).all? { |aee,bee| aee==bee } }
  #=> 0 (as expected)

using M
'a' == 'A'
  #=> true
a.zip(b).count { |ae,be| ae.zip(be).all? { |aee,bee| aee==bee } }
  #=> 2

However, I would like to use Refinements like this:

using M
a.zip(b).count { |ae,be| ae == be }
  #=> 0

but, as you see, that gives the wrong answer. That's because I'm invoking Array#== and the refinement does not apply within Array.

I could do this:

module N
  refine Array do
    def ==(other)
      zip(other).all? do |ae,be|
        case ae
        when String
          ae.downcase==be.downcase
        else
          ae==be
        end
      end  
    end
  end
end

using N
a.zip(b).count { |ae,be| ae == be }
  #=> 2

but that's not what I want. I want to do something like this:

module N
  refine Array do
    using M
  end   
end

using N
a.zip(b).count { |ae,be| ae == be }
  #=> 0

but clearly that does not work.

My question: is there a way to refine String for use in Array, then refine Array for use in my method?

Community
  • 1
  • 1
Cary Swoveland
  • 106,649
  • 6
  • 63
  • 100

1 Answers1

1

Wow, this was really interesting to play around with! Thanks for asking this question! I found a way that works!

module M
  refine String do
    alias :dbl_eql :==
      def ==(other)
        downcase.dbl_eql(other.downcase)
      end
  end

  refine Array do
    def ==(other)
      zip(other).all? {|x, y| x == y}
    end
  end
end

a = [[1, "a"],
     [2, "b"],
     [3, "c"],
     [4, "d"]]

b = [[1, "AA"],
     [2, "B"],
     [3, "C"],
     [5, "D"]]

using M

a.zip(b).count { |ae,be| ae == be } # 2

Without redefining == in Array, the refinement won't apply. Interestingly, it also doesn't work if you do it in two separate modules; this doesn't work, for instance:

module M
  refine String do
    alias :dbl_eql :==
      def ==(other)
        downcase.dbl_eql(other.downcase)
      end
  end
end

using M

module N
  refine Array do
    def ==(other)
      zip(other).all? {|x, y| x == y}
    end
  end
end

a = [[1, "a"],
     [2, "b"],
     [3, "c"],
     [4, "d"]]

b = [[1, "AA"],
     [2, "B"],
     [3, "C"],
     [5, "D"]]

using N

a.zip(b).count { |ae,be| ae == be } # 0

I'm not familiar enough with the implementation details of refine to be totally confident about why this behavior occurs. My guess is that the inside of a refine block is treated sort of as entering a different top-level scope, similarly to how refines defined outside of the current file only apply if the file they are defined in is parsed with require in the current file. This would also explain why nested refines don't work; the interior refine goes out of scope the moment it exits. This would also explain why monkey-patching Array as follows works:

class Array
  using M

  def ==(other)
    zip(other).all? {|x, y| x == y}
  end
end

This doesn't fall prey to the scoping issues that refine creates, so the refine on String stays in scope.

Zoë Sparks
  • 260
  • 3
  • 9
  • That's great! One detail: you might consider replacing `!self.zip(other).map {|x, y| x == y}.include? false` with `zip(other).all? {|x, y| x == y}`. (Recall that `self` is the default receiver.) – Cary Swoveland Mar 27 '15 at 00:20
  • Ah, yeah, thanks—I've acquired a bad habit of using `self` everywhere it could apply. This will help me remember to consider whether or not it makes sense to use. It does look much nicer/more readable here without `self` and using `all?`. – Zoë Sparks Mar 27 '15 at 14:21
  • Many Rubyists use `self` when it's not needed because they believe its omission may be confusing to the reader. I'm not in that camp, but I cannot say they are wrong. – Cary Swoveland Mar 27 '15 at 14:49
  • It seems like it really depends on the situation to me. There are times when I would deliberately use `self` where it could be omitted, but in this case it seems like the line of code is so simple that it's easier to read if `self` is left out. If it was a more complex/unusual line of code it might be easier to make immediate sense of if `self` was used. – Zoë Sparks Mar 27 '15 at 15:27