Thursday, April 19, 2012

Ruby Metaprogramming with class_eval

One of the really cool and exciting aspects of using a dynamic programming language like Ruby is that you can generate code dynamically at runtime. Yup, you heard me, at runtime. Try doing something like this in Java. This is one of the features that allows all the magic that happens behind the scenes in Ruby on Rails possible. For example, this is how Rails' attr_accessor automatically creates getters and setters for you. If you don't remember, you can call attr_accessor in an ActiveRecord class to define a "virtual" attribute, or in other words an attribute that isn't persisted in the database table. I want to explain this idea in more detail by creating a new method called attr_accessor_with_history, which is similar to attr_accessor, but it will also store the attribute's history. Say we have the following class:
class Test
   attr_accessor_with_history :sample
end
Then this is how the attr_accessor_with_history method should work in the Ruby console:
t = Test.new
t.sample = "test"  # => "test"
t.sample = 1       # => 1
t.sample = :abc    # => :abc
t.sample_history   # => [nil, "test", 1, :abc]
So how can we do this? Let's first create a new file called class.rb and put the following inside:
class Class
   def attr_accessor_with_history(attr_name)

   end
end
By defining our method in the class Class, all Ruby classes will be able to use it. This is because in Ruby, all classes are an object of the class Class. You can find more information in the API here. The second thing we want to know is that Ruby provides a method called class_eval, which takes a string and evaluates it in the context of the current class. So in our example, the class Test is calling our method, so when class_eval is called, it will be evaluated as if it were called in the class Test.

Let's take care of the easy part first, which is creating the attribute's getter methods. This is pretty straight forward because we don't really need to do anything special here.
class Class
   def attr_accessor_with_history(attr_name)
      attr_name = attr_name.to_s         # make sure it's a string
      attr_reader attr_name              # create the attribute's getter
      attr_reader attr_name + "_history" # create bar_history getter

      # write our setter code below
      class_eval %Q(
                  def #{attr_name}=(attr_name)
                  end
                 )
   end
end
The %Q is Ruby syntax for an interpolated string, which allows us to create a string, and it will also interpret Ruby values such as anything in #{}. So in thinking about how to define our setter, we need to store the previous history of the variable. How can we do this? Well we can store stuff in an Array. If we push things in to the Array like a Stack, we can also figure out the order in which things were inserted. The one thing we need to be careful about is the initial case where we should initialize the array, and also insert the first value as nil. This is what we get:
class Class
   def attr_accessor_with_history(attr_name)
      attr_name = attr_name.to_s         # make sure it's a string
      attr_reader attr_name              # create the attribute's getter
      attr_reader attr_name + "_history" # create bar_history getter

      # write our setter code below
      class_eval %Q(
                  def #{attr_name}=(attr_name)
                     @#{attr_name} = attr_name

                     unless @#{attr_name + "_history"}
                        @#{attr_name + "_history"} = []
                        @#{attr_name + "_history"} << nil
                     end

                     @#{attr_name + "_history"} << attr_name

                   end
                 )
   end
end
First we set the value of the variable. Second, we check if the _history array has been initialized yet, and if it hasn't, then go ahead and initialize it. Lastly, we insert the new value into the _history array.

That was just a quick example of some of the cool things you can do with metaprogramming in Ruby. I want to end by showing a real world example of how I used metaprogramming on a project I'm currently working on. If you haven't read my previous blog posts, I'm working on a Rails site using MongoDB (with the Mongoid gem) that deals with computer games. I have a class called Event, and I need to define some scopes based on the game type so that I can do queries such as Event.all.starcraft2, which would return all Events that have a game attribute of 'starcraft2'. I could easily hard code these scopes, but it will be annoying to always have to add a new scope every time we add support for a new game. Instead I can dynamically create these scopes using Ruby metaprogramming.
  class_eval %Q(
              #{Constants::GAMES}.each_pair do |key, value|
                scope key, where(game: value)
              end
             )
First I need to explain the Constants::GAMES variable. I created a dynamic_fields.yml file that stores all the games that my application supports. Please refer to my previous post here for more detailed information. So when my application loads, it reads in that YML file into the Constants::GAMES variable, which is a hash. The key is the game name as a symbol, and the value is the game name as a string. For each game, it will create a scope with the same name (as the game), and it does all of this automatically. I could have wrote a line of code for each game, but this is much cleaner to write and easier to maintain in the future.

10 comments:

  1. I don't really understand what is happening at:
    def #{attr_name}=(attr_name)

    For one, what does this mean? It seems that if I were to make a class call Foo and make call attr_accessor_with_history :bar, it would evaluate to
    def bar=(attr_name), but I don't know what that means?
    Secondly, I tried
    def #{attr_name} = (attr_name)
    and it doesn't work. Why does the spaces matter?

    ReplyDelete
  2. This comment has been removed by the author.

    ReplyDelete
    Replies
    1. When def #{attr_name}=(attr_name) is called it creates a method for the class with the attr_name passed to it. It is just a setter method in Ruby.

      For example if you create a class named foo and add an accessor to it by this:
      class Foo
      attr_accessor_with_history :bar
      end

      the above code will be called like this:
      def bar=(attr_name)
      @bar = attr_name
      unless @bar_history #this makes sure the bar_history is created
      @bar_history = []
      @bar_history << nil
      end
      @bar_history << @bar
      end

      And if you create another accessor:
      class Foo
      attr_accessor_with_history :baz
      end

      just replace the bar in the above code with baz.
      This way you will access to the previous items that have been assigned to the Foo instance with the variable name added _history behind it.
      I think Ruby does not allow spaces in the setter method, that's just the way it is.

      Edit: Couldn't edit my previous comment, so I had to delete it and repost.

      Delete
  3. Great post! I'm learning Ruby and this was both clear and amazing, congratulations.

    ReplyDelete
  4. Great post indeed !
    I'm trying to use it as part of CS169.1x Engineering Software as a Service course, and I'm getting the following error in the RSpec tests :

    Failure/Error: obj1.foo_history.should == [nil, :x]
    expected: [nil, :x]
    got: [nil, :x, :y] (using ==)
    expected: [nil, "x"]
    got: [nil, "x", "y"] (using ==)
    .
    .
    .
    .

    this happens for all the tests, It see,s like the out put should not include the current value .

    any idea how can I fix it ?

    ReplyDelete
    Replies
    1. I think the spec is asking you to provide the history but without the current (last) value.
      You probably need to strip the last value off in order to pass the test.

      Delete
  5. This comment has been removed by the author.

    ReplyDelete
  6. Really nice blog post.provided a helpful information.I hope that you will post more updates like thisRuby on Rails Online Course India

    ReplyDelete