Hacker News new | ask | show | jobs
by jasonhansel 1261 days ago
Something that annoys me:

- Go's approach to normal error handling assumes that correctness and proper error handling are paramount: every error must be handled explicitly, and every control flow path must be made obvious, no matter how much verbosity this creates.

- Go's approach to panic handling assumes that correctness and proper error handling don't matter: a function can halt at any given point, panics are easy to trigger by accident (e.g. methods with nil receivers), handling them explicitly is usually discouraged, and they tend to leave values in intermediate and unexpected states (unless you use "defer" very carefully and consistently).

Rust partially resolves this issue by preventing many causes of panics, by using methods like "lock poisoning" to avoid leaving shared values in unexpected states, and by having proper destructors.

Go's approach (just crash everything) makes it easy for one error to completely bring down an application. Handling panics leads to values being left in inconsistent states with operations half-completed.

3 comments

One of the difficulties here is that you can’t actually turn every fault into an error return. Yes, Rust “partially” resolves the issue, but if it’s not null pointers, it’s out of bounds array accesses or something like that.

The reason I say you can’t turn every fault into an error return is because an unexpected infinite loop is also a type of fault. If you call some function that has bugs in it, then there’s a chance that the function won’t return. “Won’t return” may mean that it panics, deadlocks, loops forever, or loops for so long it might as well be forever. In Haskell, all of these different behaviors are lumped in together as “bottom”, and bottom is a value which has well-defined semantics even though it covers all these different cases. There’s a whole debate in the Haskell community about whether functions should be total. A total function doesn’t return bottom unless bottom was an argument—in Go/Rust terms, a total function does not panic and does not infinitely loop.

My take—as long as you think “this function might not return because it has a bug in it”, there’s not a good reason to prohibit panic(). The panic() functionality is a more controlled, flexible way for a function to not return.

I think you could write a whole article on when to use recover() in Go. The idea that you should never panic is a bit of a hopeless dream—if that’s the kind of correctness you want, then it sounds like you want some kind of formal verification, which can be done but not in Go. The idea that you should never recover() is too severe. Yes, you can find code that leaves your program in an inconsistent state after a panic(), but in practice I’d say that these problems are relatively rare. You can also use panic/recover to simplify your code in certain ways. I’ve used panic/recover to write parsers or deserialization code, where you just use a panic() to return an error from the top-level parser/deserializer, which catches it with a recover().

I’d also say that it’s relatively normal to recover() inside your request handler for network services. You could weigh the risk of panic/recover leaving your application server in an unexpected state against the risk of getting a denial of service from panic taking down the whole app.

Panics in Rust are for logic errors that can't be meaningfully recovered from. You can easily convert an error state into a panic, by using .unwrap() or .expect(), or pass the error back to the caller via the '?' syntax. Rust does have a "recover"-like facility that can catch a panic, but it's intended for exceptional use; it also has no effect when panics are configured (at the whole-program level) to abort the program immediately.
Yes, that matches my understanding. In Go, panics can also be used for recoverable errors, if you so choose. In Rust you can catch_unwind() which is similar to Go’s recover(), and the big difference is that (1) it may not work depending on how your project is configured, and (2) its use is very strongly discouraged.
In theory, Go is the same way, but Go does a lot less to prevent panics, limit them to very specific circumstances, mitigate their effects, or limit unexpected failure modes.
> Yes, Rust “partially” resolves the issue

Which is a pretty big advantage!

Doesn’t Go also partially solve the issue?

I write both Go and Rust code, and I don’t feel like there’s a huge advantage to one side or the other here. Maybe I don’t understand what point you are making.

Whether it's a big or small advantage is an empirical question. How often do panics happen otherwise, and how bad are they? It's going to depend on the system.
> panics are easy to trigger (e.g. methods with nil receivers)

No, methods nil receivers in Go don't trigger panics (if they are not dereferenced).

> every error must be handled explicitly, and every control flow path must be made obvious, no matter how much verbosity this creates.

Is this, bad? It is just the recommended way. You are not required to implement your code in this way.

> No, methods nil receivers in Go don't trigger panics (if they are not dereferenced).

Yes, but in practice most methods do dereference their receivers. Given that, you have two choices:

1. Check for nil receivers explicitly in every method. This is considered unidiomatic and libraries rarely do this.

2. Don't check for nil receivers, and have your method panic on the first dereference with no explicit check. Then, most of your methods can halt partway through in unexpected (and usually undocumented) ways, unless you pay very close attention to this failure mode.

Furthermore, such panics can occur far down the call stack, making it non-obvious from the stack trace where the error is.

Also, this means that, if you upgrade your library so that a method now dereferences its receiver, your library is suddenly no longer backwards-compatible.

In my honest opinion, you describe a theory problem not happening in production practice.

It is the NORMAL behavior to let it panic when a nil receiver is dereferenced. DON'T try to check it!

To the second point: I think the issue is that you don't want to handle the errors in a function, but just propagate it, you still need to write error handling code, whereas in languages with exceptions you can just not do anything and let the exception propagate. Hence, the extra verbosity. Not checking for error is not an option in go.
What is the way you recommend?
The only way to do it in go is to always handle errors, even if you just want to pass them through. It’s one of the downsides of the language. My recommended way is to use a language with exceptions. They have downsides too, of course. It’s always a trade off, but I personally think the upsides of exceptions outweigh the downsides. At least the better implementations of them do.
Go supports exceptions: panics.
That's not a particularly nice implementation of exceptions and all libraries treat panics as invariant checks, things that should never happen. So, if you want to treat expected errors (eg. file not found) using panic you simple won't be able to go very far, unless you create some functions to convert all errors into panics. But, even then, panic is not great. They are not typed, so you never know what panics could be raised from a function and using a single deferred function that has to handle all errors whereve it is you want to handle them is quite awkward. Obviously, the reason for those design choices is that panics are really not exceptions.
Why would you have a method that doesn’t touch the receiver? Refactor it into a function.
Dereferencing isn't the only way to touch a pointer:

    func (o *myObject) isNil() bool {
        return o == nil
    }
Right, but if you’re doing a nil check, you obviously won’t be worried about nil deferences.
Either to implement an interface, or because it branches to check if the value is nil before dereferencing it
Re: the second point, see my other comment.
It's not fully general, but crashing and letting the outer system handle the restart can make sense for servers. The system needs to handle restarts anyway.

It usually works okay unless there's a "query of death" causing repeated restarts.

Similarly for command line apps.