In general, raising exceptions for control flow makes code hard to understand. However, there are other cases when an exception is the right choice.
Raise vs Return
return’s evil twin.
They both stop the execution of the current method. After a
return, nothing else is executed. After a
raise, nothing else is executed … maybe. The method may have a
ensure clause which is executed after the
raise, so a reader must check for those.
They both change flow of control.
return gives control back to the caller.
raise may give control anywhere on the call stack, depending on the specific error and
rescue clauses. If all you see is a
raise, you can’t guess where it will be rescued!
They both send values to their new destination.
return provides the given value to the caller, who may capture the return value in a local variable.
raise provides the error object to the
return can send any kind of value, but
raise can only send error objects.
They both create coupling across call stack frames.
return couples two adjacent call stack frames: caller depends on the return value.
rescue couples far-removed stack frames: they may be adjacent, or they may be several frames removed from one another.
Raise → Rescue is Unpredictable
Sending values through a program by calling methods and
return-ing values is very predictable. If you return a different value, the caller will get a different value. To see where return values “go”, simply search for calls to that method.
raise’d errors go is a bit more challenging. For example, this change:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
How can you tell if this is a safe refactor? Here are some considerations:
- Instead of looking for callers of this method, you have to find entire call stacks which include this method, since any upstream calls may also have expectations about this error.
- When searching for
rescues, you have to keep the error’s ancestry in mind, finding bare
rescues and class-tagged
rescues may consume the error object itself. For example, they may read its
#messageor other attached data. If you change any properties of the error object, you may break the assumptions of those
- If you find that the new error will be
rescue’d differently, you must also consider how execution flow will change in other methods. For example, some methods may be cut short because previously-
rescue’d errors now propagate through them. Other methods which used to be cut short may now continue running, since errors are rescued in child method calls.
raise is located in a Ruby gem, these problems are even harder, because
rescue clauses may exist in your users’ code.
If your error patterns are well documented,
༼ つ ◕_◕ ༽つ 🏆. Bravo, just don’t break your public API. Users might still make assumptions beyond the documentation, such as error ancestry or message values. Additionally, they could be monkey-patching library methods and applying
rescue-related assumptions to those patches.
If your error patterns aren’t documented,
💩 ノ༼ ◕_◕ ノ ༽. You have no idea what assumptions users make about those errors! You can’t be sure your changes won’t break their code.
Use Return Instead
raise can be replaced by
return. However, if you’re using
raise to traverse many levels of the call stack, the refactor will be intense. Take heart: previously you were hacking your way back up the call stack, now you’re creating a predictable, explicit flow through your program!
It’s worth repeating, don’t use exceptions for flow control.
Here are some techniques for expressing failures with
- Return errors instead of raising them. Ruby errors are objects, like everything else. You can return them to the caller and let the caller check whether the returned value is an error or not. For example, to return an error:
1 2 3 4 5 6 7 8 9 10 11
- Use success and failure objects. Instead of returning a raw
StandardErrorinstance to the caller, use a
Failureclass to communicate failure. Additionally, use a
Successclass to communicate success. (This is similar to the “monad” technique, eg
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
As a last resort, return
nilas an expression of failure has some downsides:
nilcan’t hold a message or any extra data
nilis a valid value
But, for simple operations, using
nilmay be sufficient. Since it will be communicated via
return, refactoring it will be straightforward in the future!
Sometimes, Raise is Okay
raise has its purposes.
raise is a great way to signal that the program has reached a completely unexpected state and that it should exit. For example, in the
convert_file example above, we could use
raise to assert that we don’t receive an unexpected value from
1 2 3 4 5 6 7 8 9
Now, if the method ever returns some unexpected value, we’ll receive a loud failure. Some people use
fail in this case, which is also fine. However, the need to disambiguate
fail is a code smell: stop using
raise for non-emergencies!
raise is also helpful for re-raising other errors. For example, if your library needs to log something when an error happens, it might need to capture the error, then re-raise it. For example:
1 2 3 4 5 6 7 8 9 10 11 12 13
This way, you can respond to the error without disrupting user code.
In my own work, I’m transitioning away from raising errors and towards communicating failure by return values. This pattern is ubiquitous in languages like Go and Elixir. In Node.js, callbacks communicate errors in a similar way (callback arguments). I think Ruby code can benefit from this practice as well.