~6 minutes

ActiveSupport: Delegate for Demeter

This next post is going to be about one macro only…and it is a very special one if you like to obey the Law of Demeter. It has 130 lines of comments explaining it’s usage before it’s finally declared. It is, the one and only, Module#delegate.

You might wonder why this is on Module instead of Class. Well, Class inherits from Module, so it will get all the constants and methods declared there. Matt Aimonetti has a good write up on the distinction between the two, culminating in, “whenever you don’t create instances of a class, please don’t use a class.” Since delegate is really just method passing, it doesn’t need an instance so Module scope it is.

Module#delegate usage

By way of reminder, delegate is used like this (from the 130 lines of comments):

class Greeter < ActiveRecord::Base
  def hello
    'hello'
  end

  def goodbye
    'goodbye'
  end
end

class Foo < ActiveRecord::Base
  belongs_to :greeter
  delegate :hello, to: :greeter
end

Foo.new.hello   # => "hello"
Foo.new.goodbye # => NoMethodError: undefined method `goodbye' for #<Foo:0x1af30c>

It allows you to abide by the Law of Demeter by only knowing about the interface to the second object rather than the implementation details in your collaborating object. Seeing code like Foo.new.greeter.hello with all those dot dot dot dots is a good indication that Demeter is going to get pissed off (I like to anthropomorphize Demeter).

Implementing Module#delegate

Take a deep breath. Hold it…let it out. Find your happy place. Ok, now you’re ready to tackle this magical beast.

We begin with the ever present options hash parsing and validation:

class Module
  def delegate(*methods)
    options = methods.pop
    unless options.is_a?(Hash) && to = options[:to]
      raise ArgumentError, 'Delegation needs a target. Supply an options hash with a :to key as the last argument (e.g. delegate :hello, to: :greeter).'
    end

    prefix, allow_nil = options.values_at(:prefix, :allow_nil)

Oh, I should mention, there are a ton of ways to configure the delegate macro. In case you want to modify the names of the methods created you can specify a “prefix” as per below:

    if prefix == true && to =~ /^[^a-z_]/
      raise ArgumentError, 'Can only automatically set the delegation prefix when delegating to a method.'
    end

    method_prefix = \
      if prefix
        "#{prefix == true ? to : prefix}_"
      else
        ''
      end

That method_prefix is an interesting piece of syntax. It could have been written as a ternary but using the line continuation and indentation I could see the reasoning that it perhaps is a bit more readable.

    file, line = caller.first.split(':', 2)
    line = line.to_i

    to = to.to_s
    to = 'self.class' if to == 'class'

The above just sets up some variables that we will need below, specifically where we are pointing to and where “we” are located for error reporting (as we’ve seen before with class_eval, we have module_eval below).

Version 1: Nil Result is OK

There are two conditions in the code…whether we want to raise an exception on a nil or just roll with it. The easier case is also the first one, so we see it first below:

    methods.each do |method|
      # Attribute writer methods only accept one argument. Makes sure []=
      # methods still accept two arguments.
      definition = (method =~ /[^\]]=$/) ? 'arg' : '*args, &block'

      # The following generated methods call the target exactly once, storing
      # the returned value in a dummy variable.
      #
      # Reason is twofold: On one hand doing less calls is in general better.
      # On the other hand it could be that the target has side-effects,
      # whereas conceptualy, from the user point of view, the delegator should
      # be doing one call.
      if allow_nil
        module_eval(<<-EOS, file, line - 3)
          def #{method_prefix}#{method}(#{definition})        # def customer_name(*args, &block)
            _ = #{to}                                         #   _ = client
            if !_.nil? || nil.respond_to?(:#{method})         #   if !_.nil? || nil.respond_to?(:name)
              _.#{method}(#{definition})                      #     _.name(*args, &block)
            end                                               #   end
          end                                                 # end
        EOS

Ok. That’s some crazy stuff right there but if you look at the example on the right it totally makes sense. We are using module_eval to create a new method from the EOS here-doc that we provide. Since a here document is just a big string, we get to shove in all sorts of interpolation. From above, we specified the optional prefix which the example shows as customer_, the method of name and then the definition/signature of that method comes out of the regular expression ternary. That regular expression is just checking whether our method is an attribute writer which should just have one arg. It’s unclear to me why _ = #{to} is necessary (as opposed to throwing more interpolations on the next two lines), but it could be for readability as well.

A few lines of meta and now we have a nice way to avoid Law of Demeter violations. Use it!

Version 2: Freak out on Nil

There is a second code path for #delegate, which you can see below. It’s the version which freaks out when it encounters a nil on the collaborator’s method. You can see the extra logic below.

      else
        exception = %(raise "#{self}##{method_prefix}#{method} delegated to #{to}.#{method}, but #{to} is nil: \#{self.inspect}")

        module_eval(<<-EOS, file, line - 2)
          def #{method_prefix}#{method}(#{definition})        # def customer_name(*args, &block)
            _ = #{to}                                         #   _ = client
            _.#{method}(#{definition})                        #   _.name(*args, &block)
          rescue NoMethodError                                # rescue NoMethodError
            if _.nil?                                         #   if _.nil?
              #{exception}                                    #     # add helpful message to the exception
            else                                              #   else
              raise                                           #     raise
            end                                               #   end
          end                                                 # end
        EOS
      end

It is really easy to understand now that we’ve broken down the first one.

That’s all for now, have fun reading the source!

Tagged with: programming

My first novel is coming soon-ish!

Check out Singular