Ruby 2.0 Refinements in Practice
First Shugo announced them at RubyKaigi. Then Matz showed some improved syntax at RubyConf. But what are refinements all about, and what would they be used for?
The first thing you need to understand is that the purpose of refinements in Ruby 2.0 is to make monkey-patching safer. Specifically, the goal is to make it possible to extend core classes, but to limit the effect of those extensions to a particular area of code. Since the purpose of this feature is make monkey-patching safer, let's take a look at a dangerous case of monkey-patching and see how this new feature would improve the situation.
A few months ago, I encountered a problem where some accidental monkey-patches in Right::AWS conflicted with Rails' own monkey-patches. In particular, here is their code:
unless defined? ActiveSupport::CoreExtensions
class String #:nodoc:
def camelize()
self.dup.split(/_/).map{ |word| word.capitalize }.join('')
end
end
end
Essentially, Right::AWS is trying to make a few extensions available to itself, but only if they were not defined by Rails. In that case, they assume that the Rails version of the extension will suffice. They did this quite some time ago, so these extensions represent a pretty old version of Rails. They assume (without any real basis), that every future version of ActiveSupport will return an expected vaue from camelize.
Unfortunately, Rails 3 changed the internal organization of ActiveSupport, and removed the constant name ActiveSupport::CoreExtensions. As a result, these monkey-patches got activated. Let's take a look at what the Rails 3 version of the camelize helper looks like:
class String
def camelize(first_letter = :upper)
case first_letter
when :upper then ActiveSupport::Inflector.camelize(self, true)
when :lower then ActiveSupport::Inflector.camelize(self, false)
end
end
end
module ActiveSupport
module Inflector
extend self
def camelize(lower_case_and_underscored_word, first_letter_in_uppercase = true)
if first_letter_in_uppercase
lower_case_and_underscored_word.to_s.gsub(/\/(.?)/) { "::#{$1.upcase}" }.gsub(/(?:^|_)(.)/) { $1.upcase }
else
lower_case_and_underscored_word.to_s[0].chr.downcase + camelize(lower_case_and_underscored_word)[1..-1]
end
end
end
end
There are a few differences here, but the most important one is that in Rails, "foo/bar" becomes "Foo::Bar". The Right::AWS version converts that same input into "Foo/bar".
Now here's the wrinkle. The Rails router uses camelize to convert controller paths like "admin/posts" to "Admin::Posts". Because Right::AWS overrides camelize with this (slightly) incompatible implementation, the Rails router ends up trying to find an "Admin/posts" constant, which Ruby correctly complains isn't a valid constant name. While situations like this are rare, it's mostly because of an extremely diligent library community, and a general eschewing of applying these kinds of monkey-patches in library code. In general, Right::AWS should have done something like Right::Utils.camelize
in their code to avoid this problem.
Refinements allow us to make these kinds of aesthetically pleasing extensions for our own code with the guarantee that they will not affect any other Ruby code.
First, instead of directly reopening the String class, we would create a refinement in the ActiveSupport module:
module ActiveSupport
refine String do
def camelize(first_letter = :upper)
case first_letter
when :upper then ActiveSupport::Inflector.camelize(self, true)
when :lower then ActiveSupport::Inflector.camelize(self, false)
end
end
end
end
What we have done here is define a String refinement that we can activate elsewhere with the using
method. Let's use the refinement in the router:
module ActionDispatch
module Routing
class RouteSet
using ActiveSupport
def controller_reference(controller_param)
unless controller = @controllers[controller_param]
controller_name = "#{controller_param.camelize}Controller"
controller = @controllers[controller_param] =
ActiveSupport::Dependencies.ref(controller_name)
end
controller.get
end
end
end
end
It's important to note that the refinement only applies to methods physically inside the same block. It will not apply to other methods in ActionDispatch::Routing::RouteSet
defined in a different block. This means that we can use different refinements for different groups of methods in the same class, by defining the methods in different class blocks, each with their own refinements. So if I reopened the RouteSet class somewhere else:
module ActionDispatch
module Routing
class RouteSet
using RouterExtensions
# I can define a special version of camelize that will be used
# only in methods defined in this physical block
def route_name(name)
name.camelize
end
end
end
end
Getting back to the real-life example, even though Right::AWS created a global version of camelize, the ActiveSupport version (applied via using ActiveSupport
) will be used. This means that we are guaranteed that our code (and only our code) uses the special version of camelize.
It's also important to note that only explicit calls to camelize in the physical block will use the special version. For example, let's imagine that some library defines a global method called constantize, and uses a camelize refinement:
module Protection
refine String do
def camelize()
self.dup.split(/_/).map{ |word| word.capitalize }.join('')
end
end
end
class String #:nodoc:
using Protection
def constantize
Object.module_eval("::#{camelize}", __FILE__, __LINE__)
end
end
Calling String#constantize anywhere will internally call the String#camelize from the Protection refinement to do some of its work. Now let's say we create a String refinement with an unusual camelize method:
module Wycats
refine String do
def camelize
result = dup.split(/_/).map(&:capitalize).join
"_#{result}_"
end
end
end
module Factory
using Wycats
def self.create(class_name, string)
klass = class_name.constantize
klass.new(string.camelize)
end
end
class Person
def initialize(string)
@string = string
end
end
Factory.create("Person", "wycats")
Here, the Wycats refinement should not leak into the call to constantize
. If it did, it would mean that any call into any method could leak a refinement into that method, which is the opposite of the purpose of the feature. Once you realize that refinements apply lexically, they create a very orderly, easy to understand way to apply targeted monkey patches to an area of code.
In my opinion, the most important feature of refinements is that you can see the refinements that apply to a chunk of code (delineated by a physical class body). This allows you to be sure that the changes you are making only apply where you want them to apply, and makes refinements a real solution to the general problem of wanting aesthetically pleasing extensions with the guarantee that you can't break other code. In addition, refinements protect diligent library authors even when other library authors (or app developers) make global changes, which makes it possible to use the feature without system-wide adoption. I, for one, am looking forward to it.
Postscript
There is one exception to the lexical rule, which is that refinements are inherited from the calling scope when using instance_eval
. This actually gives rise to some really nice possibilities, which I will explore in my next post.