“Exceptions are bad because they introduce non-local flow into a program.” It’s an argument I’ve seen often and it even came up in the comments in my previous article. It’s nonsense though. Not only can I not pin down precisely what it means, there’s simply no way it applies to exceptions.
Exotic flow patterns
What is non-local flow, or a non-local branch? I’ve been unable to find a concise definition so I’ll have to infer based on other meanings of “non-local” and what I’ve read in the argument.
Non-local implies something that is not happening, or controlled locally, in the current context. This is usually seen as the current function. Let’s look specifically at the point we call another function.
1 2 3 4 5 6 7 8 9 10 |
defn pine = -> { if( !prep() ) { return } //what happens here cone() post() } |
In the first case, with prep
, the function controls what happens when prep
is done. It inspects the return value and decides to continue, or return to its caller in turn. It’s a local decision, thus a local branch. The branch is influenced by prep
, but the decision is made locally.
What happens at cone
? We don’t have a visible branch here, so flow should presumably continue to post
. If the execution suddenly decides to go somewhere else we could have a non-local flow.
Can this happen? Yes, it certainly can. There are functions like longjmp
in the C library that make this possible. You can also use a non-local goto
in some compilers, which as the name implies jumps to a location outside of the current function.
If pine
, this function, is powerless to stop that from happening then we have a non-local flow.
I’m not including context or thread switches here as the logical flow will return to the the function in the same location. Plus, since all programs, regardless of source language are generally subject to these switches. There might be a special case with signal interruption, as it’s handled differently. But I don’t think it’s particularly relevant to this discussion.
Exception flow
Let’s put aside for a moment how exceptions are implemented, whether it’s with longjmp
or as zero cost, and consider how they can be semantically understood. In a language with exceptions any function can “throw an exception”.
The terminology here is perhaps misleading, instead just assume that any function can report an error. In a non-exception language it is programmer’s responsibility to check the error status. For example, in the C library you might need to check the errno
value:
1 2 3 4 5 6 |
FILE * f = fopen( "my_file", "r" ); if( errno != EOK ) { //handle error } load_data(f); |
Some languages encode the error combined with the result of the function, such as Haskell’s Maybe
or Rust’s std::result
. In pseudo-code this looks roughly like below:
1 2 3 4 5 6 |
var result = open_file( "my_file" ) if( is_error(result) ) { //handle _error } load_data(result.value) |
The key difference in exception based languages is that an implied error handling. If the error is not specifically handled the function raises the same error in turn. In our first example, the cone()
call is compiled roughly to a structure like this:
1 2 3 4 |
result = cone() if( is_error(result) ) { return result } |
The implied error handling simply returns the error to the caller. Let’s add some explicit error handling to our original code and see what happens:
1 2 3 4 5 |
try { cone() } catch( FileNotFoundException e ) { log "Skipping" } |
This can become this structure:
1 2 3 4 5 6 7 8 |
result = cone() if( is_error(result) ) { if( type_of(result) == FileNotFoundException ) { log "Skipping"; } else { return result; } } |
Completely local
There is nothing “non-local” happening here. The caller is in complete control of what happens when an error is raised. It doesn’t matter that most languages don’t implement exceptions this way, it only matters that we can reason about them this way. The “as-if” rule is golden in compilers: so long as the high-level semantics don’t change the compiler can modify low-level details at will.
I should note that I implement errors this way in Leaf. I had previously used the zero-cost model but was unhappy with how it worked. I gain more flexibility and less maintenance effort by using a return value approach instead. The high-level language is unaffected by this choice of implementation.
All local
Exception handling doesn’t add any complexity to a function’s branching. It can be modelled as a simple conditional and return. In contrast, languages with exceptions also tend to offer features like destructors, early return, and defer
statements. These things certainly do complicate block branching.
There are definitely things wrong with exceptions, but this idea of “non-local flow” is just not one of them. It’s a nonsensical argument that should be abandoned.