Hacker Newsnew | past | comments | ask | show | jobs | submitlogin

> And the error handling... Shudder.

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.



Strongly disagree with your conclusions. There are definitely arguments against exceptions in cases where you always want to understand and handle failure conditions, but Go has not got it right. The need to manually handle returned errors adds too much noise to the code, and in my subjective opinion, the code looks far cleaner when you skip the error checking. Additionally, if the manual error handling code isn't written, failures will generally be silent.

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.


Rust absolutely nails the error handling department.

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.

https://pypi.org/project/result/


Sorry, but Rust's error handling is pretty clumsy compared to what you can do in some languages.

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.


I agree with your first point, but it's worth noting that if someone is willing to pay a small runtime cost, they can just return a Box<dyn Error> or anyhow::Error in these cases ergonomically. Additionally, there is the thiserror crate which makes defining the union type you described trivial.

Your second point is dubious; I've never seen any crate use anything other than Result. Afaik, it works everywhere and is so idiomatic that if somebody suggested reimplementing it, I'd immediately question their credentials.


> Box<dyn Error> or anyhow::Error in these cases ergonomically

This comes at the cost of losing the precise information what error-types could occur and makes it harder to read the code compared to a language that can use its typesystem to model that.

> Your second point is dubious; I've never seen any crate use anything other than Result.

Maybe. And you also barely see something like Result being used in Python. Is that because Result is bad? No. It is because it is _hard_ to use Result in Python, both of the lack of ergonomy compared to e.g. exceptions and also because it's not standard and people will look at you funny.

So why is pretty much everyone in Rust using Result? Because using your own error-type causes exactly the problems that I mentioned (and more). So no matter how you look at it - this is a shortcoming of Rust and hopefully something that the Rust team will improve in the future. (E.g. https://github.com/rust-lang/rfcs/issues/324)


> Sorry, but Rust's error handling is pretty clumsy compared to what you can do in some languages.

I agree with clumsy part, if that means error handing being more explicit.

I've personally never faced 2nd example, so I'll limit my discussion to 1st one. If you don't want to use an extra crate (anyhow/thiserror), you could always do a `map_err` which in my opinion is a superior choice as you get to know what kind of error you're dealing with and how to map it to your domain error.


> I agree with clumsy part, if that means error handing being more explicit.

Yeah no, that is not what I meant. It is possible to make error-handling even more ergonomic while staying fully explicit. I agree that implicit/magical/runtime error-handling is not more ergonomic.

> If you don't want to use an extra crate (anyhow/thiserror), you could always do a `map_err` which in my opinion is a superior choice as you get to know what kind of error you're dealing with and how to map it to your domain error.

I briefly looked at those crates, but while they make some parts better, they make others (for instance the explicitness) worse. On top of that, they are built using macros, which is totally okay from the perspective of the library and comes at a cost.

Overall it would simply be better if Rust's typesystem were capable enough so that those macros were not even needed.

All of that being said, Rust's error-handling is still good. I'm just bothered by the "Rust nailed it perfectly" attitude. It's better to be aware that there are even better solutions out there, so that we can go and improve Rust even more.


Small nitpick :

In Rust you would use an Enum (a discriminated Union) for this not an Union and you wouldn't need unsafe.


That's exactly my point: you should not have to define and use an enum here. The compiler should be smart enough to figure out that the result will be `Result<T, Error1 | Error2>` without the user doing anything here.


As the joke goes:

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;
    }


This!


I also much prefer explicit error handling and really disklike exceptions because every function can fail in a myriad of ways and you have no idea how. Rust does it much, much better than Go though, thanks to sum types and syntax sugar for early returns (`?`).

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.


> Exceptions are extremely confusing, I just can't wrap my head around them.

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.


> 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.


> 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.

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!


> Why?

Because unless the caller of foo uses a catchall, it may not actually catch the exception raised by bar. Lets say bar opens a file, and callerOfFoo says `except FileNotFoundError:` ... what if bar opens a file that exists with insufficient Permissions? Then it's `PermissionError`, and callerOfFoo won't catch it.

Sure, its possible that callerOfFoo is prepared for that, but my point is, I don't know that unless I check its code.


But again, why do you care?


Because I can visualize Go errors completely. They're simple links from one place to another. They are straight fibers connecting my modules and functions to each other. They always work exactly like I expect them to.

With python and other exception based languages? I have made hundreds of commits in my lifetime after being surprised that some random 3rd party library throws a nonstandard exception and breaks all sensible control flow that everybody assumed was happening.


This is the reverse of the issue that the parent mentioned though!

Consider a go function foo which returns some value and an error. What can you do with that error? You mention control flow being broken by python and others, but the control flow of

    def myfunc():
        if foo():
            do_a()
        else:
            do_b()
and

    def myfunc():
        try:
            cond = foo()
        except:
            raise
        
        if cond:
            do_a()
        else:
            do_b()
