+5

Introduction to Ruby Metaprogramming

ruby-logo.png 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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí