Monkey

I think the most significant strength of the Ruby language is its impressive power and flexibility in metaprogramming. Affectionately dubbed "Monkey Patching" by Ruby developers, metaprogramming makes it easy to 'hack' existing code and frameworks like Rails for infinite customization, while making it easy to keep these hacks legible, organized and maintainable.

A powerful programming concept that Ruby makes easy to implement is the use of Proxy Objects. Since Ruby implements duck typing, a proxy object can easily step in to take the place of a regular object so long as the two share a similar object interface.

The following tutorial is the extraction of a "real world" problem that was solved with the simple implementation of a few proxy objects.

The Problem

Let's say that we are displaying information about a collection of people in our system, and we want to display min, max and averages for the attributes of the members of that collection. An immediate solution would be to clutter up my template with code to find the statistic values, but this gets messy and puts too much logic in my view. Using some kind of helper methods would help extract the logic from the view, which would be better.

That's still not quite what I want, though. What I would really like is to have an object that acts just like a Person, but that gives me values based on a collection of Person objects rather than just one. A nice implementation would be:

@john.age #=> 22
@jim.age #=> 48
@susan.age #=> 20
avg = AveragePerson.new([@john, @jim, @susan])
avg.age #=> 30

This approach is very strong if, for example, you're creating a table by iterating through a collection of people. Thanks to duck typing, an AveragePerson could be slipped into a collection of Person objects and the table would be none the wiser -- the average data would be displayed just the same as a single person's data.

Creating Some Models

First, let's whip up some quick model classes. I will simply use new Ruby classes, but the following techniques can be used with ActiveRecord classes in Rails just as easily.

class Person
  attr_accessor :name, :age, :inches, :weight

  def initialize(n, a, i, w)
    @name = n
    @age = a
    @inches = i
    @weight = w
  end
end

class AveragePerson
  def initialize(col)
    @collection = col
  end
end

Writing Some Tests

Let's write some RSpec tests to set some goals for our AveragePerson class:

require 'rubygems'
require 'spec'

describe "People Statistics" do
  before(:each) do
    @james = Person.new("James", 23, 74, 210)
    @cheryl = Person.new("Cheryl", 47, 63, 115)
    @timmy = Person.new("Timmy", 12, 55, 87)
    @people = [@james, @cheryl, @timmy]
  end

  describe "AveragePerson" do
    before(:each) do
      @average = AveragePerson.new(@people)
    end

    it "should give the average age" do
      @average.age.should eql((@james.age + @timmy.age + @cheryl.age) / 3)
    end

    it "should give the average weight" do
      @average.weight.should eql((@james.weight + @timmy.weight + @cheryl.weight) / 3)
    end

    it "should give the average inches" do
      @average.inches.should eql((@james.inches + @timmy.inches + @cheryl.inches) / 3)
    end

    it "should be named 'Average'" do
      @average.name.should eql("Average")
    end
  end
end

Now let's get these to pass.

A Little Proxy Magic

Per our specs, we need to add #age, #weight, #inches and #name methods to AveragePerson. This would do the trick, but it's not DRY:

class AveragePerson
  def name
    "Average"
  end  

  def age
    total = @collection.inject(0){ |sum, person| sum += person.age }
    total / @collection.length
  end

  def weight
    total = @collection.inject(0){ |sum, person| sum += person.weight }
    total / @collection.length
  end

  def inches
    total = @collection.inject(0){ |sum, person| sum += person.inches }
    total / @collection.length
  end
end

The tests should pass now, but we have some serious repetition and need to DRY this up. It's also not extensible -- any time a method is added to Person, another would need to be added to AveragePerson.

All we're really doing here is proxying the method that AveragePerson receives to each member of the collection, so let's use method_missing to do this more concisely and extensibly.

class AveragePerson
  def name
    "Average"
  end  

  # proxy methods to collection, return the average of results
  def method_missing(method_name, *args, &block)
    total = @collection.inject(0){ |sum, person| sum += person.send(method_name, *args, &block) }
    total / @collection.length
  end
end

Now AveragePerson will proxy any methods it doesn't have to its collection, add up the results and return the average. The tests still pass and we have much cleaner code.

Adding in Height

It isn't very helpful to just tell a user how many inches tall a person is, it would be much more useful to return a string like "5ft 8in". Let's integrate that into the Person model and write a test for AveragePerson:

class Person
  def height
    inches_to_height(self.inches)
  end

  private

  def inches_to_height(_inches)
    ft = _inches / 12
    ins = _inches % 12
    "#{ft}ft #{ins}in"
  end
end

###

describe "AveragePerson" do
  it "should give the average height" do
    @average.height.should eql("5ft 4in")
  end
end

Though our AveragePerson object is proxying the #height method to the collection, this test crashes and burns because our #method_missing assumes that each member will return a number, not a string like "6ft 0in". To fix this, let's "override" AveragePerson#height.

class AveragePerson
  def height
    ft = self.inches / 12
    ins = self.inches % 12
    "#{ft}ft #{ins}in"
  end
