Introduction
Memoization is a common technique in Ruby, but alas it's one with a few potential gotchas we need to be aware of to use it effectively. This post gives a short introduction to things that you probably want to watch out for when memoizing, or rather remembering, values that methods compute.
Difficulty
Foundational
Little to no prerequisite knowledge required. This post focuses on foundational and fundamental knowledge for Ruby programmers.
Memoization
First off, what do we mean when we say memoization? Shortly put it's a way to "remember" a value a method might produce, especially whenever that method happens to be expensive. If it happens to take a few seconds to run a method it'd probably be a good idea to store what the result was just to make sure we don't have to run the entire thing over again.
This, in essence, is memoization. We memorize, memoize, remember, or otherwise keep a record of what happened last time we did something.
That all said rarely is something quite so straightforward, and this post does elide a few concerns in favor of being a more introductory text. Shall we get into it then?
Instance Variables and ||=
The first method, and the one you're most likely to see, is that an instance method of a class happens to use ||=
and an instance variable to remember a value:
class MyClass
def some_method
@some_method ||= some_expensive_computation_or_api_call
end
end
If we happen to say this:
our_thing = MyClass.new
out_thing.some_method
out_thing.some_method
...the second run will not run the code on the right side, the some_expensive_computation_or_api_call
, which might be something more along the lines of say:
@some_method ||= Net::HTTP
.get(URI("url_goes_here"))
.then { |http_response| JSON.parse(http_response) }
||=
and What it Does
Why does that work though? Well let's take a quick detour to look at ||=
and what it does. The short version is that these two lines are roughly equivalent:
a = a || "new value"
a ||= "new value"
The second only elides the = a ||
part in favor of ||=
. This works great in most cases, but it has one glaring weakness: legitimate falsy values.
Legitimate Falsy Values
In Ruby only nil
and false
are considered to be falsy. The problem is if false
or nil
happen to be an expected return of an expensive method. Let's say we had this:
class MyClass
def some_question?
@some_question ||= api_response.include?("expected value")
end
def api_response
Net::HTTP.get(URI("url_goes_here"))
end
end
Chances are if the response of some_question?
is false
that's a valid return, and we don't want to go calling the API repeatedly until it happens to be true, meaning that ||=
would cause it to get called repeatedly.
That's not great, so what do we do about it instead?
The defined?
method
We can check whether an instance variable was even defined in the first place using defined?
. It works on any type of variable, so let's start with a local variable here:
defined? a
# => nil
Now you might expect this to give a Boolean result, but in actuality it does something a bit odd:
defined? a
# => nil
a = 5
defined? a
# => "local-variable"
Considering a String
is truthy this doesn't really present an issue, but it's good to know that this does not return true
or false
, but rather the type of variable or nil
.
Why is this important? Well if we took that same method from above where ||=
fails to "remember" legitimately false values we could make it into something like this instead:
class MyClass
def some_question?
return @some_question if defined? @some_question
@some_question = api_response.include?("expected value")
end
def api_response
Net::HTTP.get(URI("url_goes_here"))
end
end
Now if that instance variable happens to be set the value will be remembered despite being falsy in nature.
So Which Do I Use?
If a method is Boolean (it returns true
or false
) in nature use defined?
. If it returns any other type, or you want to recompute in the cases of false
or nil
you're safe to use ||=
instead.
You Said It's More Complicated?
Yep. Left as an exercise to the reader what do you reckon happens if we start introducing method arguments into the equation, or other inputs from other parts of the application? In those cases the responses are only valid in the same context, like being called with the same arguments.
You can still do this with a Hash
, yes, but we won't get too far into that in this article as that would be more advanced. If you want a more detailed explanation of this feel free to leave a comment and I can look into it.
There's also the entire &&=
operator which very few people use, which is more of a "yes, and..." concept, but that's also another article. I may write that one regardless as I very rarely see it in production code, but it definitely has a place there.
Wrapping Up
This, again, is more of an introduction to memoization in Ruby. There are more detailed guides out there including this RubyConf talk called "Achieving Fast Method Metaprogramming: Lessons from Memowize"
I will be looking to expand this series as I get back into mentorship more at work with common questions I happen to see come up. If you happen to have questions about any part of Ruby feel free to reach out or comment on this post and I can take a look into covering them as well.
Top comments (4)
Hello, I'm a bit confused with the fact that
body
inapi_response.body.include?("expected value")
doesn't work. Looks likebody
is unnecessary in the case. Or do I misunderstand something?Too used to using
HTTP.rb
, bad habit. I will fix this.Thank you for the fix!❤️
Thanks for the detailed explanations. The example for boolean variables is really useful! 🙌