Ruby Refinements

The Ruby language provides many powerful tools for software engineers to utilize. For instance, classes that have been previously defined and evaluated can be reopened and changed. This is commonly referred to as “monkey patching”, a term which elicits almost universal disdain among Ruby developers.

The Problem

A reason that monkey patched code has issues lies in the scope of the changed code. If previously defined code is changed at an arbitrary time, all other parts of the application suffer from those changes.

Why would someone need to change an existing class? Perhaps an included gem needs to be altered in a small way to behave correctly in a very specific system. Or maybe, there exists a very dark area of a codebase that must not be touched directly for fear that the entire application will go under.

Whatever the case, patching code that has been already defined happens, and it usually happens poorly.

These problems can be especially nefarious if the patches are not in automatically loaded files. For example, say we have a Dog class:

dog.rb

class Dog
  attr_accessor :trained

  def bark
    "woof woof"
  end
end

Then in file loaded later, the bark method changes to be much more formal:

training.rb

class Training
  def train(dog)
    dog.trained = true
    dog.bark
  end
end

class Dog
  def bark
    "Woof woof, good sir."
  end
end

After the Traning class is loaded, any consumers of the Dog class will be in for a surprise whenever the bark method is called. Even worse, when a confused developer opens the dog.rb class to check bark’s functionality, they will not see the patched version.

dog = Dog.new
dog.bark
# => woof woof

require './training'
training = Training.new
training.train(dog)
# => Woof woof, good sir.

dog = Dog.new
dog.bark
# => Woof woof, good sir.

As shown, the second initialized Dog barks the same way as the trained Dog. Globally, the way a Dog barks has been changed after the training file has been included.

Enter Refinements

An alternative way to extend a class’ functionality is by using the built in ruby construct: Refinements. Refinements are context specific alterations to a class’ methods.

To refine the Dog class, a module needs to be written:

module SophisticatedDog
  refine Dog do
    def bark
      "Woof woof, good sir."
    end
  end
end

The interesting piece here is the refine method. This method returns an overlaid module specific to the class passed into it, allowing a very small scoped change of the Dog class.

To include this Refinement in the place where it is needed, the using method can be added to the Training class.

class Training
  using SophisticatedDog

  def train(dog)
    dog.trained = true
    dog.bark
  end
end

Now, the changes to the Dog method are contained within the Training class:

dog = Dog.new
dog.bark
# => woof woof

require './training'
training = Training.new
training.train(dog)
# => Woof woof, good sir.

Neither the passed in dog, nor newly created dogs’ bark method has been permanently changed:

dog.bark
# => woof woof

dog = Dog.new
dog.bark
# => woof woof

No more surprise behaviour! Each new Dog is created just as unrefined and unsophisticated as ever.

Super Cool

Another problem with traditional monkey patching is that the patched method is no longer accessible.

class Dog
  def bark
    'woof woof'
  end
end

# Then later:

class Dog
  def bark
    super + ', good sir.'
  end
end

Dog.new.bark
# => NoMethodError: super: no superclass method `bark' for Dog

The original implementation of Dog had a very specific string that might not want to be duplicated. When the class is monkey patched, the original method is replaced and super is not accessible.

In a less silly example, maybe the method being patched had some valuable code a patch could have used. Since Refinements are not strict code overwrites, they maintain the super functionality found in inheritance:

class Dog
  def bark
    'woof woof'
  end
end

module SubWoofer
  refine Dog do
    def bark
      super.split(' ').first
    end
  end
end

class DogTest
  using SubWoofer

  def call
    dog = Dog.new
    dog.bark
  end
end

DogTest.new.call
# => 'woof'

Dog.new.bark
# => 'woof woof'

The SubWoofer Refinement was able to call the existing Dog#bark method and change it how it saw fit. This keeps the code around barking DRY. Since Refinements are intended to extend or refine existing code, it makes sense that they could use the existing code to their benefit. When that existing code is changed, it would be arduous to make every single Refinement aware.

Using Refinements is a nice alternative to the sledgehammer that is monkey patching. Bending a class to fit a very specific need in an encapsulated manner can mean the difference between a stable system and one riddled with hard to track down bugs. While Refinements still have their own drawbacks, they are much less intrusive than the alternative.