Ruby Refinements
13 Dec 2015The 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.