Metaprogramming Ruby
I just finished the book Metaprogramming Ruby and I’ve stepped away with a much deeper appreciation for some attributes of the Ruby language. I’m a fan of Clojure, so I was pleased to discover that Ruby’s object model has a dynamism that begs comparison to the data-is-code philosophy of more lispy languages. For more on this (although I don’t agree with all the article’s conclusions), read Why Ruby is an Acceptable Lisp.
I’ve written some code to demonstrate a few of Ruby’s interesting features. In particular, consider the class FStore:
class FStore attr_accessor :dynamic_methods def initialize dynamic_methods = [] end def mix(s1, s2, s3, &block) name = "#{s1.to_s}_#{s2.to_s}_#{s3.to_s}".to_sym self.send(name) {|a,b| send(s1, send(s2,a), send(s3,b))} end def method_missing(symbol, &block) if block_given? self.class.send :define_method, symbol, &block else bdy = proc {|*x| symbol} self.class.send :define_method, symbol, bdy end dynamic_methods << symbol symbol end end
FStore is a very small DST (Domain Specific Language), and with it I can declare methods in a new way:
# New object e = FStore.new # Define a bunch of methods dynamically e.double {|x| 2*x} e.triple {|x| 3*x} e.square {|x| x**2} e.plus {|x,y| x + y} # Then you can use them, like so: e.double 2 #=> 4 e.triple 2 #=> 6
To understand how this works, look to method_missing, as it does all the heavy lifting:
def method_missing(symbol, &block) if block_given? self.class.send :define_method, symbol, &block else bdy = proc {|*x| symbol} self.class.send :define_method, symbol, bdy end @dynamic_methods << symbol symbol end
With that, here’s the basic idea:
- The ruby interpreter attempts to evaluate a statement (e.g. “e.double {|x| 2*x}”)
- On method lookup, no double method is present for e or its ancestors, so the interpreter passes double (as a symbol) and its block to method_missing.
- Normally, method_missing would return an error, but we’ve overridden it for the FStore class. Instead, it creates a new instance method named double. The body of this method is the same block that we initially passed e.double.
So to construct methods dynamically you can change the inherited behavior of method_missing. But what’s the deal with send? In method_missing, we use it to declare a new instance method:
# This is what we did self.class.send :define_method, symbol, &block # This, although seemingly equivalent, would not work # because define_method is private define_method symbol, &block # Another example of using send [1,2,3].first # is equivalent to [1,2,3].send(:first)
This isn’t complicated. The send method provides another way to call a method on an object. Why then did we use it instead of calling define_method directly? Well, with send we can call an object’s private methods.
Now, let’s move on to some other interesting abilities of FStore:
# Mix a few methods e.mix(:plus, :double, :triple) # => create: def plus_double_triple a,b ; plus (double a) (triple b) ; end # We can call the new method e.plus_double_triple 2, 3 #=> (plus (double 2) (triple 3)) => 13
Here, we call mix to generate a new instance method (prefix style) out of e.plus, e.double, and e.triple. Mix will call this plus_double_triple, and we can use it just like any other method. Let’s look back to the definition of mix.
def mix(s1, s2, s3, &block) name = "#{s1.to_s}_#{s2.to_s}_#{s3.to_s}".to_sym self.send(name) {|a,b| send(s1, send(s2,a), send(s3,b))} end
Mix creates a new name out of the three method names that we pass it, and defines a new method with that name using a hierarchy of sends. The method definition itself is delegated once to method_missing, as the outermost send passes a block to plus_double_triple, which doesn’t initially exist for our FStore instance.
A complete gist (with all the referenced code) is available here.