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.
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:
- For operations that are done only once you pay the cost but don’t get any benefits.
- 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.
- 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
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.
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
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
Memoization has two big benefits:
- Cache expensive work
- 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!