Memoization is a technique you can use to speed up your accessor methods. It caches the results of methods that do time-consuming work, work that only needs to be done once. In Rails, you see memoization used so often that it even included a module that would memoize methods for you.

Later, this was controversially removed in favor of just using the really common memoization pattern I’ll talk about first. But as you’ll see, there are some places where this basic pattern just doesn’t work right. So we’ll also look at more advanced memoization patterns, and learn some neat things about Ruby in the process!

Super basic memoization

You’ll see this memoization pattern all the time in Ruby:

app/models/order.rb
1
2
3
4
5
6
class User < ActiveRecord::Base
  def twitter_followers
    # assuming twitter_user.followers makes a network call
    @twitter_followers ||= twitter_user.followers
  end
end

The ||= more or less translates to @twitter_followers = @twitter_followers || twitter_user.followers. That means that you’ll only make the network call the first time you call twitter_followers, and future calls will just return the value of the instance variable @twitter_followers.

Multi-line memoization

Sometimes, slow code won’t fit on one line without doing terrible things to it. There are a few ways to extend the basic pattern to work with multiple lines of code, but this is my favorite:

app/models/order.rb
1
2
3
4
5
6
7
8
9
class User < ActiveRecord::Base
  def main_address
    @main_address ||= begin
      maybe_main_address = home_address if prefers_home_address?
      maybe_main_address = work_address unless maybe_main_address
      maybe_main_address = addresses.first unless maybe_main_address
    end
  end
end

The begin...end creates a block of code in Ruby that can be treated as a single thing, kind of like {...} in C-style languages. That’s why ||= works just as well here as it did before.

What about nil?

But these memoization patterns have a nasty, hidden problem. In the first example, what if the user didn’t have a twitter account, and the twitter followers API returned nil? In the second, what if the user didn’t have any addresses, and the block returned nil?

Every single time we’d call the method, the instance variable would be nil, and we’d perform the expensive fetches again.

So, ||= is probably not the right way to go. Instead, we have to differentiate between nil and undefined:

app/models/order.rb
1
2
3
4
5
6
class User < ActiveRecord::Base
  def twitter_followers
    return @twitter_followers if defined? @twitter_followers
    @twitter_followers = twitter_user.followers
  end
end
app/models/order.rb
1
2
3
4
5
6
7
8
9
10
class User < ActiveRecord::Base
  def main_address
    return @main_address if defined? @main_address
    @main_address = begin
      main_address = home_address if prefers_home_address?
      main_address ||= work_address
      main_address ||= addresses.first # some semi-sensible default
    end
  end
end

Unfortunately, this is a little uglier, but it works with nil, false, and everything else. (To handle the nil case, you could also use Null Objects and empty arrays to avoid this problem. One more reason to avoid nil!)

And what about parameters?

We have some memoization patterns that work well for simple accessors. But what if you want to memoize a method that takes parameters, like this one?

app/models/city.rb
1
2
3
4
5
class City < ActiveRecord::Base
  def self.top_cities(order_by)
    where(top_city: true).order(order_by).to_a
  end
end

It turns out that Ruby’s Hash has an initalizer that works perfectly for this situation. You can call Hash.new with a block:

1
Hash.new {|h, key| h[key] = some_calculated_value }

Then, every time you try to access a key in the hash that hasn’t been set, it’ll execute the block. And it’ll pass the hash itself along with the key you tried to access into the block.

So, if you wanted to memoize this method, you could do something like:

app/models/city.rb
1
2
3
4
5
6
7
8
class City < ActiveRecord::Base
  def self.top_cities(order_by)
    @top_cities ||= Hash.new do |h, key|
      h[key] = where(top_city: true).order(key).to_a
    end
    @top_cities[order_by]
  end
end

And no matter what you pass into order_by, the correct result will get memoized. Since the block is only called when the key doesn’t exist, you don’t have to worry about the result of the block being nil or false.

Amazingly, Hash works just fine with keys that are actually arrays:

1
2
3
h = {}
h[["a", "b"]] = "c"
h[["a", "b"]] # => "c"

So you can use this pattern in methods with any number of parameters!

Why go through all this trouble?

Of course, if you start adding these memoization patterns to a lot of methods, your code will get pretty unreadable pretty quickly. Your methods will be all ceremony and no substance.

So if you’re working on an app that needs a lot of memoization, you might want to use a gem that handles memoization for you with a nice, friendly API. Memoist seems to be a good one, and pretty similar to what Rails used to have. (Or, with your newfound memoization knowledge, you could even try building one yourself).

But it’s always interesting to investigate patterns like this, see how they’re put together, where they work, and where the sharp edges are. And you can learn some neat things about some lesser-known Ruby features while you explore.

Don't miss out on my next essay

Sign up below to get my free weekly Ruby column. I'll send you original articles and advice every Friday to help make you a smarter, better Ruby developer. Drop your name in the box!

Did you like this post? You should read these:

Comments