The letter A styled as Alchemists logo. lchemists Syndication Icon

Putin's War on Ukraine - Watch President Zelenskyy's speech and help Ukraine fight against the senseless cruelty of a dictator!

Published June 5, 2020 Updated May 23, 2022
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.

Benefits

At a high level — and before we get into the details — refinements 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 a 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.

The rest of this article will dive deeper into explaining the value of these benefits.

Background

Before diving further into refinements, it’s important to take a step back and discuss how they rose out of a need to avoid monkey patches by leveraging lexical scopes.

Monkey Patches

Let’s start with monkey patches, what they are, and why they are not recommended. Here’s the Wikipedia definition:

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

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

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.

💡 To bolster my argument further, consider Piotr Solnica’s article on why Rails is not written in Ruby where he explains the implications of taking monkey patches too far.

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 next.

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 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:

Usage

Implementing a refinement requires the use of two specific methods:

A few code examples are necessary to explain so we will start by refining instance methods, class methods, and then follow-up with more advanced usage.

Instance Methods

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 by adding a new instance method:

module Refinements
  refine String do
    def to_bool = %w[true yes on t y 1].include?(downcase.strip)
  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

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

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

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

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

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

Now that we understand how to refine instance methods, let’s continue by looking at class methods next.

Class Methods

Building upon the previous instance method discussion, you can also refine a class. The difference is making sure you refine the singleton class. Here’s how you would refine String to answer if another string was a boolean or not:

module Refinements
  refine String.singleton_class do
    def boolean?(string) = %w[true yes on t y 1].include?(string.downcase.strip)
  end
end

using Refinements

String.boolean? "yes"  # true
String.boolean? "no"   # false

⚠️ As with any class method, refined or not, use sparingly because implementing too many of them means you have an object wanting to be born which would decrease the complexity of the class.

Main

As briefly shown with class methods, a refinement is meant to be used within a module or class. That said, you can refine main (new as of Ruby 3.1.0) which is handy when using IRB for exploration purposes. For example, try pasting the following in IRB:

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

using Refinements

"yes".to_bool  # true

This also makes gem development easier for projects like the Refinements gem where you can run the console and safely experiment with all refinements within the project specific IRB console.

Conversion Functions

Conversion functions — also known as casting functions — are a unique aspect of Ruby that allow you to cast any object into a primitive or raise an exception otherwise. Here’s a quick example of a few of these functions in action:

Integer "1"      # 1
String 1         # "1"
Hash a: 1, b: 2  # {:a=>1, :b=>2}
Array 1..3       # [1, 2, 3]

So what happens when you want to introduce a new primitive into the language? Well, you could monkey patch Kernel by adding your own conversion function but we already know that’s not acceptable. The good news is refinements are especially suited for solving this problem. The best example is via the Versionaire gem which provides a Version casting function. The specifics of how this work is documented within the Versionaire Refinement documentation but here’s a code snippet showcasing how nice it is to refine Kernel by adding a new Version function:

using Versionaire::Cast

Version "1.0.0"
Version [1, 0, 0]
Version major: 1, minor: 0, patch: 0

All of the above answer the same #<struct Versionaire::Version major=1, minor=0, patch=0> object. Without the refinement, you’d have to type Versionaire::Version all the time but with the refinement you only have to type Version just like you would with native conversion functions such as Integer, String, Array, and so forth.

Method Imports

With Ruby 3.1.0, support for importing methods into a refinement was introduced but only for pure Ruby objects. Native extensions, methods defined in C, and so forth can’t be imported because only the bytecodes of pure Ruby methods are allowed.

Defining functionality once and then importing that shared functionality across several refined objects reduces duplicated effort. For example, consider the following module which implements the #many? method for an enumerable:

module Many
  def many?
    return size > 1 unless block_given?

    total = reduce(0) { |count, item| yield(item) ? count + 1 : count }
    total > 1
  end
end

If we import the above implementation into the Array and Hash objects, we then benefit from shared functionality defined only once:

refine Array do
  import_methods Many
end

refine Hash do
  import_methods Many
end

This is made possible by using the Refinement#import_methods method which copies the module’s bytecodes into the refined object. The #import_method can also take a comma separated list of modules to import as well.

All of what has been described here is, roughly, how the #many? method was implemented for the Array and Hash objects in the Refinements gem. So you can make use of this today in your own code if you like.

Anonymous Inlines

You can inline a refinement within the same class it is defined by using an anonymous module. Consider the following:

class Demo
  using(
    Module.new do
      refine Demo.singleton_class do
        def example = "You've called the .#{__method__} method."
      end
    end
  )

  def inspect = self.class.example
end

Demo.new.inspect  # You've called the .example method.
Demo.example      # undefined method `example' for Demo:Class (NoMethodError)

As you can see from the above, when you message the #inspect instance method, it delegates to the .example class method which is public by default but ends up being private in nature because it’s only temporarily available due to the refinement’s lexical scope. However, if you message the .example class method directly, you end up with a NoMethodError because, again, lexical scope to the rescue.

The alternative would be use a private constant. Here’s the same implementation but rewritten:

class Demo
  module Kit
    def self.example = "You've called the .#{__method__} method."
  end

  private_constant :Kit

  def inspect = Kit.example
end

Demo.new.inspect   # You've called the .example method.
Demo::Kit.example  # private constant Demo::Kit referenced (NameError)

In both code snippets, roughly, the same amount of code is used. The difference is that due the lexical scope and temporary nature of the refinement being used, you could argue that the implementation using the refinement is more private than the private constant implementation.

Whether this is a good use case for refinements, I can’t say. There might be performance, debugability, readability, and other concerns. That said, being able to use a private class method as a utility method is an interesting use case that refinements handle uniquely well.

Caveats

As powerful as refinements are, there are a few caveats to be aware of before using them in your own work. These caveats are mostly restrictions implied by lexical scope — which is a good thing — but are good to be aware none-the-less.

Constants

You cannot refine a constant at the class or instance level. For example, the following code is invalid because it will yield an undefined method `refine' for main:Object error:

# 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 undefined method `refine' for main:Object error:

refine String
  @@example = "demo"
end

By the way, if you are using class variables, please don’t. Stop now. They are an antipattern and should be avoided altogether.

Methods

You cannot refine a method because the following will result in a Module#using is not permitted in methods runtime error.

module ExampleRefinements
  refine String do
    def inspect = "This is a test."
  end
end

module Demo
  def self.test
    using ExampleRefinements

    "test".inspect
  end
end

Demo.test

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

class Example
  using Refinements

  def example_normal = "A normal method."
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

class Example
  using Refinements

  def self.to_name(path) = Pathname(path).name
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

class Example
  using Refinements

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

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

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

Guidelines

Now that you understand the benefits of refinements, versus 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 be judicious in their use.

  • When implementing refinements, define them within a single module of a single file for easier organization. For example, 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.

Implementations

As you’ve probably guessed by now, I’m a proponent of using refinements to add syntactic elegance to the Ruby language when appropriate. I’ve even crafted several gems that refine Ruby even further. Here’s where you can start digging deeper, if you like:

  • Refinements - The gem — that I’ve mentioned a couple of times in the course of this article — which enhances and extends core Ruby functionality.

  • Versionaire - This gem provides a primitive Version object — which adheres to Semenatic Versioning — and is made possible by refining Kernel.

  • Projects - You can always browse through any of my open source projects. Many are built upon the Refinements gem and provide plenty of usage examples for solving common problems within your own work.

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.