Ruby Memoization and Alternatives

Joël Quenneville

memoization

In Ruby, it’s common to use memoization to make sure that instance variables in a method only get set once regardless of how many times the method is called. For example:

class Dashboard
  def users
    @users ||= Users.all
  end
end

Sometimes there are better solutions. Let’s look at the problem we’re solving and the trade-offs involved.

No caching

Memoization is often used when deriving values from other state on our objects. Saving derived state to an instance variable is a form of caching and comes with all the associated gotchas (cache invalidation!).

Most of the time, this caching is a form of premature optimization. It’s often best to approach caching problems with a cost/benefit analysis. Caching has some upfront costs you always pay: extra complexity and cache invalidation. The desired benefit is to be able to skip some work.

Let’s look at some common scenarios:

  1. For operations that are done only once you pay the cost but don’t get any benefits.
  2. For cheap operations that are used multiple times, you pay the cost but often the benefit of skipping the work trends towards zero unless you’re working at an extremely high volume.
  3. For expensive operations that get called multiple times, the benefit of only doing the work once (or not doing it at all in a lazy method) may be worth the cost.

Adding a caching layer makes the code harder to reason about and more bug-prone. Make sure you benchmark your code to make sure the benefits are worth the cost. Usually it’s OK to do the same cheap operation more than once!

class User
  # ...

  def age
    # No need to complicate the code by caching this
    # @age ||= Time.now.year - @date_of_birth

    Time.now.year - @date_of_birth
  end
end

Constructors

Memoization is useful because it allows us to do some work only once and then have it available via an instance variable. There’s another construct in Ruby that has this feature - constructors! You can almost always convert a memoized method into a simple reader by moving the logic to the constructor.

In general, you want to be setting most of your instance variables in the object constructor at initialization time.

class Dashboard
  attr_reader :users

  def initialize
    @users = Users.all
  end
end

Note that this approach is eager. All the calculations are done immediately upon object instantiation. This is fine most of the time. Occasionally you prefer to defer an expensive calculation until the result is actually needed. That’s when you’re going to need another technique.

Laziness

If you’re doing some expensive work that may not necessarily get used, you may prefer a lazy approach. This is where memoization shines.

Instead of doing the work in the constructor, you do it in a method. The expensive calculations don’t happen at object instantiation but instead only happens when the method is called. Memoization ensures the result is cached so we don’t calculate every time the method is called.

class Dashboard
  def stats
    @stats ||= begin
      # do expensive work
    end
  end
end

Separate caching and calculation

When caching, it’s best to separate caching logic and calculation logic into their own methods. This improves readability and also makes it easy to re-run the calculations if necessary. As a bonus, it means you don’t need to deal with those awkward begin ... end blocks!

class Dashboard
  def stats
    @stats ||= do_expensive_work
  end

  private

  def do_expensive_work
    # do expensive work
  end
end

This works with the constructor approach too:

class Dashboard
  attr_reader :stats

  def initialize
    @stats = do_expensive_work
  end

  private

  def do_expensive_work
    # do expensive work
  end
end

Conclusion

Memoization has two big benefits:

  1. Cache expensive work
  2. Delay expensive work via laziness

As a form of caching it comes with all the advantages and downsides of such. It also complicates a codebase.

Most common uses of memoization in Ruby are premature optimization. For operations that:

  • are always used by your object then set the instance variable in the constructor and have a normal reader
  • are only used once then a regular method is fine
  • are cheap then a regular method is fine
  • are expensive and not always used then you may want to use a memoized method to do the work lazily

In all cases, move the calculation to its own method!