and

    func myfunc() err {
        cond, err := foo()
        if err != nil {
            return err
        }
        if cond {
            do_a()
        } else {
            do_b()
        }
Are all the same! And you can't do anything different in them, because in go, you have no knowledge about the error. Is it aa temp error you should retry? Who knows, are you going to parse the error string to figure it out? The only think you can do in go to an error from some library function is pass the error, possibly adding some additional context, because otherwise you may be mishandling the error.

In exception based languages the "pass the error" case is done for you, but if you do want to retry retriable errors, you can actually use a language feature to know that. In go, you have to hope the library creator created an interface (and that all of the possible errors that could percolate up have compatible error-extension interfaces!) that includes some kind of type information, which almost no one does.

You're talking about "sensible" control flow, but go doesn't have it!


I argue that errors.Wrap and errors.Is from the Go stdlib solves this problem. Libraries can export their own error types and you can check if they threw that error type. I use this pattern all the time to handle special errors differently. Used it to implement optimistic locking which does ~not~ simply propagate all errors upwards!

https://gosamples.dev/check-error-type/

  cond, err := foo()
  if errors.Is(err, ErrFooHadTemporaryFailure) {
    // retry
  } else if errors.Is(err, ErrFooHostOffline) {
    // switchFooHost() 
  } else if err != nil {
    // propagate unhandled error upwards
    return nil, fmt.Errorf("foo unhandled err: %w", err)
  }

Of course it is up to the package author (or yourself) to write functions that return specific wrapped error types. Otherwise we're stuck in the situation your comment describes.

What do you mean by creating an error interface? It's a one liner to make a new error case:

  var ErrFooHadTemporaryFailure = errors.New("temporarily failure, retry later")


I can define error values, even wrap them, and check against them, which also works on wrapped errors without having to unwrap them first:

https://go.dev/play/p/C_Yv5s6USma


Please indulge an ignorant question from a Go-curious outsider: are ignored error returns flagged/grumbled-about? One of the enduring PITAs with C-error handling is that AFAIK there's no good way to highlight slipshod code. For example printf() returns "int number_chars_written", system() returns "int status_code": ignoring the first case is ubiquitous and having to cast it to (void) is noisy, ignoring the second is a bad smell and warrants at least a comment.

How does this compare to error handling in Go?


> are ignored error returns flagged/grumbled-about?

No, they are not.

    func couldFail() error { /*...*/ }
    func ohNoes() {
        couldFail()
    }
ohNoes() can call couldFail() and ignore the error return completely, without the compiler complaining.

This is intended. Error returns are not special; they are just another return value. Error handling by the caller includes "I don't care if there is an error". Yes, in some situations that's a bad code smell. In others, it really doesn't matter. Go puts the responsibility to decide which applies in the hands of the programmer.

To get the same effect in python, I'd have to wrap the call in

    try:
        couldFail()
    except:
        pass


I'd hope it's a pretty non-controversial thing by now that software shouldn't silently fail without indicating something's wrong?

If I saw the latter Python code in a code review I would squirm. I would scrutinize it very seriously and if there's some really odd case where you really want this, I'd require at least a logger line or a comment to explain why in the world this is desired behavior.


> I'd hope it's a pretty non-controversial thing by now that software shouldn't silently fail without indicating something's wrong?

That depends whether the error condition is worth caring about, for a given application, in a given state.

eg. how often do programs check if `printf("something")` actually succeeded?


> eg. how often do programs check if `printf("something")` actually succeeded?

Probably not very often? But they probably should?

If you're printing something, there's probably a case where you care about that being printed, and if it's not in that case, that's bad?

This also applies to logs too. Just because you're not always looking at the logs doesn't mean they're not important when you need them.

Sure, if you have some chatty logs going through a distributed logging system you might not want to crash the whole thing, and in that case you might want to ignore the exception. But this is a very fringe case.


Exceptions propagate up the call stack until caught, simple as that.

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.


Things can still panic in go.

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.


> Things can still panic in go.

Yes, but panics are intended to represent an actually exceptional situation, like dereferencing nil, or the machine running out of memory ... things that normally shouldn't happen, and from which the logic cannot easily recover.

> Error handling for an API etc usually make the most sense in a central location (

Which is doable:

    if err := couldFail(); err != nil {
        return err
    }
I can let errors bubble up the call stack as far as I want, I just have to be explicit about it.


> What about them do you find confusing?

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].

[0] https://en.wikipedia.org/wiki/Goto

[1] https://en.wikipedia.org/wiki/COMEFROM


> When you read code, you have to treat all conditions symmetrically and equally likely

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.


> 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.

This is a way of thinking that I don't get. Yes it's fine that an IOLibrary has to be able to handle a FileDoesNotExistError condition. But from the caller POV: I clearly instructed you to open a file of some description. I expect you to return to me a handle for said file. Everything that does not match that expectation is exceptional (to the caller).

And it is that violation of expectations that is communicated by (Python's) exceptions.


> Everything that does not match that expectation is exceptional (to the caller).

No. If you want to read some data from a file if it exists, and continue merrily along if it doesn't, then you cannot simply check if the file exists and then try to open it. That would lead to a race condition. The file could for example be deleted in between the two calls.

The only proper way to handle that is to try to open the file, and if the result of that is FileDoesNotExistError then you continue along merrily.

If the file the caller tries to open is a settings override file, say, then it's not exceptional that the file does not exist.


> Everything that does not match that expectation is exceptional (to the caller).

Sure, but saying "open this file" and having the response "nothing here, boss" is not exceptional. It's pretty normal. Isn't that just one of the two obvious options when you try and open a file? To me, exceptional would be something like "this file was open and now it's deleted."


I am convinced that a mixed goto-comefrom model would work best; consider this pseudo-syntax:

    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
      }
    }


