Robert Mosolgo

Breaking Out of a Yield With Return

Did you know that calling return in one Ruby method could affect the flow of another method? I discovered it today while hunting a GraphQL-Ruby bugfix. You can get more reliable behavior with ensure, if it’s appropriate.

Instrumentating a block

Let’s imagine a simple instrumentation system, where method wraps a block of code and tags it with a name:

1
2
3
4
5
6
def instrument_event(event_name)
  puts "begin    #{event_name}"
  result = yield
  puts "end      #{event_name}"
  result
end

You could use this to instrument a method call, for example:

1
2
3
4
5
6
7
8
9
def do_stuff_with_instrumentation
  instrument_event("do-stuff") do
    do_stuff
  end
end

do_stuff_with_instrumentation
# begin    do-stuff
# end      do-stuff

It prints the begin message, then the end message.

Returning early

But what if you return early from the block? For example:

1
2
3
4
5
6
7
8
9
10
11
# @param return_early [Boolean] if true, return before actually doing the stuff
def do_stuff_with_instrumentation(return_early:)
  instrument_event("do-stuff") do
    if return_early
      # Return from this method ... but also return from the `do ... end` instrumentation block
      return
    else
      do_stuff
    end
  end
end

If you instrument it without returning from inside the block, it logs normally:

1
2
3
do_stuff_with_instrumentation(return_early: false)
# begin    do-stuff
# end      do-stuff

But, if you return early, you only get half the log:

1
2
do_stuff_with_instrumentation(return_early: true)
# begin    do-stuff

Where’s the end message?

It Jumped!

Apparently, the return inside the inner method (#do_stuff_with_instrumentation) broke out of its own method and out of #instrument_event. I don’t know why it works like that.

With Ensure

If you refactor the instrumentation to use ensure, it won’t have this issue. Here’s the refactor:

1
2
3
4
5
6
def instrument_event(event_name)
  puts "begin    #{event_name}"
  yield
ensure
  puts "end      #{event_name}"
end

Then, it prints normally:

1
2
3
do_stuff_with_instrumentation(return_early: true)
# begin    do-stuff
# end      do-stuff

Of course, this also changes the behavior of the method when errors happen. The ensure code will be called even if yield raises an error. So, it might not always be the right choice. (I bet you could use $! to detect a currently-raised error, though.)