Begin; Return; Shenanigans
Published on .
Memoization is a neat way to improve performance — it allows you to cache the result of expensive operations to re-use the result without paying the cost.
We can combine memoization with the begin
block when using
Ruby. By combining these two language features, we can cache the results
of more complex logic.
Take the following example:
class Cost
def initialize(expensive:)
@expensive = expensive
end
def description
@description ||= begin
# An expensive operation.
sleep 1
return :expensive if @expensive
:cheap
end
end
end
Here we have a class, Cost
, which has a method,
description
, that returns a description of the cost
(specifically "expensive" or "cheap"). For this example, the class takes a
single argument when initialized — expensive
.
The description
method will sleep for one second and then
return “expensive” when we initialize the class with
expensive: true
, or “cheap” when we initialize it
with any other value.
The critical thing to note here is that, regardless of the value of
expensive
, the method sleeps for one second and then provides
its value.
Let’s test how this code performs — we'll use Ruby’s built-in
Benchmark module to measure how long the execution takes for five calls of
the description
method when the class is initialized as
expensive and inexpensive.
cheap = Cost.new(expensive: false)
expensive = Cost.new(expensive: true)
Benchmark.realtime do
5.times { cheap.description }
end
# => 1.0011720000766218
Benchmark.realtime do
5.times { expensive.description }
end
# => 5.003925999975763
In the first example, it takes about 1 second to call the method five times — this makes sense — the first time we run the method, the code sleeps for one second and returns its value which gets memoized.
However, in the second example, the five executions take five seconds which suggests that the result isn't being memoized...and it isn't, but why?
Let’s look at another example.
def say_hello
begin
return
end
puts 'Hello World'
end
# => nil
When we call the say_hello
method, the function returns
nil
and “Hello World” is never echoed. In this
example, code after returning from the begin
block never
executes. In the first example, memoization never happens following a
return
. Both of these things give a clue about what’s
going on.
When we use a begin
block, it’s easy to assume that it
would behave like any other block — you’d think that returning from
the block gives execution back to the method that called it. What happens
is returning from the begin
block returns us from the
function as well. This explains why the value never got memoized in the
first example and the world was never greeted in the second. When we
returned from the begin
block, we were not executing anything
after it as we’d left the function.
begin
blocks are for handling exceptions. Here are two
equivalent examples, one where we handle exceptions in a method and one
where we aren’t.
def exceptional
raise StandardError
rescue StandardError
# Handle the exception.
end
begin
raise StandardError
rescue
# Handle the exception.
end
Although the begin
keyword isn’t used in the method
(because we want to rescue from an error anywhere in the function rather
than scope it to a specific block of code), the two examples are
essentially the same. Not having the begin
keyword helps
demonstrate what is going on when we call return
from a
begin
block — we return from the method, not the block.
So, if you use this memoization style to help improve your app’s
performance, avoid calls to return
— it may not behave as you
expect.