Dave's Ramblings
Dave's Ramblings

Dave's Ramblings


Blabberings and random thoughts from a Rubae grasping at a few languages.

Dave Allie
Author

Share


Clean Monkey Patching

Recently, I realised the importance of clean monkey patching in Ruby. This is my take on what I consider to be best practice.

Dave AllieDave Allie

Monkey patching in Ruby isn't always what it seems. Sometimes you're doing it the right way for the wrong reasons, other times you are doing it just flat out wrong. Hopefully, after reading this post you'll have a better idea what you're doing, and you'll be monkey patching the right way for the right reasons.

Scenario

Let's create a hypothetical scenario in which I need to monkey patch the Hash class. In this situation, I need to accomplish two things:

  • Allow JSON strings to be interpolated by Hash's try_convert class method
  • Pretty print JSON using a new method on a Hash instance, namely to_pretty_json

When I'm done I should be able to run the following and get the associated output (where hash_ext.rb is our Hash extension file):

require './hash_ext.rb'
Hash.try_convert('{"a": 1}')
# => {"a"=>1}

puts({"a" => 1}.to_pretty_json)
# {
#   "a": 1
# }

There are three main ways to do this:

  1. Re-opening the Hash class to define/redefine those methods
  • Including a module in the Hash class that contains the new/updated methods
  • Prepending a module to the Hash class that contains the new/updated methods

Re-opening the Hash class

This is the classic example of how versatile Ruby can be, re-opening a class or module and adding or updating methods. It's used a lot in showing the power of Ruby to newcomers, but can have some nasty side effects, so this method is rarely used by libraries or production systems.

As the original try_convert method is a Ruby wrapper around a C binding, we need to be careful when redefining it. Hash's try_convert docs simply say:

Try to convert obj into a hash, using to_hash method. Returns converted hash or nil if obj cannot be converted for any reason.

It's a pretty simple method, so if we wanted to, we could write our own Hash#try_convert from scratch. Something like this would do the trick:

class Hash
  def self.try_convert(obj)
    obj.to_hash rescue nil
  end
end

Then, it would be extremely easy to adjust the logic to support our new use case:

require 'json'

class Hash
  def self.try_convert(obj)
    result = obj.to_hash rescue nil
    result || JSON.parse(obj) rescue nil
  end
end

For a simple method like this, this approach will work, but for cases where the original method is complicated or is going to/can change, this form of monkey patching will come back to haunt you. Say, for example, the original try_convert's logic was changed, or an exploit was patched. You would have to be consistently monitoring changes to try_convert and replicating the change in your patch code anytime a change was made to the upstream. Not very scalable. There is, however, another option. Instead of overriding the method directly, we can alias the original method, and still access it under a different name:

# hash_ext.rb
require 'json'

class Hash
  class << self
    alias_method :original_try_convert, :try_convert
    def try_convert(obj_or_json_string)
      original_try_convert(obj_or_json_string) ||
          JSON.parse(obj_or_json_string) rescue nil
    end
  end

  def to_pretty_json
    JSON.pretty_generate(self)
  end
end

If we were to monkey patch Hash by reopening the Hash class, this would be the most appropriate form of it. Ideally, we would never monkey patch like this, as it comes with some pretty serious side effects.

Advantages

  • Easy to write, easy to understand.

Disadvantages

  • Chance of alias overwriting existing method or new method overwriting your alias.
  • super doesn't point to the original method. We have to either alias the original method or reproduce its logic.
    • Replicating logic means that if the original logic is updated, we'll have to update our monkey patch.
  • If the class or method hasn't been defined before re-opening it, you will inadvertently create it.

Including a module

Including a module into the base class or module is by far the most popular way libraries and production systems monkey patch in Ruby. It comes with some really nice tools, like the included hook method which gets run each time your module is included in a class or module.

With this included hook, instead of including instance methods and singleton methods separately, we can use the hook when including the instance methods, to also attach the class methods.

require 'json'

module HashExt
  def self.included(base)
    base.instance_eval do
      singleton_class.send(:alias_method, :original_try_convert, :try_convert)
      def try_convert(obj_or_json_string)
        original_try_convert(obj_or_json_string) ||
            JSON.parse(obj_or_json_string) rescue nil
      end
    end
  end

  def to_pretty_json
    JSON.pretty_generate(self)
  end
end

Hash.include(HashExt)

Using this form of monkey patching comes with a couple of the same issues from re-opening the class, and a couple of new smells. The fact that we are using a send just so we can utilise the original method is a pretty big code smell. Again, we could replicate the logic of the original method, but that comes with some pretty significant downsides as we just found out.

This form monkey patching comes with a couple of benefits too. If we have a similar class, which we want to apply the same patches to, we can simply just add MyClass.include(HashExt) to the bottom of hash_ext.rb.

Advantages

  • An exception will be raised if base class or module isn't defined.

Disadvantages

  • Chance of alias overwriting existing method or new method overwriting your alias.
  • super doesn't point to the original method. We have to either alias the original method or reproduce its logic.
    • Replicating logic means that if the original logic is updated, we'll have to update our monkey patch.
  • Requires sending the alias_method method to the singleton class.

Prepending a module

Prepending modules in Ruby is a more badass form of including them. It comes with a hook like include's, only it's called prepended instead of included. The big difference is that prepend and include insert your module differently into the ancestry chain. I go into more detail on what that means in my post on Include vs Prepend, but for now, just be aware that it opens up the opportunity to use super in our custom module.

Using super in our module means we don't have to alias the original method, instead, we can override it and use super to reference the original method. This makes our extension module a lot clearer and a lot easier to read and manage.

# hash_ext.rb
require 'json'

module HashExt
  def self.prepended(base)
    base.singleton_class.prepend(ClassMethods)
  end

  module ClassMethods
    def try_convert(obj_or_json_string)
      super || JSON.parse(obj_or_json_string) rescue nil
    end
  end

  def to_pretty_json
    JSON.pretty_generate(self)
  end
end

Hash.prepend(HashExt)

Advantages

  • Don't have to pollute the base class or module with aliased methods when overwriting base class methods.
  • An exception will be raised if base class or module isn't defined.
  • super points to the original method in the class or module.

Disadvantages

  • Not as simple as re-opening the class.

Final thoughts

Although include wasn't ideal for our scenario, it still has many uses. It is very useful if you want to define common logic, and want the base class you're including into to overwrite that logic (almost like a superclass with a very small segment of logic). In fact, Rail's ActiveSupport::Concern encourages behaviour like this as uses include behind the scenes.

For the scenario we proposed above, where we were overwriting and extending logic defined in Hash, prepend was the right choice. It allowed us to add our new logic without making a mess by offering the use of super instead of adding aliased methods into our base class.


For more information on include vs prepend, please take a look at my post comparing the two: Include vs Prepend.

For more information on the different Module hook methods like included and prepended, take a look at this comprehensive article on Ruby's hook methods.

If you think I've made a mistake/am completely and utterly wrong/just want to provide some feedback, please leave a comment below.

TL;DR: When overwriting someone else's library/code, prepend your extension module to their class, don't include it.

Dave Allie
Author

Dave Allie

Comments