end

The tests pass, but we still have some refactoring to do. I've duplicated the height display logic from Person into AveragePerson, so let's extract this out into a module and include it in both classes:

module PersonHelper
  def height
    inches_to_height(self.inches)
  end

  def inches_to_height(_inches)
    ft = _inches / 12
    ins = _inches % 12
    "#{ft}ft #{ins}in"
  end
end

class Person
  include PersonHelper
end

class AveragePerson
  include PersonHelper
end

I can also include PersonHelper into my RSpec tests so that I can make them more legible using the #inches_to_height helper method:

describe "AveragePerson" do
  include PersonHelper
  it "should give the average height" do
    # @average.height.should eql("5ft 4in")
    @average.height.should eql(inches_to_height((@james.inches + @timmy.inches + @cheryl.inches) / 3))
  end
end

Creating Max and Min Classes

Using the same proxy techniques, we can easily build out MaxPerson and MinPerson classes. First the tests:

describe "MaxPerson" do

  before(:each) do
    @max = MaxPerson.new(@people)
  end

  it "should give the max age" do
    @max.age.should eql(@cheryl.age)
  end

  it "should give the max weight" do
    @max.weight.should eql(@james.weight)
  end

  it "should give the max inches" do
    @max.inches.should eql(@james.inches)
  end

  it "should be named 'Max'" do
    @max.name.should eql("Max")
  end

  it "should give the max height" do
    @max.height.should eql(inches_to_height(@james.inches))
  end

end

describe "MinPerson" do

  before(:each) do
    @min = MinPerson.new(@people)
  end

  it "should give the min age" do
    @min.age.should eql(@timmy.age)
  end

  it "should give the min weight" do
    @min.weight.should eql(@timmy.weight)
  end

  it "should give the min inches" do
    @min.inches.should eql(@timmy.inches)
  end

  it "should be named 'Min'" do
    pending("Needs to be implemented") do
      @min.name.should eql("Min")
    end
  end

  it "should give the min height" do
    @min.height.should eql(inches_to_height(@timmy.inches))
  end
end

And here are the classes:

class MaxPerson
  include PersonHelper

  def initialize(col)
    @collection = col
  end

  def name
    "Max"
  end

  def method_missing(method_name, *args, &block)
    @collection.inject(0) do |highest, person| 
      val = person.send(method_name, *args, &block)
      highest = (val > highest) ? val : highest
    end
  end
end

class MinPerson
  include PersonHelper

  def initialize(col)
    @collection = col
  end

  def name
    "Min"
  end

  def method_missing(method_name, *args, &block)
    # assumes at least one item in the collection will return a value less than 1000000
    @collection.inject(1000000) do |lowest, person| 
      val = person.send(method_name, *args, &block)
      lowest = (val < lowest) ? val : lowest
    end
  end
end

More Options with Extend

Our proxy objects are working nicely, but what if we want to access our objects a different way? Maybe it would be slicker if the collection itself had methods to instantiate these objects rather than simply instantiating them explicitly. Let's do something like this:

@people = [@john, @jim, @susan]
@people.min_person #=> MinPerson object
@people.max_person #=> MaxPerson object
@people.avg_person #=> AveragePerson object

Here is a new describe block of tests:

describe "PeopleCollection" do
  it "should return a MinPerson" do
    @people.min_person.class.should eql(MinPerson)
  end

  it "should return a MaxPerson" do
    @people.max_person.class.should eql(MaxPerson)
  end

  it "should return an AveragePerson" do
    @people.avg_person.class.should eql(AveragePerson)
  end
end

Let's create a module that will define these collection methods:

module PeopleCollection
  def min_person
    MinPerson.new(self)
  end

  def max_person
    MaxPerson.new(self)
  end

  def avg_person
    AveragePerson.new(self)
  end
end

One way to apply this module so that our collection will have access to these methods is to open up the Array class and include the module:

class Array
  include PeopleCollection
end

# or you can use the send hack:
# Array.send :include, PeopleCollection

I'm not sure this is what I want, though. The tests passs, but I don't really need (or want) every single array in my app to have these methods -- at best it's sloppy, at worst I could run into conflicts. I really only want my @people collection to have this functionality, so instead I will extend that instance with my module only when I need it. Here's the updated describe test block:

describe "PeopleCollection" do
  before(:each) do
    @people.extend PeopleCollection
  end

  it "should return a MinPerson" do
    @people.min_person.class.should eql(MinPerson)
  end

  it "should return a MaxPerson" do
    @people.max_person.class.should eql(MaxPerson)
  end

  it "should return an AveragePerson" do
    @people.avg_person.class.should eql(AveragePerson)
  end
end

I have found this to be a helpful technique when you just need to add a little extra functionality to a single object and want to keep it lightweight.

Here is the final version of the script.

Technorati Profile




RSS Feed


CATEGORIES


ARCHIVES


BOOKMARKED


Add to Technorati Favorites