Multiline Memoize
Sometimes you need to treat multiple Ruby statements as if they were a single expression, but you don’t want to create a whole new method for it. Ruby has a syntax for this, but it’s surprisingly little-known. In this episode you’ll learn how to apply this Ruby idiom, using the practical example of memoizing a method.
Video transcript & code
Here's a Ruby idiom you're probably familiar with. We have a method which returns a value. The return value requires an expensive operation to discover.
def user_profile
some_service.user_profile_for(@username)
end
There's no need to re-calculate it every time. So we memoize the result using an instance variable and the or-equals operator.
def user_profile
@user_profile ||= some_service.user_profile_for(@username)
end
This works great for simple methods. But sometimes we run across a method whose return value we'd like to memoize, but the value requires multiple lines of code to generate. Here's an example from some code I wrote.
def episode_list
connection = Faraday.new do |conn|
conn.basic_auth login, password
conn.adapter :net_http
end
response = connection.get("https://rubytapas.dpdcart.com/feed")
response.status == 200 or raise "Unable to retrieve feed"
body = response.body
doc = Nokogiri::XML(body)
items = doc.xpath("//item")
episodes = items.map {|item|
{
title: item.at_xpath("./title").text
}
}
end
The details of the method aren't important. the important thing is that this code makes an HTTP request, which we'd like to avoid making more than once. And the result of the request requires multiple lines of processing before the value is ready to be returned.
There's actually a very simple way to memoize this code, and it involves begin blocks. Let's talk about begin
blocks for a minute.
You're probably most familiar with begin
blocks as a way to introduce a zone of code in which exceptions will be rescued.
begin
# ...
rescue SomeException => e
# ...
end
But that's no their only use. You might recall back in Episode #73 we used a begin
block to implement a "do-while" loop in ruby. We wrapped the begin block around a sequence of statements, turning them into a logical unit. To which we could then apply the while
statement modifier.
begin
file.seek(next_chunk_offset, IO::SEEK_END)
chunk_start_offset = file.tell
chunk = file.read(chunk_size)
newline_count += chunk.to_s.chars.count("\n")
next_chunk_offset -= chunk_size
end while chunk && chunk_start_offset > 0 && newline_count <= 10
In fact, the begin
block is a general purpose language feature for grouping statements together. Statements inside the block are executed sequentially just as they are anywhere else in Ruby code, and the return value of the block is the value of the final statement. We can see this if assign the result of a begin block to a variable, and then perform a series of operations inside the block. The value of the last statement is the value returned.
result = begin
a = %W[foo bar baz]
a.delete("foo")
a.delete("bar")
a
end
result # => ["baz"]
Incidentally, in case you were wondering, begin
blocks do not introduce a new variable scope. We can see this if we assign a variable inside a begin
block and then read the variable outside the block.
begin
a = 23
end
a # => 23
Getting back to our memoization example, we can memoize the entire body of the method by enclosing it in a begin
block and assigning the result of the block to an instance variable.
def episode_list
@episode_list ||= begin
connection = Faraday.new do |conn|
conn.basic_auth login, password
conn.adapter :net_http
end
response = connection.get("https://rubytapas.dpdcart.com/feed")
response.status == 200 or raise "Unable to retrieve feed"
body = response.body
doc = Nokogiri::XML(body)
items = doc.xpath("//item")
episodes = items.map {|item|
{
title: item.at_xpath("./title").text
}
}
end
end
Now the episode list will only be calculated once.
However, now that you know how to do this, I have to confess that I almost never use this technique. I'll tell you why.
At least half the time when I introduce a begin
block for multiline memoization, I late realize that I also want a way to invoke the method with no memoization. In certain cases, I want to be sure that I'm getting a fresh value, not a cached one. Or that I'm forcing the request to be made.
So what I do instead is this: I extract the entire body of the method into a new method. I typically give this new method the same name as the old one, prefixed with get_
. This emphasizes the fact that this new method will always take action, rather than returning a cached value.
Then I update the original method to do a single-line memoized send to the extracted method.
def episode_list
@episode_list ||= get_episode_list
end
def get_episode_list
connection = Faraday.new do |conn|
conn.basic_auth login, password
conn.adapter :net_http
end
response = connection.get("https://rubytapas.dpdcart.com/feed")
response.status == 200 or raise "Unable to retrieve feed"
body = response.body
doc = Nokogiri::XML(body)
items = doc.xpath("//item")
episodes = items.map {|item|
{
title: item.at_xpath("./title").text
}
}
end
This gives me my memoized method as well as a way to bypass memoization, should I need to. And it has the added bonus that I haven't introduced a new level of nesting into a method.
So there you go: two different techniques for memoizing complex methods. Happy hacking!
Responses