Wednesday, September 09, 2009

Fun With Metaprogramming: hash_initializer()

Earlier this week I was doing a code review for a candidate for ThoughtWorks. One of the bits I ran across was an object constructor that looked something like this:

class MyClass
def initialize(name, price, is_cool, is_neato, is_rad)
@name = name
# et cetera

Not so bad, really, when you're looking right at the signature for the constructor. But when it's called from another file..."bob", "1.00", true, false, true)"fred", "2.00", false, true, true)
# et cetera

... the purpose of the first two parameters may be easy to infer, but the last three? Which order were those descriptors in anyway? To really be sure, one has to keep referring to the class definition. In my review, I opined that perhaps the constructor should take a single argument -- a hash with keys named after the objects instance variables and their corresponding values. Thusly:

class MyClass
def initialize(args)
args ||= {} # in case MyClass#new is called without any args
@name = args[:name] || ""
# et cetera

This frees things up a bit, and allows for parameters to be specified in any order, with reasonable defaults for any that might be left out. It also makes calls to the constructor a little more self-documenting.

But what would really be nice would be not having to write out this boilerplate code for everything that we want this kind of constructor for.

Enter metaprogramming, in the form of hash_initializer -- a nifty little class extension I found in Brian Guthrie's Awsymandias library (as used at my current ThoughtWorks gig). Behold:

1. module ClassExtension
3. if !Class.respond_to?("hash_initializer")
4. def hash_initializer(*attribute_names, &block)
5. define_method(:initialize) do |*args|
6. data = args.first || {}
7. data.symbolize_keys!
8. attribute_names.each do |attribute_name|
9. instance_variable_set "@#{attribute_name}", data[attribute_name]
10. end
11. instance_eval &block if block
12. end
13. end
14. end
16. end
18. Class.send :include, ClassExtension

Lines 5 through 12 correspond pretty much to my second version of MyClass#initialize above. Line 6 does the same defensive guard against a nil first parameter. Lines 8 through 10 pull each attribute, create an instance variable with the same name, and set it to the desired value. The extra bits? Line 7 does something I should have done in my "improved" version -- it forces the keys of the params has to be symbols, avoiding the awkwardness of blowing up when a key is a String when it should be a symbol or vice versa. Line 11 allows for the execution of a block along with initialization, if you're into that sort of thing. And the rest just opens up the class Class and makes Class#hash_initializer available to all objects.

With Class thus extended, the boilerplate for MyClass becomes simply:

class MyClass
hash_initializer :name, :price, :is_cool, :is_neato, :is_rad

And instantiating a MyClass stays what you would expect: :name => "bob", :price => "1.00" # et cetera.

I like it.

irb(main):001:0> Class#hash_initializer.is_neato?
=> true


Squirk said...

This is something that I miss from Python. Keyword arguments. They're essentially option hashes, but built in to the language.

You use them much the same way as your example:

bob = MyClass("bob", price="1.00", is_cool=False)
fred = MyClass("fred")

my_options = {
'name': "annie",
'is_cool': True
annie = MyClass(**my_options)

The easy-to-read definition:

. class MyClass:
. . def __init__(self,name="", price=None, is_cool=True):
. . . = name
. . . self.price = price
. . . self.is_cool = is_cool

(You can get a shorter definition, too, at the expense of readability)

Matt R said...

Is there an easy way to extend this if you want to do something other than simply assign the constructor argument to the corresponding field?

David Rupp said...

@Matt: All things are possible with metaprogramming. Or perhaps I should state that as: If you can program it, you can metaprogram it. "Easy", though, is a relative term.

Another consideration is a simple cost-benefit analysis: is the conceptual complexity introduced by metaprogramming worth what you gain from it? I think this use case is pretty straightforward and does have benefits that outweigh the costs.

What is it you'd like to extend this example to do? It may be possible to do what you want, but it may not be desirable, depending on how complex the metaprogramming gets.

Aman King said...

Hi David, it seems like this is a common task that Ruby devs try to solve...

My take on it:

For my usage (in a couple of internal ThoughtWorks projects), I even wanted the equality to be based on the fields, so I've included that too.

David Rupp said...

@Aman: Nice. Thanks for the link to your project. It's interesting to observe the differences between the two, and perhaps a good answer to Matt's question about how to use this kind of metaprogramming to do more work than hash_initializer does.

I think on balance I prefer the hash_initializer approach of extending Class, which makes hash_initializer available to all classes without having to explicitly include a module. I also like that it solves a very specific need -- initialization -- without imposing itself on other concerns like equality and hashing.

Even the extra little bit of work of declaring an attr_reader for every field can quickly get sticky. What if you want some fields to be attr_accessors? Or not accessible at all? If you're willing to abide by AttributeDriven's conventions, no problem. But you have to be willing to abide by all of them; you can't pick and choose.

Aman King said...

@David: I think you've made a very relevant observation. The more that a library does implicitly, the less cross-cutting it becomes. Your example does just initialization and hence can be usable in most classes; mine does that but also imposes field equality and reader methods, and hence is more specific in purpose.

AttributeDriven came out of a need to make DTO or View Objects quickly, basically classes whose purpose is mostly driven by its attributes rather than behavior. And like you pointed out, such classes shouldn't become the norm in a project (they are for specific purposes, like for rendering data in a view, or results of XML parsing, etc). I'll probably update my project description to put that warning in. ;-)