The Pros and Cons of Ruby Refinements
In Ruby 2.0.0, refinements were introduced as a new feature. Monkey patching has been used for a long time for modifying code behavior, but it creates side effects in code elsewhere that ends up affected by the modified code.
The purpose of Ruby refinements is to provide a solution by scoping changed behavior to the very specific area of code you choose. This is a huge blessing for the language, but the current implementation of refinements has its flaws. Still, everyone should be using refinements to create a more sane code base and not create surprise side effects in your code base. In this post, I’ll go into detail on how refinements can be used, as well as discuss many of the issues you’ll face with its as-yet-incomplete design.
How to Use Refinements
First, you need to define the behavior you wish to change by defining a module and declaring which class(es) you will be refining.
module ListStrings refine String do def +(other) "#{self}, #{other}" end end end
To use the behavior, simply use using ListStrings
in one of several areas.
using ListStrings '4' + '5' # => "4, 5"
You can use the refinement at the top level of your code, and all code written below that in the current file will be affected. You may also use it within eval statements.
eval "using ListStrings; '4' + '5'" # => "4, 5"
As of Ruby 2.3, these won’t work in IRB but will still work in your Ruby files.
The use of using
is lexically scoped. When used at the top level of your code, its affects are scoped only to the current file from that point on to the end and not in any other code you require from other files.
As of Ruby 2.3, you may use refinements within classes.
module Foo refine Fixnum do def to_s :foo end end end class NumAsString def num(input) input.to_s end end class NumAsFoo using Foo def num(input) input.to_s end end NumAsString.new.num(4) # => "4" NumAsFoo.new.num(4) # => :foo
This creates a very safe and sane way to scope changed behavior for existing code.
Current Issues with Ruby Refinements
As with most relatively young features, refinements still has some flaws. Let’s unpack where the trouble is.
Refinements can only be used once within a scope
If you’re benchmarking alternative ways and using multiple refinements on the same method name, then consecutive runs will fail to reuse used refinements. While this may not be a likely scenario, it’s good to keep this limitation in mind.
Dynamic dispatch won’t work with refinements yet
Dynamic dispatch happens when you use the proc form of mapping method calls, like [1,2,3].map(&:to_s)
. Here’s an example of how it won’t work with refinements:
module Moo refine Fixnum do def to_s "moo" end end end class A using Moo def a [1,2,3].map {|x| x.to_s } end def b [1,2,3].map(&:to_s) end end A.new.a # => ["moo", "moo", "moo"] A.new.b # => ["1", "2", "3"]
If you had redefined the to_s
method as a monkey patch, then both method calls would have returned lists of “moo”.
You cannot use methods for introspection
The Ruby documentation says, “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. This behavior may be changed in the future.”
module Baz refine String do def baz :baz end end end class Venue using Baz def respond? "".respond_to? :baz end def call "".baz end end Venue.new.respond? # => false Venue.new.call # => :baz
This makes determining whether code has been changed more difficult if you’re not looking at the source code yourself. It might be worthwhile to add something like an instance variable to your refinement if you need to dynamically determine what refinements are in play.
module Bar def refinements require 'set' @refinements = (@refinements || Set.new) << Bar end refine String do def bar :bar end end end class A include Bar and using Bar def a "".bar end end A.new.refinements # => #<Set: {Bar}> A.new.a # => :bar
You can decide for yourself how important introspection is for you; it would be quite handy when debugging. You may also choose to give more details for your introspection method than the example given here.
You can’t use using
from within a method or module
This requires you to very explicitly declare using
within the lexical scope where you’ve choosen to modify the codes behavior. This is good for the sanity of all future developers who’ll look at the code and see that there is modified behavior.
You’re unable to experiment with refinements at the top level
As I mentioned earlier, the behavior in IRB changed at 2.3, limiting the ways you can experiment with refinements. To experiment with it in IRB with Ruby 2.3 and later, you may do so with anonymous classes.
module Fiz refine Fixnum do def +(other) "#{self}#{other}" end end end class << Class.new using Fiz 1 + 2 end # => "12"
Summary
Refinements are still in their infancy in Ruby, and improvements are very likely to be made to them. However, current development on refinements seems to be on hold, as Ruby development focuses on optimizing Ruby 3 to be three times faster than Ruby 2. As a result, refinement issues raised on the bug tracker aren’t receiving much attention.
Regardless of all the issues that exist with refinements, they are still a must for developers. With them, you can help ensure a code base that clearly shows alternative behavior while ensuring safe cohesion with larger code bases. You can worry less about name collision, side effects, and hard-to-track bugs when you contain your changes with refinements.
Refinements become most important any time you’re making modifications to existing methods on core classes in Ruby or any other widely used code base. I have tried monkey patching in the past to write a gem in the past that would have a String object behave like an Array, but when I included it in a Rails project, it all came crashing down. Many applications may use methods like respond_to?
to type check and perform different actions based on type. So of course making strings respond as arrays will break things globally. Now with refinements, you don’t have to worry about this — the changes are specifically only effective within the file, eval string, or class you used the refinement in.
Don’t rely on the lack of introspection in refinements as a feature. This will likely change in the future. If you use a refinement to have a method respond to a specific method call, and you type-check against that knowing it won’t show up, you’d have created a “bug waiting to happen.”
I for one really look forward to future development in Ruby around refinements to get past the current issues and move on to a more mature code use for all our benefit. And the biggest thing that will help refinements move further along in the development process is for more people to use it. The more people who use it, the more attention it will get, which will lead to more focus in developing it within the language.
Reference: | The Pros and Cons of Ruby Refinements from our WCG partner Daniel P. Clark at the Codeship Blog blog. |