The letter A styled as Alchemists logo. lchemists

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 March 10, 2023
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, 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 infect other parts 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

To understand monkey patches, what they are, and why they are best avoided, I high recommend reading the section on monkey patches in my Ruby Antipatterns article for a detailed explanation. In a nutshell, monkey patches are hard to debug and maintain over time.

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 Scope

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

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

Added in 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 duplication. For example, consider the following module which implements the #many? method for an enumerable (as taken from the Refinements gem):

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. By the way, the #import_method can also take a comma separated list of modules to import as well. To quote from the Ruby documentation:

Refinement is a class of the self (current context) inside refine statement. It allows to import methods from other modules.

To illustrate further, consider this simple example:

module Demo
  def self.examine = refine(String) { def echo(text) = text }
end

refinement = Demo.examine

refinement            # #<refinement:String@Demo>
refinement.class      # Refinement
refinement.ancestors  # [#<refinement:String@Demo>]

When messaging Demo.examine, you can see an anonymous class is answered. This is further illustrated by inspecting the ancestors. In addition, we all see the anonymous class is a Refinement which finally explains why we are able to use #import_methods.

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.

Introspection

With the release of Ruby 3.2.0, several introspection enhancements were added which are documented in more detail below.

Used

You can dynamically obtain a list of modules using refinements for the current scope. Example:

module One
  refine(Object) { def moniker = "One" }
end

module Two
  refine(Object) { def moniker = "Two" }
end

using One

Module.used_refinements  # #<refinement:Object@One>

using Two

Module.used_refinements  # #<refinement:Object@One>, #<refinement:Object@Two>

This is similar to how Module.used_modules works except specifically for refinements.

Refinements

Similar to the above, you can do the same by asking the receiver for any/all refinements defined within it.

module Demo
  refine(Integer) { def moniker = "Two" }
  refine(String) { def moniker = "One" }
end

Demo.refinements  # #<refinement:Integer@Demo>, #<refinement:String@Demo>

Refined Class

Along the lines of the above — and once you have a refinement in hand — you can ask the refinement for it’s refined class. Example:

module Demo
  refine(Integer) { def moniker = "A Refined Integer" }
end

Demo.refinements.first.refined_class  # Integer

As you can see, this allows you to know what the current refinement is refining. In this case, an Integer.

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. They are an antipattern and should be avoided.

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 Object#method, Object#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 = Example.new

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)

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 custom refinements to your implementation, use a clearly defined module for namespacing these refinements. For example, Refines is the module I use so my custom implementation can peacefully coexist with functionality provided by the Refinements gem without having to use rooted ::Refinements syntax everywhere. Whatever you chose, be consistent.

  • When laying out your custom file structure, stick with lib/refines. This makes your custom refinements prominent and quick to find by fellow engineers.

  • While you can refine class methods — as shown earlier via refine String.singleton_class — be judicious in use.

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

  • Avoid heavy use of using throughout your code base since this will cache bust your classes which can lead to slow boot times. You’ll also want to avoid refining (i.e. refine) heavily used methods for the same reason. That said, this is mostly a concern for massive monolith applications. There is currently an open issue related to a Ruby 3.0.0 performance regression which hopefully will be fixed soon.

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