Ruby Refinements13 Dec 2015
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.
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
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:
class Training def train(dog) dog.trained = true dog.bark end end class Dog def bark "Woof woof, good sir." end end
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.
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
To include this Refinement in the place where it is needed, the
using method can be added to the
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
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.
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'
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.