Friday, February 09, 2007

Ruby: Forwardable addition

Almost every project I work on ends up using the Forwardable module included in the Ruby Standard Library. In fact, I use it so often I thought it was worth putting together an entry about using Forwardable to avoid violating the Law of Demeter.

These days I'm working on a project that aggregates a large amount of data on almost every screen. To increase testability of the application we introduced the Presenter pattern. I've previously written an entry introducing the Presenter pattern and a follow up with an additional Presenter example.

In practice our presenters almost always use Forwardable. For example a summary page could display shipping and billing information.
class SummaryPresenter
def_delegator :shipping_address, :first_name, :shipping_first_name
def_delegator :shipping_address, :last_name, :shipping_last_name
def_delegator :shipping_address, :first_name=, :shipping_first_name=
def_delegator :shipping_address, :last_name=, :shipping_last_name=
...
def_delegator :billing_address, :first_name, :billing_first_name
def_delegator :billing_address, :last_name, :billing_last_name
def_delegator :billing_address, :first_name=, :billing_first_name=
def_delegator :billing_address, :last_name=, :billing_last_name=
...
end
Now, I love forwardable, but it's pretty clear that I can come up with a better solution that will allow me to clean up the def_delegator code.

We decided to create another method, delegate_to, which looks very similar to def_delegators, but also takes a hash that allows you to specify a prefix. After making this change the code can be cleaned up to the following.
class SummaryPresenter
delegate_to :shipping_address, :first_name, :last_name, ..., :prefix => 'shipping'
delegate_to :shipping_address, :first_name=, :last_name=, ..., :prefix => 'shipping'
delegate_to :billing_address, :first_name, :last_name, ..., :prefix => 'billing'
delegate_to :billing_address, :first_name=, :last_name=, ..., :prefix => 'billing'
end
This was a good first step, but there is obviously more we can clean up.

We also added the ability to create writers by simply adding :writer => true to the options hash. Following this change the code is concise but just as readable as the original version, if not more because of the noise reduction.
class SummaryPresenter
delegate_to :shipping_address, :first_name, :last_name, ...,
:prefix => 'shipping', :writer => true
delegate_to :billing_address, :first_name, :last_name, ...,
:prefix => 'billing', :writer => true
end
Below is the full code required to add this functionality.
module ForwardableExtension
def delegate_to(object, *args)
options = args.last.is_a?(Hash) ? args.pop : {}
args.each do |element|
def_delegator object, element, name_with_prefix(element, options).to_sym
if options[:writer]
def_delegator object, :"#{element}=", :"#{name_with_prefix(element, options)}="
end
end
end

protected
def name_with_prefix(element, options)
"#{options[:prefix] + "_" unless options[:prefix].nil? }#{element}"
end
end

Forwardable.send :include, ForwardableExtension
And, of course, the tests.
require File.dirname(__FILE__) + '/../unit_test_helper'

class ForwardableExtensionTest < Test::Unit::TestCase
class Bar
attr_accessor :cat, :dog, :monkey
end

class Foo
extend Forwardable

delegate_to :bar, :cat, :dog, :prefix => 'baz', :writer => true
delegate_to :bar, :monkey

def bar
@bar ||= Bar.new
end
end

test "getter and setter are delegated correctly" do
f = Foo.new
f.baz_cat = "cat"
assert_equal "cat", f.baz_cat

f = Foo.new
f.baz_dog = "dog"
assert_equal "dog", f.baz_dog
end

test "setter is created only when writer is true" do
f = Foo.new
assert_raises NoMethodError do
f.monkey = "raise"
end
end
end

7 comments:

  1. Anonymous11:21 AM

    This seems really nice. The only thing I'm a little uncomfortable with is how much you need to know about the order of the args. I'd probably try to make it work like this:

    delegate_reader(:shipping, :first_name, :last_name).to(:shipping_address)

    Where delegate_reader, delegate_writer, delegate_accessor all do the obvious thing.

    Or something like that.

    WDYT?

    ReplyDelete
  2. Anonymous11:37 AM

    Actually, since you're not always going to want a prefix, perhaps:

    delegate_reader(:first_name, :last_name).with_prefix(:shipping).to(:shipping_address)

    I'll toy w/ this and follow up if I come up w/ something that works.

    ReplyDelete
  3. Anonymous11:45 AM

    David,
    Looks good. I'm a fan of Fluent Interface. We went with that I posted since we were so used to def_delegator and def_delegators that this wasn't much of a leap.

    If I were starting from scratch I'd probably look for something more descriptive like what you are suggesting.

    ReplyDelete
  4. Anonymous12:17 PM

    I don't think this will work in your particular case, but Rails also has the delegate :to method. Usage looks something like:

    class SummaryPresenter
    delegate :first_name :last_name, :zip, :to => :shipping_address


    There's some docs at http://dev.rubyonrails.org/ticket/4133

    Like I said it probably doesn't work for you because of your prefixes...but it's of course very useful, and is mixed into Object I think, so it's available to all classes in the Rails env.

    ReplyDelete
  5. Why do you not go for delegate :to? I discovered your Forwardable post the other day, and almost immediately found another post by somebody who had started using Forwardable after reading your post, but switched to delegate :to. I haven't used either one, so I'm not certain what the tradeoffs are.

    ReplyDelete
  6. Anonymous8:06 PM

    delegate :foo, :to => :bar isn't much different than def_delegator :bar, :foo. Delegate from Rails also isn't documented so I'm weary of using parts of the framework that aren't documented since they may be 'hidden' for a reason.

    Also, I write a lot of code that isn't part of Rails apps so I don't always have the choice.

    If I only wrote rails apps, I'd probably go for delegate . :to .. since it's a bit easier to read.

    ReplyDelete
  7. Based on David's suggestion, I implemented a fluent interface for delegation which includes the extra features Jay added on top of Forwardable.

    http://brynary.com/2007/4/8/fluent-interface-for-ruby-delegation

    ReplyDelete

Note: Only a member of this blog may post a comment.