There’s a common pattern in Ruby for setting up keyword parameters—optional parameters with default values that are accepted as a hash at the end of the method call. Ruby’s syntax facilitates this, with end-hashes not requiring curly braces; instead, you can do this:

obj.method :keyword => 'value', :keyword2 => 3

Many people have written about various ways of implementing this in as painless a syntax as possible, but they all involve writing a lot of boilerplate. My intent is to write code that doesn’t require such boilerplate at all, instead providing a declarative way to say `here are the optional parameters, here are their default values’ and have everything taken care of for you. Moreover, the ability to provide introspection into what optional parameters are expected at runtime and the default values thereof is also something I want to try and give.

Specing and Thought Process

Just to clarify, as I write this, I will be BDDing it up, so RSpec code will come first. Moreover, I’m going to write this post in a stream-of-consciousnessesque fashion. This is just something I try to do so that my thought process in getting from point A (here’s an idea!) to point B (here’s how I’ll do it!) to point C (well that’s just dumb, let’s do it this way instead…) to point D (here’s a completed bit of code!) is exposed. It’s an interesting exercise for me, and I hope it’ll be interesting for you as a reader to see this process.

Also, this idea is fairly complete in my mind, and, though its usefulness may be questionable (it’ll induce some interesting overhead), I think it’s another interesting investigation into what you can achieve with Ruby, much like my earlier implementation of the `with’ keyword.

Syntactic Sketch

What we’re trying to get to is a way of declaring optional parameters that is almost as good as declaring them in line. We can’t declare them in line, of course, because that isn’t supported by the syntax. The ideal syntax would be something vaguely like this:

def my_method(arg1, arg2, :arg3 => 5, :arg4 => 'test')

Since we can’t do this directly, we’ll change things up a bit. Let’s envision a situation where we do this instead:

optional(:arg3 => 5, :arg4 => 'test', :arg5 => 'magic')
def my_method(arg1, arg2)
  # ...
end