In fact, if we want to go all-in with this style, we could remove the if/else construction altogether (from the language!) and do everything using exceptions. Since they are good for control flow, why stop here?

The old-fashioned

    if (condition):
            statement_1
    else:
            statement_2
now becomes

    try:
            assert(condition)
            statement_1
    except AssertionFailed:
            statement_2
This is much clearer, since the "normal" flow of execution is emphasized, and it avoids using an ad-hoc legacy if/else construct. Moreover, it has the advantage that statement_2 can be hidden into another part of your program, wherever you want to catch this exception.


I was thinking more of making a parallel with effect handler system currently in development for Ocaml and other research languages, you do something similar to throwing a continuations[0][1][2].

It is proposed as an alternative to both the current monad-oriented approach used in Haskell and the procedural side-effects of OCaml.

To my understanding the base idea is that you can register "handlers" for (user defined) "effects" that can be emitted from normal code.

Once an effect is emitted the handler receives a continuation and can decide what to do with it (call it immediately, register it in a async/await pool, repeat it 5 times).

It would offer a type safe way to implement async/await as library code, which sounds quite cool.

The proposed try/catch/continue was a silly bastardization of this idea.

[0] https://en.wikipedia.org/wiki/Continuation

[1] https://www.youtube.com/watch?v=6lv_E-CjGzg

[2] https://www.youtube.com/watch?v=X30xmcOow2U


If people overuse try/catch, the code is close to impossible to read… but try/catch isn’t meant to be applied liberally throughout the code. Don’t let someone’s misunderstandings and bad code ruin it for you.

Typically you just care to have one or two primary places where you actually care if something has gone wrong.


"Exception" is arguably a historical term in Python - it comes from languages where exceptions really are exceptional and very rarely handled; but when they're used for regular control flow, yeah, that's definitely a misnomer.

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.


> 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.


They're a monad with an implicit hardcoded mapping function that's basically equivalent to Rust's "try". Yes, the fact that you can't change the function is a pain, but it doesn't preclude reasoning about them in this manner.


Go's error handling is one of its single worst features. It would be much better if if had supported algebraic data types (which would also eliminate two other abominations: `iota` and `nil`, with the former being replaced by type constructors as sum types can act as enums and the latter would be replaced with an `Option` type) along with some kind of pattern matching.

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.


There are indeed exceptions when running a program. Exceptions, as a construct, are baked in at the hardware level. When a program page faults, or tries to divide by zero, or runs an invalid opcode, the CPU will jump to an exception handler in much the same way as the higher-level constructs in Python and elsewhere.


> there are no "exceptions" when running a program, only conditions that you dislike.

Exceptions, aka faults, are a time-tested feature of CPUs that have been around for half a century:

https://wiki.osdev.org/Exceptions


Sure, but people here are talking about exceptions as a control-flow feature of high-level programming languages like Python. It has nothing to do with cpu exceptions or interrupts.


I don't think that's a good argument... otherwise we would like to honor the prominence of JNZ, JNE, JL, LOOP and other "spaghetti code" constructs used deep in CPU models but that are just not appropriate for human readable code.


I don’t know if this helps or just makes it more unacceptable to you, but exceptions in Python is more of a second channel of communication between parts of programs.

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.


This is true, that's exactly what exceptions are. One could argue (I would) that this is a very powerful part of Python, but the indeterminate nature of the exception (where is it coming from, how is it defined, what should I provide to my client) makes it easier to catch exceptions as they percolate upward, but more difficult to correctly know how to handle them (especially as the permutations approach infinity as underlying libraries get upgraded).

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


In most of my code, exception handling happens in one file, one function. You throw an exception, and you let it propagate until it is handled. Why is this so hard?


Because where the code that handles it is not the problem (in fact, optimisting that for simplicity could cause other problems). The problem is what state is everything left in when the exception is thrown? What if you're halfway through writing to a file/network socket/screen/shared resource when an exception happens?


That should be cleanly handled in a language like Python and others, where the context managers clean up the resources when exiting a scope, normally or after an exception.

It's like stack unwinding is a new concept or something.


You're missing the point. The OS resources might clean up, but that doesn't mean the application's world (some of which may be a file on a server on another continent) has been left in a known good state.


Trying to understand your point. If you try to open a file or a network connection or divide by zero, you don't handle any exception in that calling function, but instead let them propagate all the way to the top?


What are you going to do with that? Keep returning errors until the calling function gets the error? That's what exceptions do for you. Do you have any guarantee that there is no bug in the error-handling code somewhere?

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"!




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: