As an anecdotal data point... I like Python and prefer it to Go for the most part. However, in the case of error handling, Go seems to get it right and Python does it very wrong. Exceptions are extremely confusing, I just can't wrap my head around them. I don't even accept, philosophically speaking, the concept of exception: there are no "exceptions" when running a program, only conditions that you dislike.
My example of error handling done right would be Rust's `Result<T, E>`. There's the ? operator for syntactic sugar to propagate errors upwards. Otherwise, to skip error handling, you're generally forced to unwrap() the result, which is a noticeable smell, and it will crash your program if the operation failed (as opposed to continuing despite the error). Finally, it maintains the error code advantage of keeping the control flow explicit and visible.
We've had decades of erroneous error handling from C codebases to learn from. Frankly, it's insane that we have a modern language that still uses error codes in essentially the same way.
Python style exceptions, pros:
- rapidly drop through layers when needed to get to a stable entry point - easy to add errors deep in the stack
Cons:
- basically a limited GOTO, therefore side-effect, therefore not composable - no way to know if what you are calling is gonna throw on you
Java pros:
- have to declare exception types, so at least you know what you are dealing with
Cons:
- you have to always declare exception types, which can be tedious - same non-composable GOTO-lite behavior - adding an exception deep in the stack is tricky
Go pros:
- errors are values, therefore typed and technically composable - no surprises
Cons:
- tedious, verbose - can't rapidly unwind unless you panic - rarely composable in practice, requires manual unwrapping in most cases - adding an exception deep in the stack is tricky
Rust Result pros:
- strongly, statically typed - no surprises - higly composable - can map over it - rapidly unwind in a composable way with ? operator
Cons:
- adding an exception deep in the stack is tricky (but often amenable to programmatic refactoring)
It's no wonder Rust is the SO most loved language 7 years running.
Python actually has a Result type library which I really like, but it's been hard selling my team, and you really need buy in. But I'd give it a swing.
Example: You write a function that calls 2 thirdparty libraries, both of which can fail. The typesystem in Rust is unable to express that the resulting error with be libAError or libBError. It is lacking anonymous union-types. Even if union-types have been added, you'd have to define one first and you'd have to use unsafe (at least from my understanding).
This also impacts user-defined error-types of course, but it makes errorhandling when using other libraries very annoying. You always have to define new types and do wrapping.
Another example: You have a function that calls 2 thirdparty libraries, both of which can fail. The two libraries have decided to use a customized error-type (and not the built-in one) because they need to carry around some extra context or need some other feature, but they still support everything that Result does but in a slightly. Now you need to manually convert those or learn the interface of this specific error-type, because there is no way to abstract over different "Result"-types. Why? Because Rust has no support for Higher Kinded Types, which would be needed to do so.
There are more examples, but these are two that are immediately relevant to pretty everyone using Rust. And yes, Rust has done a lot of things right and especially better than C or C++. But when looking at it as someone who has used other high-level-languages I can say it definitely did not "absolutely nail" it.
Master Gorad asks his apprentice:
"How many statements does it take to call a function in Go ?
Apprentice replies puzzled "I can just call foo();" and I am done ?
Master Gorad beats his student with a cane. No, you silly, it takes at-least 3 statements to make a function call in Go:
r, err := foo();
if err != nil {
return nil, err;
}But the flipside is that encoding every failure condition in the type system quickly becomes unfeasible. Every allocation can fail. Every array/slice indexing can be out of bounds. Some errors might just not be recoverable at all while maintaining consistent state. Go has the null pointer problem...
That's why both Go and Rust hide certain error conditions and have a fallback mechanism (panic/recover).
There is a balance between the two idioms, and which one is right depends on the language and the facilities the type system provides.
Interesting. For me it's very much the opposite. What about them do you find confusing?
> I don't even accept, philosophically speaking, the concept of exception: there are no "exceptions" when running a program [...]
I think you're looking at it wrong. Exceptions, as implemented in the languages I know, are more about having a framework of allowing inner/library code to drop the ball and run away screaming, without making more of a mess.
It also allows the calling function to ignore the mess and hope that something further up the call-chain will deal with it.
Exceptions circumvent the normal control-flow of a program, and that makes code less obvious to follow. When I see foo calling bar() in go, I know where, and if, errors returned by the function are being handled, just by looking at foo.
When I see foo calling bar() in Python, and there is no try-except block in foo, I have no idea what happens to any errors that bar may return. I now have to check everyting that calls foo, which may be multiple other places, each of which could have a different Exception handling, or none, in which case I have to also check all the callers of that caller of foo, etc.
And even if there is a try-except, I have to check whether except catches the correct TYPE, so now I also need to be aware of the different types of Exceptions bar may raise ... which in turn depends on everything that bar calls, and how bar itself handles these calls, etc.
Yes, error handling in Go is verbose. Yes, it would be nice if it offeres a little syntactic sugar to improve that.
But error handling in Go is also obvious.
Why? Keep in mind in go, you almost certainly don't check the type of the error. Why hold python to a hire standard (or the opposite: why don't you pass errors with types in golang, and handle errors of only a particular type or set of types?)
The answer, in both cases, is of course the same: errors are almost always handled in one of two cases: basically at the callsite (either at the callsite or in the parent function), or they are handled in "main" in some form of catchall.
Exceptions are really great for this. You very much don't worry about the things that might be thrown by a child of a child of a child of foo() that you call, because the abstraction shows that you shouldn't care (unless perhaps foo documents explicitly a particular function it throws). You don't need to waste code or mindshare on something you really don't care about. In go however, you do!
How does this compare to error handling in Go?
I prefer this way, but there was one thing I didn't understand until Ned Batchelder taught me, how to layer them:
- https://nedbatchelder.com/text/exceptions-in-the-rainforest....
- https://nedbatchelder.com/text/exceptions-vs-status.html
I didn't have the full mental model until I read that.
Error handling for an API etc usually make the most sense in a central location (some middleware etc) so why would I check that each and every function call was successful.
I do like go, but the error handling is hardly the best thing about it. It’s just different. Neither is wrong.
They are non-local jumps. They make reasoning about the code very difficult. When you read code, you have to treat all conditions symmetrically and equally likely (e.g., whether a file exists or it doesn't). Using exceptions for control flow, as is done sometimes in python, forces an unnatural asymmetry between cases that I find confusing. And this is just when there is a single exception at stake. Typically, several exceptions fly invisibly over the same code and it becomes impossible to understand (unless you assume that no exceptions occur, which is the wrong stance to take when analyzing an exception-riddled code).
TL;DR: raise and except are a corporate-friendly renaming of goto [0] and comefrom [1].
Pretty sure I don't do that when I read code. When I see "list.add()" I don't consider running out of memory and the operation failing equally likely to the list being added to. And if it did, in 99.99% of the cases I'm fine with it just bailing, because there's not much else to do at that point.
I agree that using exceptions for what could be considered normal conditions is not great. Trying to open a file that doesn't exist isn't by itself an exceptional incident. The calling code might consider it an exception and decide to raise, but the IO library shouldn't.
try {
some_code();
} catch (e) {
var fixed = handle(e);
if(fixed){
continue; // goes back to where the error was trown
} else {
beak;// gives up and continues from here
}
}Typically you just care to have one or two primary places where you actually care if something has gone wrong.
But our vocabulary is full of such things. A "method" is not a particularly descriptive term given what we use it for these days, either. At the end of the day, so long as everybody knows what it is, it's not a big deal.
Conceptually, though, it can be treated as an error monad.
Exceptions are very much not a monad, that's one of the biggest pain points about them. You can't map over exceptions. They are a control flow statement.
If you're not familiar with algebraic data types, they're well worth learning about, and not a difficult concept. Once you use a language with them, heading back to a language without them feels like developing with one hand tied behind your back.
Exceptions, aka faults, are a time-tested feature of CPUs that have been around for half a century:
Since messages in this channel propagates up the call stacks they are very handy to stop an application and therefore used for error handling.
But they can just as well be used for all sorts of other messaging. In base Python they are used to communicate that you’ve reached the end of an iteration.
If you’ve ever made a function that returns both a value and a status in as a tuple, chances are you are better off using exceptions to communicate the status, especially if there is a “stop” status.
Golang actually has a back-channel communication path that is somewhat similar as a second channel of communications. (Actually they're called channels!) They're a first-class and extremely powerful feature of the language. You can even run a for loop over them, block or not block while waiting for an incoming message, etc.
Here's a great video that talks about use cases and the patterns in using them, and they pair great with goroutines (and you don't have to sprinkle async before every function, either): https://www.youtube.com/watch?v=f6kdp27TYZs
It's like stack unwinding is a new concept or something.
And what will be the final result of the error? An error screen, right? That's presentation layer. Any fatal error should halt execution, be thrown to the top, and manifest itself as a message in the presentation layer, right?
Throwing an error is not "not handling it"!