Skip to content

MR13 Monkey Patching

Dave Strus edited this page Feb 1, 2016 · 3 revisions

Monkey Patching

In this unit, we'll do the following:

  • Add a convenience method for replacing underscores with spaces

We'll learn about the following concepts or tools:

  • Monkey patching

Twice now we've wanted human-readable versions of our attribute names. It seems like something that will pop up again, and we'll just end up copying and pasting the same code... unless we do something about it!

The main thing we did to make them readable was to replace underscores with spaces, .gsub('_', ' '). We've also capitalized the attribute name in both cases, but we may not always want to. It seems acceptable to continue to chain the capitalization on the end when calling it.

Let's write a humanize method that takes a string, and returns a copy of that string with the underscores replaced with spaces. Now then... where do we put this method?

It doesn't really make sense to make it an instance method of Mutant.

class Mutant
  def humanize(str)
    str.gsub('_', ' ')
  end
end

Think about how strangely that would read when it's called.

mutant.attributes.each do |attribute, value|
  report_output += mutant.humanize(attribute.to_s).capitalize + ': '
  report_output += "#{value}\n"
end

The operation we're performing doesn't really have anything to do with the mutant instance. Sure we happen to be passing in an attribute name as an argument to the method, but the method doesn't use any instance variables or call any other instance methods. Even if it were using the attribute name without having it passed in as an argument, the names apply to the entire class. They don't change from one instance to another.

We could make it a class method.

class Mutant
  def self.humanize(str)
    str.gsub('_', ' ')
  end
end
mutant.attributes.each do |attribute, value|
  report_output += Mutant.humanize(attribute.to_s).capitalize + ': '
  report_output += "#{value}\n"
end

That's a little bit better, but it really doesn't have anything to do with mutants, period. It can transform any string.

We could make a module to hold utility methods like this one. That would be a good, clean solution. But it would be more fun if we could make it an instance method on all strings!

[1] pry(main)> 'my_gross_string'.humanize
=> "my gross string"

Ruby makes it very easy to do exactly that. Very, very easy. You can re-open an existing class, as though you were defining it, and add to it as you please.

class String
  def humanize
    gsub('_', ' ')
  end
end

We're not replacing the String class with a new one. We're re-opening the one that already exists and making modifications to it. Magic!

Changing classes or modules at runtime in this manner is referred to as monkey patching.

We can even replace existing methods with our own implementations. Want to change String#reverse to behave like capitalize instead? You can!

class String
  def reverse
    capitalize
  end
end

Hopefully that strikes you as a terrible idea. Even if we used a less ridiculous example—like monkey patching capitalize to capitalize the first letter of every word instead of just the first letter of the entire string—we'd still break any code that was dependent on the original behavior. In a project this small, that might not seem important. But even another built-in method might be calling capitalize internally, relying on its expected behavior. It's not an issue we'll know about until we run into it, and it could be a nightmare to debug when it happens.

But what about our String#humanize method? That seems really useful, and it doesn't conflict with anything in standard Ruby: It's a completely new method!

That's certainly much safer than changing the behavior of a pre-existing method, but it can still lead to trouble down the road. If we start to integrate our code into a larger project, or if we include third-party libraries in our project, we could start to create problems. We're not the first people on the block to think that String could use a humanize method, and others may be relying on the presence of a monkey patch that behaves differently.

For the moment, convenience trumps hypothetical side effects. Let's do it!

We could do this in one of our existing files, but it feels better to create a new lib/string.rb file.

lib/string.rb

class String
  def humanize
    gsub('_', ' ')
  end
end

Remember to include it...

roster.rb

require_relative 'lib/string'
require_relative 'lib/mutant'
require_relative 'lib/roster_application'
require 'pry'

...and to use it.

lib/roster_application.rb

report_output += attribute.to_s.humanize.capitalize + ': '

Since our humanize method is on String, we have to convert a symbol to a string before we can use it. Why don't we add humanize to Symbol too? We'll once again create a new file for it:

lib/symbol.rb

class Symbol
  def humanize
    to_s.humanize
  end
end

Remember to require it:

roster.rb

require_relative 'lib/string'
require_relative 'lib/symbol'
require_relative 'lib/mutant'
require_relative 'lib/roster_application'
require 'pry'

Now we can change both RosterApplication#collect_mutant_data and RosterApplication#report to use our fancy new monkey-patched methods.

lib/roster_application.rb

class RosterApplication
  def start
    @mutants = []
    puts 'Hello, mutant collector!'
  end

  def collect_mutant_data
    mutant = Mutant.new
    mutant.class.attribute_names.each do |attribute|
      print attribute.humanize.capitalize + ': '
      mutant.send "#{attribute}=", gets.chomp
    end

    @mutants << mutant
  end

  def mutant_descriptions
    @mutants.map(&:description).join("\n")
  end

  def report
    report_output = ''
    @mutants.each_with_index do |mutant, i|
      report_output += "\nMutant #{i + 1} ================================\n"
      mutant.attributes.each do |attribute, value|
        report_output += attribute.humanize.capitalize + ': '
        report_output += "#{value}\n"
      end
    end
    report_output
  end
end

Beautiful!