Several solutions for calling a method only once per instance

written by paul on July 1st, 2007 @ 09:38 PM

One of the things that I’m still getting used to in ruby is that there are many different ways to accomplish the same task.

The problem: We want our class to call an expensive method only once per instance.

Solution 1: ||= operator

We use an instance variable along with the ||= operator:


class Product
  def name
    @name ||= ExternalService.expensive_call_for_name(@id)
  end
end

Now, we can do


p = Product.new
p.name
p.name

The ExternalService.expensive_call_for_name is called only once, even though we call name twice.

There is a subtle bug in this solution, however. If the expensive_call_for_name returns nil, then the expensive call will be made again due to the way ||= works. This is not the desired behavior. We want to cache the value of name, even if it is nil.

Solution 2: once method

We use a once method similar to date.rb:


class Product

  def once(*ids) # :nodoc:
    for id in ids
      module_eval <<-"end;" 
        alias_method :__#{id.to_i}__, :#{id.to_s}
        private :__#{id.to_i}__
        def #{id.to_s}(*args, &block)
          (@__#{id.to_i}__ ||= [__#{id.to_i}__(*args, &block)])[0]
        end
      end;
    end
  end

  def name
    ExternalService.expensive_call_for_name(@id)
  end
  once :name
end

This solution does not have the nil limitation of solution 1. However, it is a bit heavy weight for one time problems. If there are multiple methods that should only be called once, then I would recommend this approach. If not, I believe there are simpler solutions.

Solution 3: instance_variables check

We can modify solution 1 so it works in the nil case. We will remove the use of ||= and replace it with an instance variable check:


class Product
  def name
    if !self.instance_variables.include? "@name" 
      @name = ExternalService.expensive_call_for_name(@id)
    end
    @name
  end
end

This solution works as well, but it feels a bit hackish to me. I don’t love the idea of searching the instance variables to see if something is defined. Let’s try one more solution.

Solution 4: metaprogramming

We will use a bit of metaprogramming:


class Product
  def name
    expensive_name = ExternalService.expensive_call_for_name(@id)
      class<<self;self;end.class_eval do
        define_method :name, lambda {expensive_name}
      end
    expensive_name
  end
end

In this case, we add a method with the same name to the singleton class of the instance of Product when name is called. Now, the next time name is called on our instance, it will find the method on the singleton class first which uses the cached value.

If you are doing any metaprogramming, it is useful to pull

class<<self;self;end
into its own method on Object.


class Object
  def singleton_class
    class<<self;self;end
  end
end

class Product
  def name
    expensive_name = ExternalService.expensive_call_for_name(@id)
    singleton_class.class_eval do
      define_method :name, lambda {expensive_name}
    end
    expensive_name
  end
end

Comments

  • Mark Aufflick on 22 Jul 03:50

    TMTOWTDI at it's best :) (For the un-Perl initiated, There's More Than One Way To Do It. Pronounced tim-toady)

Post a comment

Options:

Size

Colors