The Beginner's Guide To Monkey Patching In Ruby
Published: Feb 17, 2022
Last updated: Feb 17, 2022
"Monkey patching" is used (and misused) a lot in Ruby. It is a way of adding and overriding methods to existing classes without having to rewrite the entire class.
This post looks to demonstrate how monkey patching works in Ruby, as well as some best practices found from across the community.
Source code can be found here
Prerequisites
Getting started
We will create the project directory demo-monkey-patching-in-ruby
and use Bundler to initialize the project:
$ mkdir demo-monkey-patching-in-ruby $ cd demo-monkey-patching-in-ruby # Create the required folders and files # Our recursive creation for the extensions $ mkdir -p lib/core_extensions/monkey_patching $ touch lib/monkey_patching.rb lib/core_extensions/monkey_patching/basic.rb # Make the test folder $ mkdir spec $ touch spec/monkey_patching_spec.rb # Initialise Bundler project $ bundler init # RSpec added for testing $ bundler add rspec --group "development,test" # Pry added for debugging $ bundler add pry --group "development,test"
At this stage, our project is now ready to start working with.
Our basic class
Within the lib/monkey_patching.rb
file, let's add the following code:
class MonkeyPatching def hello 'world' end def yell 'gaaah!' end def extend 'extended' end end
This class is contrived for the sake of our demonstration, but in this post we will be aiming for the following:
- An example of the
hello
method being overwritten by a monkey patch. - Demonstrating that
yell
will remain unaffected after the class is monkey patched. - An example of extending the functionality of the
extend
method by using monkey patching.
Before we write out our extension file, it is best to do a quick pivot to understand why we will opt to use prepend
instead of include
for the purpose of monkey patching in Ruby.
include
vs prepend
Dave Allie has an excellent post demonstrating the ancestral chain of classes when using include
vs prepend
.
It is best to read the post through, but as a simple example, here is the relevant ancestral demonstration:
# For `include` module IncludeExt end class IncludeExample include IncludeExt end IncludeExample.ancestors # => [IncludeExample, IncludeExt, Object, Kernel, BasicObject] # For `prepend` module PrependExt end class PrependExample prepend PrependExt end IncludeExample.ancestors # => [PrependExample, PrependExt, Object, Kernel, BasicObject]
The tl;dr from the post most relevant to monkey patching: Use prepend
when overwriting/extending an existing class.
If we prepend
a module to a class with methods of the same name, we will overwrite the base definition. If we use the super
keyword within a prepend
, we can call to the original definition and extend its behavior. We will demonstrate both overwriting as well as extending.
Note: there is another alternative where we re-write the class for the monkey patching, but I have opted not to cover the example as it is not an approach I would recommend in the wild. There is an example of this on Geeks for geeks.
The monkey patching spec
Let's write out our spec into spec/monkey_patching.rb
so that we can see what our aims our. The approach in these specs are contrived but designed to colocate the examples into one test file:
require 'monkey_patching' require 'core_extensions/monkey_patching/basic' RSpec.describe MonkeyPatching do context 'no monkey patching' do it 'should return "world"' do expect(subject.hello).to eq('world') end it 'should return our gasp when we yell' do expect(subject.yell).to eq('gaaah!') end it 'should return "extended" when we call to extend' do expect(subject.extend).to eq('extended') end end context 'with monkey patching' do before do MonkeyPatching.prepend CoreExtensions::MonkeyPatching::Basic end xit 'should return "people"' do expect(subject.hello).to eq('people') end xit 'should return our gasp when we yell' do expect(subject.yell).to eq('gaaah!') end xit 'should return "I am extended" when we call to extend' do expect(subject.extend).to eq('I am extended') end end end
In our specs, we split up the examples into two contexts:
- Without monkey patching.
- With monkey patching.
The "With monkey patching" context has a before hook to prepend
the module CoreExtensions::MonkeyPatching::Basic
that we have included in our file before the tests.
I am also currently using xit
to denote the tests that we will skip and work on later.
As things currently stand, if we run bundler exec -- rspec spec/monkey_patching_spec.rb
then we should get the following output:
$ bundler exec -- rspec spec/monkey_patching_spec.rb ...*** Pending: (Failures listed here are expected and do not affect your suite's status) 1) MonkeyPatching with monkey patching should return "people" # Temporarily skipped with xit # ./spec/monkey_patching_spec.rb:24 2) MonkeyPatching with monkey patching should return our gasp when we yell # Temporarily skipped with xit # ./spec/monkey_patching_spec.rb:28 3) MonkeyPatching with monkey patching should return "I am extended" when we call to extend # Temporarily skipped with xit # ./spec/monkey_patching_spec.rb:32
Our basic expectations for our standard MonkeyPatching
class all pass, and our three tests that are marked xit
are not yet run.
If we change our fourth test 'should return "people"' to it
and run our tests again, we will see our fourth test fails as subject.hello
still returns world
.
$ bundler exec -- rspec spec/monkey_patching_spec.rb ...F** Pending: (Failures listed here are expected and do not affect your suite's status) 1) MonkeyPatching with monkey patching should return our gasp when we yell # Temporarily skipped with xit # ./spec/monkey_patching_spec.rb:28 2) MonkeyPatching with monkey patching should return "I am extended" when we call to extend # Temporarily skipped with xit # ./spec/monkey_patching_spec.rb:32 Failures: 1) MonkeyPatching with monkey patching should return "people" Failure/Error: MonkeyPatching.prepend CoreExtensions::MonkeyPatching::Basic NameError: uninitialized constant CoreExtensions # ./spec/monkey_patching_spec.rb:21:in `block (3 levels) in <top (required)>' Finished in 0.00441 seconds (files took 0.12602 seconds to load) 6 examples, 1 failure, 2 pending Failed examples: rspec ./spec/monkey_patching_spec.rb:24 # MonkeyPatching with monkey patching should return "people"
To remedy this, let's move onto writing some code for our extension to fix this problem.
Writing our extension
The file lib/core_extensions/monkey_patching/basic.rb
is where we will write our monkey patching code.
The naming convention used here comes from a Rails convention brought up in this blog post by Justin Weiss where states that the Rails convention is to write our monkey patches to the file-naming convention lib/core_extensions/<class_name>/<group>.rb
. Feel free to adjust as you see fit for your own project.
Note: My forced interpretation is different to the Rails convention, but for the sake of this post, I will push forward - the example still relays the important parts. The golden rule is to have a set standard and convention that you follow when it comes to defining where your monkey patches live.
Within the file, add the following:
module CoreExtensions module MonkeyPatching module Basic def hello 'people' end def extend "I am #{super}" end end end end
Our code here enables us to fulfill all three of our objectives.
hello
is overwritten to return'people'
.extend
is extended to return"I am #{super}"
wheresuper
calls to thesuper
class on our ancestor tree: the originalMonkeyPatching
implementation ofextend
.
At this stage, we are ready to go back to our test file and change all of our xit
tests to it
.
require 'monkey_patching' require 'core_extensions/monkey_patching/basic' RSpec.describe MonkeyPatching do context 'no monkey patching' do it 'should return "world"' do expect(subject.hello).to eq('world') end it 'should return our gasp when we yell' do expect(subject.yell).to eq('gaaah!') end it 'should return "extended" when we call to extend' do expect(subject.extend).to eq('extended') end end context 'with monkey patching' do before do MonkeyPatching.prepend CoreExtensions::MonkeyPatching::Basic end it 'should return "people"' do expect(subject.hello).to eq('people') end it 'should return our gasp when we yell' do expect(subject.yell).to eq('gaaah!') end it 'should return "I am extended" when we call to extend' do expect(subject.extend).to eq('I am extended') end end end
If we now run RSpec, we should see the following output:
$ bundler exec -- rspec spec/monkey_patching_spec.rb ...... Finished in 0.00611 seconds (files took 0.15551 seconds to load) 6 examples, 0 failures
Perfect! Our monkey patching code has been successfully applied to our MonkeyPatching
class.
We can look deeper at this using binding.pry
to see what is happening within our MonkeyPatching
class.
Let's adjust our first test to add binding.pry
to our test:
require 'pry' # require pry gem require 'monkey_patching' require 'core_extensions/monkey_patching/basic' RSpec.describe MonkeyPatching do context 'no monkey patching' do it 'should return "world"' do expect(subject.hello).to eq('world') end it 'should return our gasp when we yell' do expect(subject.yell).to eq('gaaah!') end it 'should return "extended" when we call to extend' do expect(subject.extend).to eq('extended') end end context 'with monkey patching' do before do MonkeyPatching.prepend CoreExtensions::MonkeyPatching::Basic end it 'should return "people"' do binding.pry # LINE CHANGED HERE expect(subject.hello).to eq('people') end it 'should return our gasp when we yell' do expect(subject.yell).to eq('gaaah!') end it 'should return "I am extended" when we call to extend' do expect(subject.extend).to eq('I am extended') end end end
Now when we run our tests, our debugger will open up in the terminal. We can use the ancestors
method to see what is happening within our MonkeyPatching
class.
[1] pry(#<RSpec::ExampleGroups::MonkeyPatching::WithMonkeyPatching>)> MonkeyPatching.ancestors => [CoreExtensions::MonkeyPatching::Basic, MonkeyPatching, Object, PP::ObjectMixin, Kernel, BasicObject]
Here we can note that CoreExtensions::MonkeyPatching::Basic
is the first ancestor of MonkeyPatching
. This is because we have added our monkey patching code to the top of our class.
The super
ancestor of CoreExtensions::MonkeyPatching::Basic
is MonkeyPatching
, so this is what helps us to access the original implementation of extend
.
Below are some more debugging calls to show what is returned by binding.pry
to aid in demonstrating what our MonkeyPatching
subject has as return values when those methods are invoked:
[2] pry(#<RSpec::ExampleGroups::MonkeyPatching::WithMonkeyPatching>)> subject.extend => "I am extended" [3] pry(#<RSpec::ExampleGroups::MonkeyPatching::WithMonkeyPatching>)> subject.hello => "people" [4] pry(#<RSpec::ExampleGroups::MonkeyPatching::WithMonkeyPatching>)> subject.yell => "gaaah!"
Summary
Today's post demonstrated how to use the prepend
method to add monkey patching code to a class.
Monkey patching can be useful for augmenting functionality to classes in Ruby, but you must implement them with caution to ensure that you don't break the original functionality or make the implementation of monkey patches confusing and messy.
Resources and further reading
Photo credit: joshstyle
The Beginner's Guide To Monkey Patching In Ruby
Introduction