Some people will likely be uncomfortable given the lack of indication as to where the `optional’ method stops working (if I declare another method after that, does it still apply?). The idea would be that it would only apply for the next method definition; however, let’s provide an alternate syntax to clarify that:

with_optional(:arg3 => 5, :arg4 => 'test', :arg5 => 'magic') do
  def my_method(arg1, arg2)
    # ...
  end
end

Naturally, a set of optional parameters can be shared with several methods with this syntax:

with_optional(:arg3 => 5, :arg4 => 'test', :arg5 => 'magic') do
  def my_method(arg1, arg2)
    # ...
  end

  def my_other_method(arg6, arg7)
    # ...
  end
end

Ok, so now we have a sketch of what we want our syntax to look like. Let’s make it happen!

A Little Bit of Planning

First of all, let’s think about how things will fit together. Clearly, to do this, we’ll need to hook into method creation. Fortunately, Ruby provides the Module#method_added method. This method gets called when a method is added to a module (or a class). It receives, as a parameter, the name of the new method that was defined.

So we can hook into when the method is created; what we can’t do is modify the code in the actual method at runtime (well, we probably could with ParseTree, but let’s not go there). Instead, we’re going to wrap the new method with our own method that will sit in between and intercept calls and take care of optional parameters automagically.

Additionally, we’re going to need at least two methods: optional and with_optional. In fact, we can make these the same method (by checking block_given? or whether a parameter &block is provided, we can just specialize within the method itself). We’ll also need some helpers—one to define the wrapper method, and one or two to set when we’re monitoring added methods and when we aren’t (i.e., we’d enable monitoring when we need to add optional parameters to whatever method(s) is/are defined next, and we’d disable it once we’re done with adding optional parameters).

The details of this breakup will become clearer as we create the actual code. The last thing we’re missing is how to provide the optional parameters to the method once we’re done. In a perfect world, we could maybe inject a local variable into the method at runtime and be happy, but we can’t really do that. We could also use some external instance or global variable, but that’s both dirty and not thread-safe in any way, shape or form. Instead, we’ll go for the still-intrusive but mildly less dirty approach of making the last parameter to the declared method be some sort of placeholder for the options. This modifies our syntax into:

optional(:arg3 => 5, :arg4 => 'test', :arg5 => 'magic')
def my_method(arg1, arg2, options)
  # ...
end

Initial Specs and Code

Before writing any code, we’ll need to come up with some appropriate specs that will detail how we want the code to work. For each behavior (group of specs), we will create a new testing class to use.

Let’s start with the basic version—declaring a set of optional parameters for the next method.

require 'optional_params'

describe OptionalParams, 'when declaring parameters for the next method' do
  before(:all) do
    class TestClassA
      include OptionalParams # include OptionalParams functionality

      optional :birth => Time.now
      def create_person(name, options)
        options
      end
    end
  end

  before(:each) do
    @test = TestClassA.new
  end

  it 'should pass the optional parameters in' do
    @test.create_person('dude', :birth => 'w00t').should == { :birth => 'w00t' }
  end

  it 'should fill in default values when needed' do
    @test.create_person('dude')[:birth].should be_kind_of(Time)
  end
end

This spec will fail initially since the optional_params.rb file doesn’t exist, then because the OptionalParams module doesn’t exist, and then because there’s still no #optional method. Let’s knock these three out in one shot:

module OptionalParams
  def optional(params = {})
  end
end

The next error won’t actually be with the first example—the first example actually runs fine, since options just takes the value of the hash that’s passed in, the way it usually would. The next problem happens in the second example, where we have no default value specified. So it’s time to actually create some code that’ll do what we want it to. Let’s start with the basics:

module OptionalParams
  module ClassMethods
    def optional(params = {})
      @next_optional_params = params
    end

    def optional_params
      @optional_params ||= {}
    end
  end

  def self.included(base)
    singleton = class <<base; self; end
    singleton.class_eval do
      def method_added(meth_name)
        return unless @next_optional_params # return if there's nothing to do

        old_name = "#{meth_name}_without_params" 
        new_name = "#{meth_name}_with_params" 

        # Store the parameters permanently, then clear it out so that we don't
        # do an infinite method_added loop.
        optional_params[meth_name] = @next_optional_params
        @next_optional_params = nil

        # Create the new method, doing whatever optional parameter housekeeping
        # needs to be done.
        define_method(new_name) do |*args|
          opt_params = (Hash === args.last) ? args.pop : {}

          opt_params = self.class.optional_params[meth_name].merge(opt_params)

          self.send old_name, *(args + [opt_params])
        end

        # alias_method_chain -- replace the just-created method, wrapping it
        # with our own optional parameter handling code.
        alias_method old_name, meth_name
        alias_method meth_name, new_name
      end

      include ClassMethods
    end
  end
end

Okay, so let’s look at what’s going on here. First off, we see that the task of the optional method is pretty minimal—it just sets up the next optional parameters instance variable for method_added to use when it’s wrapping a method with optional parameter handling code1. There’s also another auxiliary method, optional_params, which provides an accessor to an instance variable. This instance variable basically keeps track of the optional parameter lists for various method names2.

The real work is done by method_added. method_added starts off by just returning if it has nothing to do (i.e., if there is no next optional parameter that it needs to set up). Otherwise, it sets up the new names for methods—we follow the Rails alias_method_chain convention of renaming the original method with an _without_feature and creating a new method with a _with_feature appended to the end that gets renamed to the original method name.

Once we’ve got the method names, we pull out the next optional parameters and record them permanently (remember, we didn’t know the method name until method_added was called!), then we clear out the next params variable. Note that if we don’t do this, when we define our new method, we’ll try to set up optional params for it, and we’ll go into an infinite loop of method definition-method wrapping.

Then, we go ahead and define the new method. It follows a relatively common pattern of checking if the last parameter is already a hash, and, if it is, using it instead of creating our own. We then merge in our values, and finally we call the original method. We put the hash back into the list of parameters and splat these (effectively splitting the array into separate parameters instead of one array parameter). Finally, we rename the two methods so that everything’s set up the way it should be and our new method wraps the old one.

Here, by the way, the first example comes in useful—had we merged the wrong way (i.e., if we’d merged the passed parameters with the optional parameters rather than the other way around), that example would have failed.

Adding Blocks

Here’re some specs:

describe OptionalParams, 'when declaring optional parameters for multiple methods' do
  before(:all) do
    class TestClassB
      include OptionalParams # include OptionalParams functionality

      with_optional(:birth => Time.now, :weight => 16.5) do # in pounds
        def create_person(name, options)
          options
        end

        def create_baby(name, options)
          options
        end
      end
    end
  end

  before(:each) do
    @test = TestClassB.new
  end

  it 'should fill in the optional parameters for all methods' do
    results = @test.create_person('w00t')
    other_results = @test.create_baby('other_w00t')

    results[:birth].should be_kind_of(other_results[:birth].class)
    results[:weight].should == other_results[:weight]
  end
end

Adding the block version is a little more involved. We still need to clear out the next optional parameters variable in that case, so that we don’t get into the aforementioned infinite loop. However, we also need to keep it around so that if multiple method definitions are done, we can still use the same parameter list. So, we’ll go ahead and fill in some code. First, we’ll add the with_optional method, and then we’ll update method_added to work in a more friendly way.

  def with_optional(params = {}, &block)
    # Store the parameters and raise the keep_params flag, which tells
    # +method_added+ to keep this variable around.
    @next_optional_params = params
    @keep_params = true

    self.class_eval &block

    # Clear the parameters and the keep_params flag, effectively ending the
    # block of methods that will take these parameters.
    @next_optional_params = nil
    @keep_params = false
  end

  # ...
      def method_added(meth_name)
        # ...
        @next_optional_params = optional_params[meth_name] if @keep_params
      end
  # ...
end

Here, we create the with_optional method, which sets up the next params variable appropriately and also raises the keep_params flag. This flag tells method_added that it should keep the parameters around after it runs once. To do this, we add one last line to method_added, which restores next_optional_params if the keep_params flag is raised. Once with_optional’s block is done running, we go ahead and clear both the next params variable and the keep params flag.

Metadata

This gives us the basic structure for the functionality. All that’s left is adding one more bit of useful functionality: metadata. Right now, we declare the optional parameters and such, but the real win of our approach is the ability to get back information about what optional parameters are expected and what their default values are. Thus, we can modify the method method to give us back, inside our Method object, data about the optional parameters. We can also complete the wrapping of the original method by returning a Method object with the original method’s characteristics (specifically, we can use the original method’s arity, rather than the new method, which will always have an arity of -1 (since it uses *args).

First, some specs; under ‘when declaring optional parameters for the next method’:

  it "should keep the original Method object's arity" do
    @test.method(:create_person).arity.should == 2
  end

  it "should provide access to the optional parameters through the Method object" do
    opt_params = @test.method(:create_person).optional_params

    opt_params.keys.length.should == 1
    opt_params[:birth].should be_kind_of(Time)
  end

To achieve this, we’ll have to modify the Method object itself. To achieve this wrapping, we have to store the metadata, which we can do in method_added, and then we have to wrap the method method to return a modified object. In the self.included method, after we do the singleton’s class_eval, we drop this in:

      base.instance_eval do
      define_method(:method_with_metadata) do |meth_name|
        if self.class.optional_params[meth_name]
          params = self.class.optional_params[meth_name]
          initial = method_without_metadata(meth_name)
          orig = method_without_metadata("#{meth_name}_without_params")

          singleton = class <<initial; self; end
          singleton.class_eval do
            define_method(:optional_params) do
              params
            end

            define_method(:arity) do
              orig.arity
            end
          end

          initial
        else
          method_without_metadata(meth_name)
        end
      end

      alias_method :method_without_metadata, :method
      alias_method :method, :method_with_metadata
    end

First, we define the method_with_metadata method. Inside it, we check whether there are optional parameters for the named method (recall that the singleton class stores the list of optional parameters). Then we grab the initial Method object for the specified method, modify it so it returns the regular, un-wrapped method’s arity and the optional parameters. The extensive use of define_method and blocks lets us share local variables like orig and params. If there are no optional parameters, we just pass through directly to the regular method method. Then, we alias the methods appropriately.

This makes the specs for optional pass; let’s add some for with_optional and make sure they pass, too:

  it "should keep the original Method objects' arity" do
    @test.method(:create_person).arity.should == 2
    @test.method(:create_baby).arity.should == 2
  end

  it "should provide access to the optional parameters through the Method objects" do
    opt_params = @test.method(:create_person).optional_params

    opt_params.keys.length.should == 2
    opt_params[:birth].should be_kind_of(Time)
    opt_params[:weight].should == 16.5

    opt_params = @test.method(:create_baby).optional_params

    opt_params.keys.length.should == 2
    opt_params[:birth].should be_kind_of(Time)
    opt_params[:weight].should == 16.5
  end

And there we go. These specs pass, as well.

You can find the two Ruby files here: optional_params_spec.rb, optional_params.rb .

Parting Remarks

Again, this is a bit of a toy. The performance degradation from wrapping methods and such might outweigh the benefits of the cleaner syntax. However, having this to encapsulate the common optional parameter idiom is, I think, pretty cool, and again showcases the power of Ruby’s metaprogramming.

1 Clever people will point out that this technique will also not be particularly threadsafe. I’m thinking the best way to get around that is to store the next optional parameter list per-thread, using a hash indexed by the thread object.

2 As a side note, this means that technically we could use this technique to wrap methods in optional parameter handlers after the fact, as well.

Leave a Reply