Introduction to Ruby Metaprogramming
Bài đăng này đã không được cập nhật trong 3 năm
This will be my first writing on Ruby Metaprogramming. My goal is just you taste the power and enchantment of the magical features of Ruby so that you can make a journey and explore more by yourself.
A Method for making methods
What surprise the most is with method "define_method". The usual way of defining new methods is to put your intended logic between def & end. And that works perfectly fine for the most part.
But consider a situation where you have to create a series of methods all of which have the same basic structure except for one string, and which can only be one of a certain set of strings. Now, you might say we'll just define one method and accept that one string as a parameter, which we can then use wherever we want. And you'd be right. But, the problem with such an approach is it's not particularly declarative: you don't know exactly what values that optional parameter can accept.
class Programmer
define_method("write_ruby") do |argument|
"writing ruby on #{argument}"
end
define_method("write_java") do |argument|
"writing java on #{argument}"
end
define_method("write_php") do |argument|
"writing python on #{argument}"
end
end
programmer = Programmer.new
puts programmer.write_ruby("Rails Project")
puts programmer.write_java("Android Project")
puts programmer.write_php("Web Project")
When we run the above codes we get the result as below:
writing ruby on Rails Project
writing java on Android Project
writing php on Web Project
The above piece of code causes three methods write_ruby, write_java, and write_php to be created. And invoking them calls the respective blocks that were passed to the define_method call. This is clearly no better than just defining methods the old-fashioned way. In fact, it's a little bit worse since we've just added a bunch of extra words and strings and gained nothing for it.
Real Power of define_method
The real power of define_method is realised when we realise we call it like any other code. Here's an example where we call it inside a loop and it achieves the same result as above.
class Programmer
["ruby", "java", "php"].each do |action|
define_method("write_#{action}") do |argument|
"writing #{action.gsub('_', ' ')} on #{argument}"
end
end
end
programmer = Programmer.new
puts programmer.write_ruby("Rails Project")
puts programmer.write_java("Android Project")
puts programmer.write_php("Web Project")
We get the same result:
writing ruby on Rails Project
writing java on Android Project
writing php on Web Project
Isn't that nice? We have code that's generating the code for us. Pretty meta, isn't it?
In fact, we can now extend this by adding any number of elements to that array -- or replacing the array entirely with an external data source. Many Ruby libraries use exactly this technique to define methods dynamically based on, say, database schemas. Rails, for example, will create methods such as User#find_by_first_name.
Dynamic Method Call
One more thing that fascinate me is dynamic method call in Ruby. Suppose you have a the following classes:
class Analyst
def plan
puts "Planning"
end
def design
puts "Designing"
end
end
class Developer
def initialize(analyst)
@analyst = analyst
end
def do(action)
if action == 'plan'
@analyst.plan
elsif action == 'design'
@analyst.design
else
raise NoMethodError.new(action)
end
end
end
developer = Developer.new(Analyst.new)
developer.do("plan")
developer.do("design")
When running the code we get the result:
Planning
Designing
That solution only works as long as you know exactly what methods are going to be called. What if you didn't? What if you did not know all the actions that were possible on the glider object? If only there was a generic way to write the above.
Ruby gives a convenient way for you to call any method on an object by using the send method. send takes, as its first argument, the name of the method that you want to call. This name can either be a symbol or a string.
class Nomad
def initialize(glider)
@glider = glider
end
def do(action)
@glider.send(action)
end
end
Ghost Method
Ghost methods come with pros and cons. The major pro is the ability to write code that responds to methods when you have no way of knowing the names of those methods in advance. The major con is that changing Ruby’s default behaviour like this may cause unexpected bugs if you’re not careful with your method names. With that in mind, let’s go back to our CarModel example and see if we can extend the functionality a little further.
class CarModel
def method_missing(name, *args)
name = name.to_s
super unless name =~ /(_info|_price)=?$/
if name =~ (/=$/)
instance_variable_set("@#{name.chop}", args.first)
else
instance_variable_get("@#{name}")
end
end
end
This example may look a little complex but is really quite simple. First, we take the name argument and convert it from a symbol to a string. Next, we say “send this method up the inheritance chain unless the name ends with _price, _price=, _info or _info=”. If the name ends in an equals sign then we know this is a setter method so we set an instance variable with the same name as our method (minus the =). If there’s no equals sign then we know this is a getter method and so we return the instance variable with the same name.
Now, we don’t have to specify the features each car model has in advance. We can simply get and set values on any _price or _info attribute during runtime:
@car_model = CarModel.new
@car_model.stereo_info = "CD/MP3 Player"
@car_model.stereo_price = "£79.99"
@car_model.stereo_info # => "CD/MP3 Player"
@car_model.stereo_price # => "£79.99"
Conclusion
This tutorial has only scratched the surface of Ruby’s metaprogramming capabilities but hopefully it’s enough to spark your curiosity and will urge you to learn more about metaprogramming
All rights reserved