Syndication Icon
Published June 5, 2020 Updated June 7, 2020
Article Cover
Ruby Refinements

Refinements were added to the Ruby language as an experimental feature to safely modify an object’s methods in an isolated manner in Version 2.0.0 and became fully supported in Version 2.1.0. In this context, to modify means changing existing behavior, adding new behavior, or disabling behavior altogether. What makes Refinements unique is that they don’t persist changes, globally, as found when monkey patching. The goal of this article is to dive deeper into what refinements are, why they’re important, and how to use them.

Overview

Implementing a refinement requires the use of two specific methods:

A few code examples are necessary to explain. If we’d like to teach strings to answer if they are boolean or not, for example, here’s how we could implement that behavior:

module Refinements
  refine String do
    def to_bool
      %w[true yes on t y 1].include? downcase.strip
    end
  end
end

The first and only argument of the refine block requires a class or module. In the implementation, above, String is passed as an argument to refine. Next, we use the newly minted refinement, via the using method, within the object that needs the behavior:

class Example
  using Refinements

  def self.boolean? string
    string.to_bool
  end
end

Finally, we can message our refined Example object as follows:

Example.boolean? "on" # => true
Example.boolean? "off" # => false

Because refinements are lexically scoped, we’ll not see this behavior exhibited in different objects which are not using the refinement:

class OtherExample
  def self.boolean? string
    string.to_bool
  end
end

We’ll discuss lexical scopes later. Returning to the above, here is what is yielded:

OtherExample.boolean? "true" # => NoMethodError (undefined method `to_bool' for "true":String)

💡 If you like the refinement above, you can get that behavior and more via the Refinements gem.

Monkey Patches

Before diving further into refinements, it’s important to take a step back and talk about monkey patches. Here’s the Wikipedia definition of this practice:

A monkey patch is a way for a program to extend or modify supporting system software locally (affecting only the running instance of the program).

For those of you who have worked with Smalltalk, you’ll know this as Duck Typing:

If it walks like a duck and it quacks like a duck, then it must be a duck.

💡 In fact, the Ruby language has many influences, including languages such as Perl, Eiffel, Ada, Basic, and Lisp, but especially Smalltalk from which monkey patching or duck typing is inherited.

Again, the problem with monkey patches is that, once applied, change the application globally. To illustrate, let’s study a simple monkey patch:

class String
  def split pattern = nil
    fail StandardError, "Sorry, I have no idea how to split myself. 😈"
  end
end

In the above, we monkey patch the String class by opening it to add new behavior. Unfortunately, this new behavior now applies to all strings within the application that need to split themselves. Here’s the result of this change (truncated for brevity):

"example".split # => Sorry, I have no idea how to split myself. 😈 (StandardError)

With the above example, we at least have a stack dump, despite not being shown, to trace back to where the monkey patch was introduced. What if the change was more subtle?

class String
  def size
    666
  end
end

Upon using the above monkey patch, we see the following output:

"example".size # => 666

Unfortunately, String#size always answers 666 now. Imagine if this code wasn’t in our application but was loaded from a gem dependency or — worse — indirectly via a gem’s own dependency. Beyond the struggle of identifying the root cause, the above also breaks expected behavior understood by all Ruby engineers, leading to time lost debugging confusing behavior not seen before.

We can avoid monkey patches by leveraging refinements because any object using a refinement is transparently declared via the Module#using method. To understand the power of refinements and just how isolated the behavior is that they introduce, we need to talk about lexical scopes.

Lexical Scopes

Lexical scope is defined from the point of origin/definition, not from where the code is used elsewhere in the program/application. This means lexical scope occurs when:

  • Entering a different file.

  • Opening a class or module definition.

  • Executing Kernel.eval on a string of code.

Specifically, this means:

  • Different files get different top level scopes. For example, a.rb is not the same scope as b.rb.

  • Scope hierarchy is not the same as class hierarchy.

  • Refinements are activated within current and nested scopes only.

  • Methods and blocks are evaluated using the lexical scope of their definition.

Take the following, for example:

# File: primaries.rb
module Primaries
  class Example
  end
end

# File: secondaries.rb
module Secondaries
  class Example
  end
end

While the above defines the same Example class, each is different structurally. They’re within different files and modules. Here’s another way to visualize the different scopes:

primaries.rb -> Primaries -> Example
secondies.rb -> Secondaries -> Example

Within the same file, scope can change based on where the refinement is defined:

using Refinements

module Primaries
  class Example
  end
end

module Secondaries
  class Example
  end
end

With the above, Refinements is defined at the top of the file and so is available to both the Primaries::Example and Secondaries::Example classes. However, if Refinements were to be defined after the Primaries, then only the Secondaries would be able to use it:

module Primaries
  class Example
  end
end

using Refinements

module Secondaries
  class Example
  end
end

To learn more, I’d suggest the following articles:

Caveats

As powerful as refinements are, there are a few caveats to be aware of before implementing refinements in your own work. These caveats are mostly restrictions implied by lexical scope.

Constants

