Ruby Metamagic

As part of developing Hosties, I ended up learning some interesting metaprogramming tricks with Ruby. This is stuff that would make a legit Ruby afficionado scoff at my amazement, but it’s new to me and it’s bad ass, so why not share?

With Hosties, I needed a way to dynamically add methods to objects. This helped me create the DSL feel that the syntax has, and also lets me enforce sanity checking on attribute values where necessary (the big win). The implications of this knowledge lead to all kinds of neat tricks and hackery - endless possibilities for madness!

To play along at home, grab the latest blog-snippets commit, and head over to the RubyMetaMagic directory. In here you’ll find some files that should hopefully make it easy to experiment and play as you’re reading through.

The contents are:

  • Gemfile - This declares the requirements for this project. Use bundle install to grab them
  • Gemfile.lock - Declares the specific versions of the requirements used
  • Rakefile - This defines the different tasks that rake can execute. It defaults to running all of the tests
  • lib/ - Here’s where you’ll be doing your hacking. In the metamagics.rb file you’ll be implementing methods on the Metamagics module.
  • spec/ - I’ve defined three different rounds of tests in here to grant you some feeling of progression

Getting started

Once you’ve grabbed the code, head over to the RubyMetaMagic directory and run rake. You’ll see that a bunch of tests fail. Our job is to fix them! Let’s take this one round at a time.

Round One

In order to only run the specs for round one, use rake round_one. Take a look at the test failures, and then crack open /spec/one_spec.rb.

First, a little aside about the secret-sauce in this project. Inside the metamagics.rb file, I define a class called Meta. This class has the following body:

class Meta
  def metaclass
    class << self
      self
    end
  end
end

So what is this madness? It’s code liberally inspired from various metaprogramming links I trolled up. The metaclass method above is providing access to an instance’s metaclass through a convenient mechanism, and then we can do fun things with it!

The first task is to define read-write attributes dynamically. If you’ve been using Ruby for more than a few days, you know about the :attr_accessor short-cut for adding getters and setters for instance attributes. Using the metaclass method described above, we can already implement the first failing spec, ‘can defined read-write attributes’! Just to be sure we’re dialing the dynamicism up to 11, let’s use Object#send for this:

def self.read_write_attributes attr, *more
  # Our return object
  result = Meta.new
  # For each of the accumulated params...
  (more << attr).each do |entry|
    # fire the attr_accessor method
    result.metaclass.send(:attr_accessor, entry)
  end
  result
end

Splat that implementation into the appropriate place in metamagics.rb and fire rake round_one again. Success! The first spec passes!

The next spec requires us to dynamically define addition methods. For each of the symbols provided as a parameter, we are to return an object with methods of those names that expect two parameters and return their sum. To do this, we’ll use the define_method function. This expects a symbol as a name and a block to handle the logic. For this particular use, we want the provided block to bind two parameters and sum them. This requirement can be satisfied with something like:

def self.addition_method name, *more
  result = Meta.new
  (more << name).each { |entry|
    result.metaclass.send(:define_method, entry) do |x, y|
      x + y
    end
  }
  result
end

Here we send the define_method message to our metaclass, telling it to use the provided name and a block that sums two things. With that, again fire off rake round_one and revel in sweet, sweet success!

Round Two

In round two, we build on the two concepts introduced (defining a method dynamically, and defining attributes dynamically) to create some more complex effects. Take a look at what’s required in /spec/two_spec.rb (or by running rake round_two and scrutizing your failures).

The first requirement of round two is to make a meta object that will shout things back. Specically, when we are provided a list of symbols, those should be created as attributes, but any value we put in should be returned as upcase. For instance, if I specify that :foo should be created, then the following should hold true:

instance.foo = "I am calm"
instance.foo == "I AM CALM"

This spec will allow us to provide our meta objects with some much needed neurotic ticks! The trick to solving this one is to use the two bits we learned above (add attributes, define methods) and keep in mind that accessors in Ruby are zero parameter methods named after the attributes.

Oh, and one more hint, you can grab an instance variable value with instance_variable_get and a string (don’t forget instance variables start with ‘@’!)

I’ll use some brutal on the eyes syntax highlighting here to protect against spoilers.

Solution:

def self.make_angry_object name, *more
  result = Meta.new
  # Your implementation here
  (more << name).each { |entry|
    result.metaclass.send(:attr_accessor, entry)
    result.metaclass.send(:define_method, entry) do
      instance_variable_get("@#{entry}").upcase 
    end 
  }   
  result
end

Once you’ve knocked out this requirement, let’s look at the second spec of part two. This requirement says that we should be able to specify an attribute name and a list of acceptable values, then the returned object should raise an ArgumentError if we try assigning a verbotten value to the new attribute. This is very similar to the problem above, only for the setter (which is defined as attribute_name= in Ruby). There are a couple of tricks to this one too (well it wouldn’t be very fun if all these specs asked for the same thing!). First, know that you can set an attribute variable in much the same way as we retrieved it above, with the instance_variable_set method. The other gotcha is with defining the method name… Hint: Strings can be transmografied into symbols using the to_sym method.

Solution:

def self.make_snobby_object attribute_name, acceptable_values 
  result = Meta.new
  result.metaclass.send(:attr_accessor, attribute_name)
  result.metaclass.send(:define_method, "#{attribute_name}=".to_sym) do |val|
    raise ArgumentError, "Bad value!" unless acceptable_values.include? val 
    instance_variable_set("@#{attribute_name}", val)
  end 
  result
end 

With this requirement met, you’re two-thirds of the way through this hell-hole someone tricked you into!

Round Three

We’re on the home stretch here! Take a look at what the spec file requires in /spec/three_spec.rb, or run rake round_three and take a look at the failures.

The first piece is a bit of a rehashing of the things learned above. Given a hash, turn it into an object. For instance, if we were given a hash of { :foo => "bar" }, then the returned object should hold for obj.foo == "bar". Don’t worry about making mutators here, we’re purely concerned with reading back the data from the hash in a more friendly manner. If you’re concise about it, this solution can be really small.

Solution:

def self.transform_hash hash
  result = Meta.new
  # Your implementation here
  hash.each do |k,v|
    result.metaclass.send(:define_method, k) do v end 
  end 
  result
end 

For the final test, the culmination of all that you’ve learned over these last 193 lines of text - as counted from the source ;-) - we’ll shoe-horn in just the tiniest bit of DSL type language abuse. To accomplish this, I’ve been very friendly and left in a bit of the scaffolding required for this. The important thing to note is that instance_eval lets you evaluate a block in the context of an instance - meaning the scope of the code in the block is the same as though it were written in that instance’s class declaration.

With that in mind, read the attribute list and create single argument setters - drop the equals sign in the name! Take a look at the spec, I’m not using flavor = :poor, as using an equals sign would be too much like code for our poor Lame DSL users!

Solution:

def self.lame_dsl attr_names, attr_values, &block
  result = Meta.new
  # Your meta magic here!
  attr_names.each do |name|
    result.metaclass.send(:attr_accessor, name)
    result.metaclass.send(:define_method, name) do |val|
      raise ArgumentError, 'Bad val!' unless attr_values.include? val
    end
  end
  # Ok.. you get a little help for this part
  begin
    result.instance_eval &block
  rescue ArgumentError # You are raising ArgumentError above, right?
    result = nil # NO OBJECT FOR YOU!
  end
  result
end

That’s it! You can now congratulate yourself on being a Lvl 1 Ruby Mage. Wear your robe and wizard hat with pride!