Begin; Return; Shenanigans

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.