Search This Blog

Tuesday, March 15, 2011

Named Arguments in Ruby are All Wet and How to Keep DRY

One of the more interesting features in Ruby 1.9.n and up is the new syntax for passing hashes as arguments. You can still do it the old way, which is clearly a hash, but there's a new syntax that pretends to be a series of named parameters. This gives you the flexibility of not being tied down to positional parameters and allows a great deal of leeway in setting default values, not to mention improved readability of complex method calls. So, you can have calls that look like this:

foo(anything: 22,  something: "it")
foo(nothing: true, anything: 0)
foo(everything: 'almost', something: 'unknown', anything: 0)

Which is awesome on the calling side! However, in every example I've ever seen, foo ends up looking a bit ugly in order to support this syntactical sugar.

def foo(arguments = {})
    something = arguments[:something] || 'some'
    everything = arguments[:everything] || 'all'
    nothing = arguments[:nothing] || false
    anything = arguments[:anything] || 100
    # do something with the passed arguments

    puts arg.anything

end # def foo   

What's wrong with this? There are several bad points here.
  • It violates DRY (Don't Repeat Yourself), and it does it twice! Every line references the arguments array, and every line defines the argument name twice - once as the key into the argument array, and once as a variable to hold the argument value. If you have a large number of possible arguments, the boilerplate becomes tedious - which means error prone.
  • It has no checking of the passed variables. If the caller passes an invalid argument, the method will silently ignore it. If this is a minor misspelling of a valid argument, this could lead to a very difficult bug to catch.
  • The defaults are not easy to see. They're at the end of a series of operators, and they're not lined up. They could be lined up, of course, but they'll end up in the far right of the screen - it's a pretty long line of code.
  • It handles defaults poorly - more on that later.

My solution is a helper method that collects the named arguments into a single structure, does some basic validity checking, and sends the whole kit and kaboodle back to the caller as an anonymous Struct.

def collect_named_args(given, expected)
    # collect any given arguments that were unexpected
    bad = given.keys - expected.keys

    # if we have any expected arguments, raise an exception.
    # Example error string: "unknown arguments sonething, anyhting"
    raise ArgumentError,
        "unknown argument#{bad.count > 1 ? 's' : ''}: #{bad.join(', ')}",
        caller unless bad.empty?
        * { |arg, default_value| given[arg] || default_value }
end # def collect_named_args

How does that work in our new foo? Pretty nice, actually.

def foo(arguments = {})
    arg = collect_named_args(arguments,
        something:  'nothing',
        everything: 'almost',
        nothing:     false,
        anything:    75)

    # Do something with the arguments 

    puts arg.anything
end # def foo

Much cleaner! It has one more line of code, but the lack of redundancy makes up for that. In fact, it's so much less redundant, we can afford to put in some overhead and fix another problem of the original foo.

Let's look at this line:

    nothing = arguments[:nothing] || false

"false" is a bad choice for nothing's default, don't you think? The default for nothing is nil, not the opposite of true. Let's fix that:

    nothing = arguments[:nothing] || nil

Cool! I can now call foo(nothing: 'something'), or just foo() and I'll get 'something' or nil respectively - just like I want! But there's a trap for the unwary here. What if I call:

    foo(nothing: false)

Uh-oh.  I want nothing to be false, but I end up with it being set to nil. That's probably not working as intended. Both false and nil trigger the or (||) side of the assignment which results in the default being assigned, but we only want nil to do so.

This could be even uglier! Look!

        something = arguments[:something] || true
    foo(:something: false)

This results in the variable something being true! We're getting the exact opposite of what we were asking for!

It's easy to fix, of course:

    nothing = arguments.has_key?(:nothing) ? arguments[:nothing] : nil
    something = arguments.has_key?(:something) ? arguments[:something] : true

Now only nil (which is returned by the arguments hash when the key is not found) will trigger the assignment of the default. If we want the default to be nil, that works, and if we pass nil, we get nil back! Booleans return what is passed, and their defaults when nothing is passed. It just works!

It should be our new boilerplate. But look at that statement! "arguments" appears twice, "nothing" appears (in some form or another) three times! We've got an assignment operator and a ternary operator all going at once. We need the method call has_key? for every assignment. What a mess! Let's rewrite the pertinent line of collect_named_arguments.*expected.keys).new(
        * { |arg, default_value| 
            given.has_key?(arg) ? given[arg] : default_value

It's still a mess, but it's a mess that you write once, and it's hidden from view forever after.

This paradigm could easily be expanded to include argument validation with the use of lambdas, but that's beyond the scope of this discussion. As an exercise, try writing a validate_named_args function that works on the same principle. For simplicity, you can use the results of collect_named_args as your data source.

While playing with this, I found that it was fairly easy to create method helper functions that allowed for nesting arguments like so:

def window...

def box(arguments = {})
        top:    0,
        left:   0,
        height: 1,
        width:  1)
end # def box

    layer: :top,
    coordinates: box(
            height: 480,
            width: 640))

I'm curious as to what other interesting applications can be derived from this. Is there an even neater way to wrap up this package? It should be possible to do this in a more object-oriented way, certainly. Thoughts?

No comments:

Post a Comment