You cannot refine a constant at the class or instance level. For example, the following code is invalid as it will yield the not defined at the refinement, but at the outer class/module warning:

# Class Refinement
refine String.singleton_class do
  EXAMPLE = "example"
end

# Instance Refinement
refine String do
  EXAMPLE = "example"
end

Class Variables

As with constants, you cannot refine a class variable either as you’ll get a not defined at the refinement, but at the outer class/module warning:

refine String
  @@example = "demo"
end

By the way, if you are using class variables, please don’t. Just stop now. They are a code smell and should be avoided altogether.

Methods

You cannot refine a method:

def example
  using ExampleRefinements
end

The above will result in a NoMethodError.

Introspection

Currently, method introspection via Kernel#method, Kernel#methods, Object#respond_to?, and Object#public_send is not possible. For example, study the output below when example is messaged:

module Refinements
  refine String do
    def example_refined
      "A refined method."
    end
  end
end

class Example
  using Refinements

  def example_normal
    "A normal method."
  end
end

Example.new.then do |example|
  p example.method(:example_refined).source_location
  # => `method': undefined method `example_refined' for class `Example' (NameError)

  p example.methods.grep(/example/)
  # => [:example_normal]

  p example.respond_to?(:example_refined)
  # => false

  p example.public_send(:example_refined)
  # => `public_send': undefined method `example_refined' for #<Example:0x00007fa7bf585390> (NoMethodError)
end

Message Chains

Attempting to send the same, refined, message to the object answered back will result in a NoMethodError:

require "pathname"

module Refinements
  refine Pathname do
    def name
      basename extname
    end
  end
end

class Example
  using Refinements

  def self.to_name path
    Pathname(path).name
  end
end

p Example.to_name "/tmp/example.txt"
# => #<Pathname:example>

p Example.to_name("/tmp/example.txt").name
# => NoMethodError (undefined method `name' for #<Pathname:example>)

The error above occurs because only Example.to_name is using Refinements. When Example.to_name answers back the new Pathname object, we can’t send the #name message because it’s not using the refinement.

Monkey Patches

While monkey patches should be avoided, attempting to monkey patch a refinement will void the refinement due to introducing a new lexical scope. For example, look at the output below:

require "pathname"

module Refinements
  refine Pathname do
    def name
      basename extname
    end
  end
end

class Example
  using Refinements

  def self.to_name path
    Pathname(path).name
  end
end

class Example
  def self.to_name path
    Pathname(path).name
  end
end

p Example.to_name "/tmp/example.txt"
# => NoMethodError (undefined method `name' for #<Pathname:/tmp/example.txt>)

Main

As mentioned before, a refinement must be used within a module or class. To illustrate what this means, try pasting the following in an IRB console:

module Refinements
  refine String do
    def to_bool
      %w[true yes on t y 1].include? downcase.strip
    end
  end
end

using Refinements

Running the above, will result in the following error:

RuntimeError (main.using is permitted only at toplevel)

The RuntimeError insures a refinement isn’t used at a global level because we’d be no better off than when monkey patching code.

Benefits

Caveats aside, refinements still shine because they:

  • Clearly define what is being refined by the Module#refine method block.

  • Clearly define who is using the refinement via the Module#using method.

  • Use lexical scopes to ensure the refinement only applies to the file, module, or class that needs the refinement and doesn’t bleed into any other part of a program accidentally.

  • Make it possible to modify legacy APIs or DSLs by refining only the specific behavior needed while not altering the rest of the program.

Guidelines

Now that you understand the benefits of refinements, especially instead of monkey patches, here are a few guidelines to help you leverage refinements in the best possible way:

  • When adding refinements to your gem, program, and/or application, use a clearly defined module for namespacing these refinements. For example, Refinements is the module I tend to use. Whatever you chose, be consistent.

  • When laying out the file structure, stick with lib/refinements for gems and app/refinements for frameworks like Hanami, Ruby on Rails, etc. This makes refinements more prominent and easy to find by fellow engineers.

  • While you can refine class methods, as shown earlier via refine String.singleton_class, it’s best to stick with refining only instance methods when possible.

  • When implementing refinements, define them within a single module of a single file for easier organization. For examples, especially within a gem, check out the Refinements gem.

  • As with all tools, use refinements sparingly. Don’t lose sight of the fact that Ruby is an object oriented language. If you don’t have to modify a core Ruby object, parts of an existing gem, etc. then don’t. Sometimes constructing objects that interact with existing code is a better choice. In situations where minor modifications can be made, then Refinements might be the exact syntactic sugar you need.

Gem

As hinted earlier, I’m a proponent of using refinements to add syntactic elegance to the Ruby language when appropriate. I even crafted a gem for this called: Refinements. Perhaps not the most inventive name, but it is pragmatic, at least! Contributions and feedback are welcome.

Conclusion

I hope this article has shed more light on refinements and encourages you leverage them instead of obscuring implementation details within a monkey patch. Refinements provide a way to be transparent while also self-describing. Finally, remember that not every object needs be modified but, when new behavior is applied appropriately, it can be all that is needed to bring extra joy to your codebase.