Ruby Refinements – Not quite ready for production
I recently looked for a method on Array for a boolean response on its uniqueness. As Ruby currently doesn’t have one I figured it’s easy enough to write one. Now Ruby’s refinements are fairly new and a nice feature to have around. So I decided to write a benchmark for different ways to test the uniqueness of an Array. And I chose to do so with refinements. In the process I ran into oddities and unexpected behavior I’d like to share with you – “the limitations Ruby refinements have as of Ruby 2.2.2”
If you’d like a basic overview on Ruby refinements see my blog post here: Refinements over Monkey-patching
Extremely Strict Scoping
This is by design, but the strictness of its design is quite startling. First let us write our own refinement on Array for uniqueness checking.
module BoolUniq refine Array do def uniq? !self.dup.uniq! end end end
Now we can use this within any class we write with using BoolUniq . Let’s now write a class that will be using it. For demonstration purposes we’ll leave out the refinement to see an expected exception.
class A def test [1,2,3].uniq? end end A.new.test # NoMethodError: undefined method `uniq?' for [1, 2, 3]:Array
This is as expected. Now you may know that generally you can open up an existing class and add stuff to it such as follows:
class A def another_method end end A.instance_methods - Object.methods # => [:test, :another_method]
With this thinking if you add refinements to a class with existing methods you “might” expect the behavior to happen to the entire new instance where the refinement is applied. But this is not so.
class A using BoolUniq end A.new.test # NoMethodError: undefined method `uniq?' for [1, 2, 3]:Array
So even though we told class A to include the refinement; existing methods remained untouched by the refinement. So it turns out we need to define all the new methods/objects at the same time that we call using with our refinement for the refinement to take effect. Let’s write the same exact method with a different name and use the refinement.
class A using BoolUniq def test2 [1,2,3].uniq? end end A.new.test2 # => true A.new.test # NoMethodError: undefined method `uniq?' for [1, 2, 3]:Array
So even though we injected behavior within the same scope it did not touch anything previously defined. Also if you re-open a class that has previously used a refinement and edit a method without explicitly calling the refinement again, you lose the effect of the refinement.
class A def test2 [1,2,3].uniq? end end A.new.test # NoMethodError: undefined method `uniq?' for [1, 2, 3]:Array
Invisibility
One thing I ran into is that the respond_to? method will always return false when asking a refined object if it responds to the method call. This is true even if you evaluate a block of code within the scope/singleton-instance of a refined Object. This is a known feature/bug and will be changed in the future (as noted in the official Refinement Specs). So as of now there is no way to pry, peak, or reveal a refined method existence/source (which makes testing problematic). You can only reap the result of its existence.
Indirect Method Calls – When using indirect method access such as Kernel#send, Kernel#method or Kernel#respond_to? refinements are not honored for the caller context during method lookup.
It’s very much like voodoo code doing it’s magic and you can’t verify it is what you think it is. And it gets worse.
Lost Refinements
During my benchmark tests I wanted to test different quantities of results. (Here is a gist of the original benchmark I used testing 6 different ways to discover an Array’s uniqueness: https://gist.github.com/danielpclark/d27e3db346428a117712 ) I created a loop to go over different quantities but I found something terribly wrong with the results. The first time the loop called each refinement to test I got differing results as one would expect. But once the loop of refinements came up to be tested again everything washed into the same kind of results.
For example, testing these 6 methods on Array as the method :uniq?
#1 !self.dup.uniq! #2 self.length == self.uniq.length #3 self.sort == self.uniq.sort #4 self.each_with_index { |a,b| self.each_with_index { |c,d| next if b == d return false if a == c } } true # 5 self.combination(2).each {|a,b| return false if a == b} true # 6 self == self.uniq
The first time with an Array of 200 hexadecimal items gave these results: (smaller numbers are better)
#1 1.830000 0.010000 1.840000 ( 1.830531) #2 1.880000 0.000000 1.880000 ( 1.882458) #3 2.590000 0.000000 2.590000 ( 2.596115) #4 42.560000 0.070000 42.630000 ( 42.670076) #5 26.250000 0.050000 26.300000 ( 26.331753) #6 1.920000 0.000000 1.920000 ( 1.927852)
Which has easily discernible differences between each test. But on a continued loop testing the same refinements at quantities of 210, 220, and 230 all resulted into a wash of same results from which we can tell isn’t testing different code, but only one of the refinements:
#1 2.020000 0.010000 2.030000 ( 2.025461) #2 2.000000 0.000000 2.000000 ( 2.014741) #3 2.040000 0.010000 2.050000 ( 2.039809) #4 2.030000 0.000000 2.030000 ( 2.038861) #5 2.000000 0.010000 2.010000 ( 2.009482) #6 1.980000 0.000000 1.980000 ( 1.989207) #1 2.080000 0.000000 2.080000 ( 2.081751) #2 2.080000 0.010000 2.090000 ( 2.082846) #3 2.100000 0.000000 2.100000 ( 2.108430) #4 2.090000 0.000000 2.090000 ( 2.097069) #5 2.090000 0.010000 2.100000 ( 2.091239) #6 2.080000 0.000000 2.080000 ( 2.088127) #1 2.180000 0.000000 2.180000 ( 2.184355) #2 2.180000 0.010000 2.190000 ( 2.186166) #3 2.190000 0.000000 2.190000 ( 2.200226) #4 2.230000 0.010000 2.240000 ( 2.240560) #5 2.180000 0.000000 2.180000 ( 2.182407) #6 2.200000 0.010000 2.210000 ( 2.210603)
Now we know something is wrong but we have no way to test what, or where, the source code is since refinements are invisible to external inspection. I went ahead and changed my source code to not include the refinements within the benchmark test but to include refined objects instead and got these same results. So in my testing a loop of refinements for benchmarking the refinements got lost. This should be a red flag for people considering them in production. You may want to consider alternative solutions for now until refinements are ironed out.
Conclusion
Refinements are great! Just be very cautious about using them if they may end up in scenarios like I’ve mentioned above. I’m looking forward to when all the kinks are ironed out, but for now stick with tried and true ways when writing code for production!
Please feel free to comment, share, subscribe to my RSS Feed, and follow me on twitter @6ftdan!
God Bless!
-Daniel P. Clark
Image by Pete Zarria via the Creative Commons Attribution-NonCommercial-NoDerivs 2.0 Generic License
Andrew Kozin
June 21, 2015 - 5:12 am
Hi, Daniel! Have you seen this talk http://www.youtube.com/watch?v=_27-4-dbnA8 by Paolo Perotta, explaining why refinements are lexically scoped? After this I became thinking the “strictness” of refinements is not the bug, but a feature. Or kind of featurish.
Daniel P. Clark
June 21, 2015 - 1:29 pm
Thanks! I had not seen it.
I hadn’t meant to imply with my post that the scope was a problem… it’s by design. The second half of my post was really the point on why I don’t think it’s production ready.
I like the scoping. Once I can test it with indirect method calls it will be clear whether or not there are bug. The documentation clearly says that this “will” change, as in, they will have indirect method calls available to refinements in the future.
Daniel P. Clark
June 21, 2015 - 1:04 pm
After watching the video he has a great point on it being a potential security issue. Example:
require "some_gem"
SomeGem.methods
# => [:trust_me, :no_harm, :all_clear]
SomeGem.respond_to? :destroy_system
# => false
SomeGem.destroy_system
***KAABOOOOOM!!!!!!!!!!!****
Segfault System Not Found
John
April 13, 2016 - 2:04 pm
The dynamic dispatch issue is even worse than you describe, because it means `array.map { |x| x.refinement }` works fine, but `array.map(&:refinement)` will fail to find your function.
Daniel P. Clark
April 13, 2016 - 2:18 pm
I’ve submitted my test results to the Ruby Bug issue tracker https://bugs.ruby-lang.org/issues/11704
But as for your point… Wow! You’re right!
If you believe this is related directly to my issue please add this information to the issue in the bug tracker. If it’s different and not already in the issue tracker please add it.