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.
I don't really understand what is happening at:
ReplyDeletedef #{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?
This comment has been removed by the author.
ReplyDeleteWhen 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.
DeleteFor 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.
Great post! I'm learning Ruby and this was both clear and amazing, congratulations.
ReplyDeleteThis was wonderful. Thanks!
ReplyDeleteThank you this is so precious
ReplyDeleteGreat post indeed !
ReplyDeleteI'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 ?
I think the spec is asking you to provide the history but without the current (last) value.
DeleteYou probably need to strip the last value off in order to pass the test.
This comment has been removed by the author.
ReplyDelete