class Test attr_accessor_with_history :sample endThen 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 endBy 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 endThe %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 endFirst 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.